diff --git a/docs/docs/partners-courts/jurisdiction-config.md b/docs/docs/partners-courts/jurisdiction-config.md index e5868339..08fe3913 100644 --- a/docs/docs/partners-courts/jurisdiction-config.md +++ b/docs/docs/partners-courts/jurisdiction-config.md @@ -678,3 +678,63 @@ and [Vermont small claims guidance](https://www.vtcourts.gov/civil/suing-and-bei The guidance configuration is cached per web process. Restart the web service after editing it; no filing catalog rebuild or database migration is required. + +## Contextual filing guidance + +Use `filing_guidance` for informational warnings and help that depend on the +filer's saved choices. These messages never require an acknowledgment or block +navigation or submission. Court requirements accepted on Review remain separate. + +Each message appears above the form on its configured workflow steps. Choose a +step before the relevant task: for example, `upload_documents` for statewide +upload help, `document_checklist` or `organize_documents` for court-specific +document instructions, and `payment` for payment help. Include `review` when the +same information is useful before submission. Court-specific help appears only +after a court is saved; use a later step if court selection happens after upload. + +The following example uses **placeholder codes and copy**, not actual court +instructions. Replace them with current provider codes and partner-reviewed text +and links before deploying: + +```yaml +filing_guidance: + - id: local_document_help + steps: [document_checklist, organize_documents, review] + title: "Preparing documents for this court" + text: "Read this court's document instructions if you need help preparing your filing." + when: + court_codes: ["example:division-a", "example:division-b"] + case_type_codes: ["example-case-type"] + existing_case: ["new"] + resources: + - label: "Court document instructions" + url: "https://court.example.org/filing-help" +``` + +- `id`, `steps`, `title`, and `text` are required. IDs must be unique within the + jurisdiction. Titles and text are plain text; HTML is escaped. +- Omit `when` for statewide help. All configured conditions must match; within + each list, any one value matches. Missing selections do not match a condition. +- Supported conditions are `court_codes`, `case_category_codes`, `case_type_codes`, + `case_subtype_codes`, `filing_type_codes`, and `existing_case` (`new` or + `existing`). Code matching is exact. Quote numeric provider codes in YAML. +- A `filing_type_codes` condition matches any saved document, including supporting + documents. It does not use an AI prediction or an obsolete draft-level default. +- To target a county, division, or district, list its current filing court codes + in `court_codes`. Names, prefixes, and inferred geographic matches are not used. + Maintain this list when provider codes change. +- `resources` is optional. Each resource needs a descriptive `label` and an + absolute HTTPS `url`. Links open in the same tab. +- Supported steps are the active keys in `efile/workflow.py` (`FILING_WORKFLOW`). + Messages appear in configuration order. They refresh on the next rendered page + after choices are saved, including when a draft is resumed. They do not update + while a dropdown has an unsaved selection. + +No local court guidance is enabled by default. Add reviewed messages to the +appropriate state's YAML file. Run `uv run python manage.py check` after editing; +invalid configuration produces `efile.W004` and that jurisdiction's guidance is +omitted. Filing remains available. Use +`uv run python manage.py check --fail-level WARNING` to reject these errors in a +configuration review. Run `uv run python manage.py extract_config_text` and then +`uv run python manage.py makemessages -l es` to include the new copy and link +labels in the existing translation workflow. diff --git a/efile_app/.env.example b/efile_app/.env.example index b707624b..90ec6e89 100644 --- a/efile_app/.env.example +++ b/efile_app/.env.example @@ -35,6 +35,11 @@ EFSP_URL = "https://efile-test.suffolklitlab.org" # the repo-root .env instead. EFSP_TEST_DOCUMENT_URL = "https://raw.githubusercontent.com/SuffolkLITLab/LITEFile/main/testing/sample_test.pdf" +# Local development only. Shows the demonstration filing guidance messages so the +# guidance boxes can be checked in a browser. Leave unset to show only the +# guidance configured for each state. +# FILING_GUIDANCE_DEMO_FILE = "../testing/filing-guidance-demo.yaml" + # AWS S3 Configuration AWS_S3_ENDPOINT_URL = "http://localstack:4566" # if mocking S3 locally, see [testing README](../testing/README.md). AWS_ACCESS_KEY_ID = "your-aws-access-key-id-here" diff --git a/efile_app/efile/checks.py b/efile_app/efile/checks.py index 44066f83..e35aec66 100644 --- a/efile_app/efile/checks.py +++ b/efile_app/efile/checks.py @@ -98,3 +98,20 @@ def configured_ui_text_keys_are_known(app_configs, **kwargs): ) ) return problems + + +@register() +def configured_filing_guidance_is_valid(app_configs, **kwargs): + """Report invalid informational rules before partners deploy their copy.""" + from efile.services.filing_guidance import guidance_errors + from efile.utils.config_loader import config_loader + + return [ + Warning( + f"{jurisdiction}.yaml: {message}", + hint="Correct filing_guidance; invalid guidance is omitted, and never blocks a filing.", + id="efile.W004", + ) + for jurisdiction in config_loader.get_available_jurisdictions() + for message in guidance_errors(config_loader.load_jurisdiction_config(jurisdiction)) + ] diff --git a/efile_app/efile/management/commands/extract_config_text.py b/efile_app/efile/management/commands/extract_config_text.py index 66eccb4f..5c7bf439 100644 --- a/efile_app/efile/management/commands/extract_config_text.py +++ b/efile_app/efile/management/commands/extract_config_text.py @@ -22,6 +22,7 @@ from django.core.management.base import BaseCommand, CommandError +from efile.services.filing_guidance import guidance_errors, guidance_strings from efile.utils.config_loader import config_loader from efile.utils.ui_text import UI_STRINGS, config_overrides @@ -54,11 +55,17 @@ def _render(): for jurisdiction in config_loader.get_available_jurisdictions(): config = config_loader.load_jurisdiction_config(jurisdiction) or {} overrides = {key: value for key, value in config_overrides(config).items() if key in UI_STRINGS} + errors = guidance_errors(config) + if errors: + raise CommandError(f"{jurisdiction}.yaml: {'; '.join(errors)}") + overrides.update(guidance_strings(config)) if not overrides: continue lines.append(f" # {jurisdiction}.yaml\n") for key, value in sorted(overrides.items()): - description = UI_STRINGS[key].description + description = ( + UI_STRINGS[key].description if key in UI_STRINGS else "Jurisdiction-maintained filing guidance." + ) if description: lines.append(f" # Translators: {description}\n") # Written the way ruff would format it -- double quotes, one argument diff --git a/efile_app/efile/services/filing_guidance.py b/efile_app/efile/services/filing_guidance.py new file mode 100644 index 00000000..f374581b --- /dev/null +++ b/efile_app/efile/services/filing_guidance.py @@ -0,0 +1,179 @@ +"""Informational, jurisdiction-maintained help matched to saved filing choices.""" + +import logging +import os +from urllib.parse import urlsplit + +import yaml +from django.conf import settings +from django.utils.translation import pgettext + +from efile.utils.config_loader import config_loader + +logger = logging.getLogger(__name__) + +# Exact provider codes, not names inferred from a document or a court caption. +DRAFT_CONDITIONS = { + "court_codes": "court_code", + "case_category_codes": "case_category_code", + "case_type_codes": "case_type_code", + "case_subtype_codes": "case_subtype_code", + "existing_case": "existing_case", +} +CONDITION_KEYS = {*DRAFT_CONDITIONS, "filing_type_codes"} +RULE_KEYS = {"id", "steps", "title", "text", "when", "resources"} + +# jurisdiction -> (loaded config, demo file version, valid rules or None) +_checked_rules = {} + + +def _strings(value): + return isinstance(value, list) and bool(value) and all(isinstance(item, str) and item.strip() for item in value) + + +def _resource_is_valid(resource): + if not isinstance(resource, dict) or set(resource) != {"label", "url"}: + return False + if not all(isinstance(resource[key], str) and resource[key].strip() for key in ("label", "url")): + return False + try: + url = urlsplit(resource["url"]) + return url.scheme == "https" and bool(url.hostname) and not url.username and not url.password + except ValueError: + return False + + +def guidance_errors(config): + """Validate authoring errors without treating guidance as a filing requirement.""" + from efile.workflow import FILING_WORKFLOW + + rules = config.get("filing_guidance", []) + if not isinstance(rules, list): + return ["filing_guidance must be a list."] + steps = {step.key for step in FILING_WORKFLOW} + errors = [] + identifiers = set() + for index, rule in enumerate(rules): + prefix = f"filing_guidance[{index}]" + if not isinstance(rule, dict): + errors.append(f"{prefix} must be a mapping.") + continue + if set(rule) - RULE_KEYS: + errors.append( + f"{prefix} contains unknown keys: {', '.join(sorted(str(key) for key in set(rule) - RULE_KEYS))}." + ) + for key in ("id", "title", "text"): + if not isinstance(rule.get(key), str) or not rule[key].strip(): + errors.append(f"{prefix}.{key} must be nonempty text.") + identifier = rule.get("id") + if isinstance(identifier, str): + if identifier in identifiers: + errors.append(f"{prefix}.id duplicates {identifier}.") + identifiers.add(identifier) + if not _strings(rule.get("steps")) or not set(rule["steps"]).issubset(steps): + errors.append(f"{prefix}.steps must list active workflow step keys.") + conditions = rule.get("when", {}) + if not isinstance(conditions, dict): + errors.append(f"{prefix}.when must be a mapping.") + else: + for key, values in conditions.items(): + if key not in CONDITION_KEYS or not _strings(values): + errors.append(f"{prefix}.when.{key} must be a supported condition with a nonempty list of strings.") + elif key == "existing_case" and not set(values).issubset({"new", "existing"}): + errors.append(f"{prefix}.when.existing_case accepts only new and existing.") + resources = rule.get("resources", []) + if not isinstance(resources, list) or not all(_resource_is_valid(resource) for resource in resources): + errors.append(f"{prefix}.resources must contain label and absolute HTTPS url pairs.") + return errors + + +def guidance_strings(config): + """Strings shared by runtime translation and the config extraction command.""" + for rule in config.get("filing_guidance", []): + prefix = f"filing_guidance.{rule['id']}" + for field in ("title", "text"): + yield f"{prefix}.{field}", rule[field] + for index, resource in enumerate(rule.get("resources", [])): + yield f"{prefix}.resources.{index}.label", resource["label"] + + +def _demo_file_version(): + path = getattr(settings, "FILING_GUIDANCE_DEMO_FILE", "") + if not path: + return None + try: + stat = os.stat(path) + except OSError: + return (path, None) + return (path, stat.st_mtime_ns, stat.st_size) + + +def _demo_rules(version, jurisdiction): + """Local demonstration guidance, kept out of the deployed jurisdiction files.""" + if version is None: + return [] + try: + with open(version[0]) as demo_file: + rules = (yaml.safe_load(demo_file) or {}).get(jurisdiction, []) + except (OSError, yaml.YAMLError, AttributeError): + logger.warning("Could not read the filing guidance demo file %s", version[0]) + return [] + return rules if isinstance(rules, list) else [rules] + + +def _rules(jurisdiction): + """The jurisdiction's guidance rules, validated once per loaded config.""" + config = config_loader.load_jurisdiction_config(jurisdiction) + demo_version = _demo_file_version() + checked = _checked_rules.get(jurisdiction) + if checked and checked[0] is config and checked[1] == demo_version: + return checked[2] + rules = config.get("filing_guidance", []) + demo = _demo_rules(demo_version, jurisdiction) + if demo: + rules = [*rules, *demo] if isinstance(rules, list) else rules + errors = guidance_errors({"filing_guidance": rules}) + if errors: + # Invalid informational copy must not prevent a filer from proceeding. + # The system check reports it to maintainers before deployment. + logger.warning("Invalid filing guidance for %s: %s", jurisdiction, "; ".join(errors)) + rules = None + _checked_rules[jurisdiction] = (config, demo_version, rules) + return rules + + +def filing_guidance(draft, step, *, jurisdiction=None): + if draft is None: + return [] + rules = _rules(jurisdiction or draft.jurisdiction) + if not rules: + return [] + matches = [] + document_codes = None + for rule in rules: + if step not in rule["steps"]: + continue + conditions = rule.get("when", {}) + if any( + getattr(draft, field, "") not in conditions[key] + for key, field in DRAFT_CONDITIONS.items() + if key in conditions + ): + continue + if "filing_type_codes" in conditions: + if document_codes is None: + document_codes = set(draft.documents.values_list("filing_type_code", flat=True)) - {""} + if not document_codes.intersection(conditions["filing_type_codes"]): + continue + prefix = f"filing_guidance.{rule['id']}" + matches.append( + { + "title": pgettext(f"{prefix}.title", rule["title"]), + "text": pgettext(f"{prefix}.text", rule["text"]), + "resources": [ + {"label": pgettext(f"{prefix}.resources.{index}.label", resource["label"]), "url": resource["url"]} + for index, resource in enumerate(rule.get("resources", [])) + ], + } + ) + return matches diff --git a/efile_app/efile/settings_dev.py b/efile_app/efile/settings_dev.py index 11fe1760..16439400 100644 --- a/efile_app/efile/settings_dev.py +++ b/efile_app/efile/settings_dev.py @@ -88,6 +88,10 @@ # and URLs; only the URL handed to the proxy changes. Defined here rather than in # settings_base so no environment variable can enable it outside development. EFSP_TEST_DOCUMENT_URL = os.getenv("EFSP_TEST_DOCUMENT_URL", "").strip() +# Extra filing guidance for local validation, such as +# ../testing/filing-guidance-demo.yaml. Development only, like the setting above, +# so demonstration messages can never be shown to filers on a deployed site. +FILING_GUIDANCE_DEMO_FILE = os.getenv("FILING_GUIDANCE_DEMO_FILE", "").strip() CSRF_TRUSTED_ORIGINS = [ "http://localhost", "http://127.0.0.1", diff --git a/efile_app/efile/templates/efile/case_confirmation.html b/efile_app/efile/templates/efile/case_confirmation.html index 12e7603e..d4456b13 100644 --- a/efile_app/efile/templates/efile/case_confirmation.html +++ b/efile_app/efile/templates/efile/case_confirmation.html @@ -13,6 +13,7 @@
{% translate "Check the details before you add this filing to the case." %}
+ {% include "efile/partials/filing_guidance.html" %}{% translate "Choose the court and enter the case number exactly as it appears on your documents." %}
diff --git a/efile_app/efile/templates/efile/case_questions.html b/efile_app/efile/templates/efile/case_questions.html index fe1bee5f..69e6b3ee 100644 --- a/efile_app/efile/templates/efile/case_questions.html +++ b/efile_app/efile/templates/efile/case_questions.html @@ -8,6 +8,7 @@{% translate "The court needs these answers for the case type you selected." %}
{% translate "We will email you when the court accepts, returns, or asks for changes to this filing." %}
diff --git a/efile_app/efile/templates/efile/document_checklist.html b/efile_app/efile/templates/efile/document_checklist.html index 0f2204cc..f6a3811e 100644 --- a/efile_app/efile/templates/efile/document_checklist.html +++ b/efile_app/efile/templates/efile/document_checklist.html @@ -8,6 +8,7 @@{% translate "Review the files below. Add anything else you want the court to get with this filing." %}
diff --git a/efile_app/efile/templates/efile/extraction_review.html b/efile_app/efile/templates/efile/extraction_review.html index 7889675b..838c130c 100644 --- a/efile_app/efile/templates/efile/extraction_review.html +++ b/efile_app/efile/templates/efile/extraction_review.html @@ -18,6 +18,7 @@ {% ui_text "extraction_review.case_category_help" as case_category_help %} {% if has_guesses %}{% if ai_opted_out %} {% translate "You turned AI off, so we searched the text of your document for a form number and a case number instead. These are clues from the document, not answers you gave us, and the search can be wrong. Correct anything that is." %} @@ -94,6 +95,7 @@
{% translate "Choose the option that best describes your filing. You can change this after we review your documents." %}
diff --git a/efile_app/efile/templates/efile/filing_unavailable.html b/efile_app/efile/templates/efile/filing_unavailable.html index 82ef67a2..46021a47 100644 --- a/efile_app/efile/templates/efile/filing_unavailable.html +++ b/efile_app/efile/templates/efile/filing_unavailable.html @@ -6,6 +6,7 @@ {% block workflow_content %}{% translate "Tell the court what each PDF is and if it should be public or confidential. You can also rename and reorder additional documents." %}
diff --git a/efile_app/efile/templates/efile/partials/filing_guidance.html b/efile_app/efile/templates/efile/partials/filing_guidance.html new file mode 100644 index 00000000..49baeeb1 --- /dev/null +++ b/efile_app/efile/templates/efile/partials/filing_guidance.html @@ -0,0 +1,14 @@ +{% for guidance in filing_guidance %} + +{% endfor %} diff --git a/efile_app/efile/templates/efile/parties.html b/efile_app/efile/templates/efile/parties.html index eaf3a24b..eb5e0d76 100644 --- a/efile_app/efile/templates/efile/parties.html +++ b/efile_app/efile/templates/efile/parties.html @@ -9,6 +9,7 @@{% if case_has_parties %} {% translate "You already told us who is in this case. The one thing left is your own part in it — the list below is here to check, not to fill in again." %} diff --git a/efile_app/efile/templates/efile/party_details.html b/efile_app/efile/templates/efile/party_details.html index 8391b269..b755e4bb 100644 --- a/efile_app/efile/templates/efile/party_details.html +++ b/efile_app/efile/templates/efile/party_details.html @@ -15,6 +15,7 @@
{% translate "Enter this party's court role and name. Add a mailing address if you know it or the court requires it." %}
diff --git a/efile_app/efile/templates/efile/payment.html b/efile_app/efile/templates/efile/payment.html index b40e0c01..3021aefc 100644 --- a/efile_app/efile/templates/efile/payment.html +++ b/efile_app/efile/templates/efile/payment.html @@ -11,6 +11,7 @@{% translate "The court will get these copies. Open each PDF and check every page before you continue." %}
{% translate "If a file looks wrong, go back and replace it." %}
{% if preview_error %}{{ preview_error }}
{% endif %} diff --git a/efile_app/efile/templates/efile/review.html b/efile_app/efile/templates/efile/review.html index e261125e..ef3b767c 100644 --- a/efile_app/efile/templates/efile/review.html +++ b/efile_app/efile/templates/efile/review.html @@ -11,6 +11,7 @@{% translate "Check each section carefully. Use Edit to go back to the screen where you entered it." %}
diff --git a/efile_app/efile/templates/efile/upload_documents.html b/efile_app/efile/templates/efile/upload_documents.html index 30bcca7f..c03aab08 100644 --- a/efile_app/efile/templates/efile/upload_documents.html +++ b/efile_app/efile/templates/efile/upload_documents.html @@ -9,6 +9,7 @@
{% translate "New or existing case" %}
diff --git a/efile_app/efile/templates/efile/your_information.html b/efile_app/efile/templates/efile/your_information.html
index 4f7d7331..0c5086da 100644
--- a/efile_app/efile/templates/efile/your_information.html
+++ b/efile_app/efile/templates/efile/your_information.html
@@ -10,6 +10,7 @@
{% ui_text "your_information.lede" %}{% translate "Your information" %}
+ {% include "efile/partials/filing_guidance.html" %}