diff --git a/efile_app/efile/api/dropdown_views.py b/efile_app/efile/api/dropdown_views.py index 1a8c3c31..9fb320db 100644 --- a/efile_app/efile/api/dropdown_views.py +++ b/efile_app/efile/api/dropdown_views.py @@ -5,6 +5,7 @@ """ import logging +import re import requests from django.conf import settings @@ -13,14 +14,34 @@ from efile.services.efsp_payload import parse_optional_services from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request -from ..utils.str_dist import levenshtein_distance from ..utils.zip_to_county_il import get_county_by_zip from .base import APIResponseMixin logger = logging.getLogger(__name__) -# The maximum "string distance" that options should be from the guessed value to be "recommended" -MAX_LEV_DIST = 5 +# Words that appear in nearly every Illinois court name, so they say nothing +# about which county a guess is pointing at. +_COURT_NOISE_WORDS = frozenset( + {"circuit", "county", "court", "courts", "division", "illinois", "in", "judicial", "of", "the"} +) + + +def _county_tokens(text): + """Split a court name into the words that could name a county.""" + words = re.split(r"[^a-z0-9]+", str(text or "").lower()) + return [word for word in words if word and word not in _COURT_NOISE_WORDS] + + +def _county_keys(text): + """Every run of whole words in a court name, joined the way court codes are. + + Court codes drop the spaces inside a county name ("stclair", "rockisland"), + so a run of whole words is the smallest unit worth comparing. Comparing runs + instead of raw substrings is what keeps "Henry County" from matching a + document that said "McHenry County". + """ + tokens = _county_tokens(text) + return {"".join(tokens[start:end]) for start in range(len(tokens)) for end in range(start + 1, len(tokens) + 1)} def prioritize_options(api_data, guessed): @@ -51,17 +72,16 @@ def prioritize_options(api_data, guessed): option_text = opt.get("text", "").lower().strip() # Direct value match (e.g., 'cook' matches 'cook') or text match (e.g., 'Cook County' matches 'cook') + # Edit distance used to count as a match here, but at any threshold loose + # enough to forgive a typo it also pairs unrelated options ("Motion" and + # "Notice" are 3 edits apart), and the marker below claims the document + # actually said so. is_match = option_text == guessed_norm or option_text in guessed_norm or guessed_norm in option_text - if not is_match: - dist = levenshtein_distance(option_text, guessed_norm) - if dist <= MAX_LEV_DIST: - is_match = True - if is_match: - # Mark as default/recommended court with recommended text + # Mark matches from document extraction with a compact marker. opt_copy = opt.copy() - opt_copy["text"] = f"{opt['text']} (Recommended)" + opt_copy["text"] = f"{opt['text']} *" prioritized_options.append(opt_copy) else: other_options.append(opt) @@ -429,7 +449,9 @@ def get_courts(request): ] logger.debug("Returning fallback courts") return DropdownAPIViews.success_response( - DropdownAPIViews._prioritize_courts_by_location(fallback_courts, user_zip, user_county) + DropdownAPIViews._prioritize_courts_by_location( + fallback_courts, guessed_court, user_zip, user_county + ) ) except Exception as e: @@ -445,56 +467,71 @@ def _prioritize_courts_by_location(courts, guessed_court="", user_zip=None, user if not courts: return courts - # Determine user county from zip code if provided - target_county = user_county or guessed_court + # Location data controls location recommendations. Keep the extracted + # court guess separate so it can receive the extraction marker below. + target_county = user_county if user_zip and not target_county: target_county = get_county_by_zip(user_zip) - if not target_county: + if not target_county and not guessed_court: return courts - guessed_court_norm = guessed_court.lower().replace("court", "").replace("illinois", "") - - # Normalize county name for matching (lowercase, no spaces) - target_county_norm = target_county.lower().replace(" ", "").replace("county", "") + guessed_keys = _county_keys(guessed_court) + guessed_key = "".join(_county_tokens(guessed_court)) + target_key = "".join(_county_tokens(target_county)) # Create prioritized list - prioritized_courts = [] + guessed_courts = [] + exact_guessed_courts = [] + location_courts = [] other_courts = [] for court in courts: - court_value = court.get("value", "").lower().replace("county", "") - court_text = court.get("text", "").lower() - - # Check if this court matches the user's county - is_match = False - - # Direct value match (e.g., 'cook' matches 'cook') or text match (e.g., 'Cook County' matches 'cook') - if court_value == guessed_court_norm or court_value in guessed_court_norm: - is_match = True - elif court_value == target_county_norm or target_county_norm in court_text: - is_match = True - # Special handling for Cook County divisions - elif target_county_norm == "cook" and "cook:" in court_value: - is_match = True - - if is_match: - # Mark as default/recommended court with recommended text + court_value = court.get("value", "").lower() + # Cook County's divisions all share a "cook:" prefix, so the county + # is whatever sits in front of the colon. + court_county = court_value.split(":", 1)[0].replace(" ", "") + court_key = "".join(_county_tokens(court.get("text", ""))) + court_keys = _county_keys(court.get("text", "")) | {court_county} + + guessed_match = bool(guessed_keys) and court_county in guessed_keys + location_match = bool(target_key) and target_key in court_keys + + if guessed_match: + # A document match gets the extraction marker, and comes first: + # the document is better evidence of the court than a zip code. + court_copy = court.copy() + court_copy["text"] = f"{court['text']} *" + guessed_courts.append(court_copy) + if court_key == guessed_key: + exact_guessed_courts.append(court_copy) + elif location_match: + # Location-only recommendations keep their existing wording. court_copy = court.copy() court_copy["text"] = f"{court['text']} (Recommended)" - prioritized_courts.append(court_copy) + location_courts.append(court_copy) else: other_courts.append(court) - # Mark only the first prioritized court as selected/default - final_courts = prioritized_courts + other_courts - if prioritized_courts: - # Mark the first recommended court as selected using multiple flag approaches - final_courts[0]["selected"] = True - final_courts[0]["default"] = True - final_courts[0]["recommended"] = True - - return final_courts + # Only pre-select a court the evidence actually singles out. A caption + # reading "Circuit Court of Cook County" matches every Cook division, + # and picking one of them for the filer would be a guess the document + # never made. Leave those at the top of the list and let them choose. + selected_court = None + if len(guessed_courts) == 1: + selected_court = guessed_courts[0] + elif len(exact_guessed_courts) == 1: + selected_court = exact_guessed_courts[0] + elif not guessed_courts and location_courts: + selected_court = location_courts[0] + + if selected_court is not None: + # Mark the recommended court as selected using multiple flag approaches + selected_court["selected"] = True + selected_court["default"] = True + selected_court["recommended"] = True + + return guessed_courts + location_courts + other_courts @staticmethod @require_http_methods(["GET"]) diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css index 2566c404..65cf029b 100644 --- a/efile_app/efile/static/css/reorganized-flow.css +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -472,6 +472,50 @@ padding: 1.25rem; } +.extraction-page-help[hidden] { + display: none; +} + +.extraction-page-help { + align-items: center; + color: var(--text-muted); + display: flex; + font-size: 0.9rem; + gap: 0.45rem; + margin: -0.8rem 0 1.25rem; +} + +.extraction-marker-help { + align-items: center; + background: var(--surface-accent); + border: 1px solid var(--border-accent); + border-radius: 50%; + color: var(--better-blue); + display: inline-flex; + flex: 0 0 auto; + height: 1.75rem; + justify-content: center; + padding: 0; + width: 1.75rem; +} + +.extraction-marker-help:hover, +.extraction-marker-help:focus-visible { + background: #fff; + border-color: var(--better-blue); +} + +.extraction-section-heading { + align-items: center; + display: flex; + gap: 0.55rem; + justify-content: space-between; +} + +.extraction-section-heading h2 { + margin-bottom: 0; +} + .extracted-details h2, .confirm-filing-details-heading { color: var(--text-heading); diff --git a/efile_app/efile/static/js/api-utils.js b/efile_app/efile/static/js/api-utils.js index 55003f70..6c74c4b1 100644 --- a/efile_app/efile/static/js/api-utils.js +++ b/efile_app/efile/static/js/api-utils.js @@ -39,6 +39,10 @@ class ApiUtils { return null; } + cleanOptionText(value) { + return String(value || "").replace(/ \(Recommended\)$/, "").replace(/ \*$/, ""); + } + getCache() { try { const cached = localStorage.getItem('apiResponseCache'); diff --git a/efile_app/efile/static/js/case-lookup.js b/efile_app/efile/static/js/case-lookup.js index 6a321a2c..3d528033 100644 --- a/efile_app/efile/static/js/case-lookup.js +++ b/efile_app/efile/static/js/case-lookup.js @@ -8,6 +8,7 @@ const errorBox = document.getElementById("lookup-error"); const submitButton = document.getElementById("find-case-button"); const guessedCourt = JSON.parse(document.getElementById("guessed-court").textContent || '""'); + const extractionHelp = document.getElementById("court-extraction-help"); const selectedCourtCode = JSON.parse(document.getElementById("selected-court-code").textContent || '""'); async function loadCourts() { @@ -18,15 +19,20 @@ }); if (!response.success) throw new Error(response.error || "Could not load courts."); courtSelect.innerHTML = ''; + let hasMarkedCourt = false; response.data.forEach((court) => { const option = document.createElement("option"); option.value = court.value; option.textContent = court.text; + // The guess only earns a marker when it matches a real court, so the + // help text that explains the marker waits for one to show up. + if (String(court.text || "").trim().endsWith("*")) hasMarkedCourt = true; if (court.value === selectedCourtCode || (!selectedCourtCode && (court.selected || court.default))) { option.selected = true; } courtSelect.appendChild(option); }); + if (extractionHelp) extractionHelp.hidden = !hasMarkedCourt; } catch (error) { courtSelect.innerHTML = ''; errorBox.textContent = error.message; @@ -61,7 +67,7 @@ }, body: JSON.stringify({ court: courtSelect.value, - court_name: selectedCourt?.textContent?.replace(" (Recommended)", "") || "", + court_name: apiUtils.cleanOptionText(selectedCourt?.textContent), case_tracking_id: caseInfo.caseTrackingID, case_docket_id: caseInfo.caseDocketID || caseNumber.value.trim(), case_title: caseInfo.caseTitle || "", diff --git a/efile_app/efile/static/js/extraction-review.js b/efile_app/efile/static/js/extraction-review.js index c882b0c5..c210afd0 100644 --- a/efile_app/efile/static/js/extraction-review.js +++ b/efile_app/efile/static/js/extraction-review.js @@ -66,7 +66,7 @@ } function optionText(item) { - return (item.text || item.name || optionValue(item)).replace(/ \(Recommended\)$/, ""); + return item.text || item.name || optionValue(item); } async function getJson(url) { @@ -132,8 +132,9 @@ if (chosen) { field.select.value = chosen.value; - field.nameInput.value = chosen.textContent; - field.valueEl.textContent = chosen.textContent; + const chosenText = apiUtils.cleanOptionText(chosen.textContent); + field.nameInput.value = chosenText; + field.valueEl.textContent = chosenText + (chosen.textContent.trim().endsWith("*") ? " *" : ""); setMode(key, "found"); await ADVANCE[key](); } else { @@ -307,19 +308,19 @@ }; fields.court.select.addEventListener("change", () => { - fields.court.nameInput.value = fields.court.select.selectedOptions[0]?.textContent || ""; + fields.court.nameInput.value = apiUtils.cleanOptionText(fields.court.select.selectedOptions[0]?.textContent); loadCaseCategories(); }); fields.case_category.select.addEventListener("change", () => { - fields.case_category.nameInput.value = fields.case_category.select.selectedOptions[0]?.textContent || ""; + fields.case_category.nameInput.value = apiUtils.cleanOptionText(fields.case_category.select.selectedOptions[0]?.textContent); loadCaseTypes(); }); fields.case_type.select.addEventListener("change", () => { - fields.case_type.nameInput.value = fields.case_type.select.selectedOptions[0]?.textContent || ""; + fields.case_type.nameInput.value = apiUtils.cleanOptionText(fields.case_type.select.selectedOptions[0]?.textContent); loadFilingTypesAndRoles(); }); fields.filing_type.select.addEventListener("change", () => { - fields.filing_type.nameInput.value = fields.filing_type.select.selectedOptions[0]?.textContent || ""; + fields.filing_type.nameInput.value = apiUtils.cleanOptionText(fields.filing_type.select.selectedOptions[0]?.textContent); loadFilerRoles(); }); diff --git a/efile_app/efile/static/js/organize-documents.js b/efile_app/efile/static/js/organize-documents.js index 5dfd62d9..1bd1f8f5 100644 --- a/efile_app/efile/static/js/organize-documents.js +++ b/efile_app/efile/static/js/organize-documents.js @@ -444,7 +444,7 @@ id: Number(card.dataset.documentId), name: card.querySelector(".document-name").value, filing_type: filingType.value, - filing_type_name: filingType.selectedOptions[0]?.text || "", + filing_type_name: apiUtils.cleanOptionText(filingType.selectedOptions[0]?.text), document_type: documentType?.value || "", document_type_name: documentType?.dataset.optionText || "", filing_component: component?.value || "", diff --git a/efile_app/efile/templates/efile/case_lookup.html b/efile_app/efile/templates/efile/case_lookup.html index b15a6587..248d6d9d 100644 --- a/efile_app/efile/templates/efile/case_lookup.html +++ b/efile_app/efile/templates/efile/case_lookup.html @@ -11,6 +11,13 @@

{% translate "Find your court case" %}

{% translate "Choose the court and enter the case number exactly as it appears on your documents." %}

+ {% if guessed_court %} + {# Revealed by case-lookup.js only once a court in the list actually carries the marker. #} + + {% endif %}
{% csrf_token %}
diff --git a/efile_app/efile/templates/efile/components/extraction_marker_help.html b/efile_app/efile/templates/efile/components/extraction_marker_help.html new file mode 100644 index 00000000..a356f877 --- /dev/null +++ b/efile_app/efile/templates/efile/components/extraction_marker_help.html @@ -0,0 +1,9 @@ +{% load i18n %} + diff --git a/efile_app/efile/templates/efile/components/extraction_marker_modal.html b/efile_app/efile/templates/efile/components/extraction_marker_modal.html new file mode 100644 index 00000000..d4a94736 --- /dev/null +++ b/efile_app/efile/templates/efile/components/extraction_marker_modal.html @@ -0,0 +1,22 @@ +{% load i18n %} + diff --git a/efile_app/efile/templates/efile/extraction_review.html b/efile_app/efile/templates/efile/extraction_review.html index 05bfcd43..c500b007 100644 --- a/efile_app/efile/templates/efile/extraction_review.html +++ b/efile_app/efile/templates/efile/extraction_review.html @@ -22,7 +22,10 @@

{% translate "Check what we read from your document" %}

{% endif %}

-

{% translate "The document we read" %}

+
+

{% translate "The document we read" %}

+ {% include "efile/components/extraction_marker_help.html" %} +
{% if document_summary_details %}
{% for detail in document_summary_details %} diff --git a/efile_app/efile/templates/efile/organize_documents.html b/efile_app/efile/templates/efile/organize_documents.html index f97738b9..f7ad40c8 100644 --- a/efile_app/efile/templates/efile/organize_documents.html +++ b/efile_app/efile/templates/efile/organize_documents.html @@ -12,6 +12,12 @@

{% translate "Organize your documents" %}

{% translate "Tell the court what each PDF is and if it should be public or confidential. You can also rename and reorder additional documents." %}

+ {% if organize_context.guessed_filing_type %} +
+ {% translate "Some choices may be marked with an asterisk." %} + {% include "efile/components/extraction_marker_help.html" %} +
+ {% endif %} {% csrf_token %} diff --git a/efile_app/efile/templates/efile/review.html b/efile_app/efile/templates/efile/review.html index ae60353f..123cabd7 100644 --- a/efile_app/efile/templates/efile/review.html +++ b/efile_app/efile/templates/efile/review.html @@ -14,6 +14,12 @@

{% translate "Review your filing" %}

{% translate "Check each section carefully. Use Edit to go back to the screen where you entered it." %}

+ {% if extracted_markers.has_any %} +
+ {% translate "Some values may be marked with an asterisk because they came from your document." %} + {% include "efile/components/extraction_marker_help.html" %} +
+ {% endif %}
@@ -32,7 +38,7 @@

{% translate "Case" %}

{% translate "Case name" %}
- {{ draft.case_title }} + {{ draft.case_title }}{% if extracted_markers.case_title %} *{% endif %}
{% endif %} @@ -40,26 +46,26 @@

{% translate "Case" %}

{% translate "Case number" %}
- {{ draft.docket_number }} + {{ draft.docket_number }}{% if extracted_markers.docket_number %} *{% endif %}
{% endif %}
{% translate "Court" %}
- {{ draft.court_name|default:draft.court_code }} + {{ draft.court_name|default:draft.court_code }}{% if extracted_markers.court %} *{% endif %}
{% translate "Category" %}
- {{ draft.case_category_name|default:draft.case_category_code }} + {{ draft.case_category_name|default:draft.case_category_code }}{% if extracted_markers.case_category %} *{% endif %}
{% translate "Case type" %}
- {{ draft.case_type_name|default:draft.case_type_code }} + {{ draft.case_type_name|default:draft.case_type_code }}{% if extracted_markers.case_type %} *{% endif %}
@@ -85,7 +91,7 @@

{% translate "Documents" %}

{% if document.document_type_name %}ยท {{ document.document_type_name }}{% endif %} {% if document.filing_type_name %} - {% translate "Filing type:" %} {{ document.filing_type_name }} + {% translate "Filing type:" %} {{ document.filing_type_name }}{% if document.id in extracted_markers.document_ids %} *{% endif %} {% endif %}
@@ -99,7 +105,9 @@

{% translate "Your information" %}

aria-label="{% translate "Edit your information" %}">{% translate "Edit" %} {% if filer %} -

{{ filer.first_name }} {{ filer.middle_name }} {{ filer.last_name }}

+

+ {{ filer.first_name }} {{ filer.middle_name }} {{ filer.last_name }}{% if filer.id in extracted_markers.party_ids %} *{% endif %} +

{{ filer.address_line_1 }} {% if filer.address_line_2 %}, {{ filer.address_line_2 }}{% endif %} @@ -149,7 +157,7 @@

{% translate "Other people" %}

{{ party.organization_name }} {% else %} {{ party.first_name }} {{ party.middle_name }} {{ party.last_name }} - {% endif %} + {% endif %}{% if party.id in extracted_markers.party_ids %} *{% endif %} {{ party.party_type_name|default:party.party_type }} diff --git a/efile_app/efile/templates/efile/workflow_base.html b/efile_app/efile/templates/efile/workflow_base.html index 00601fdb..5b809d5a 100644 --- a/efile_app/efile/templates/efile/workflow_base.html +++ b/efile_app/efile/templates/efile/workflow_base.html @@ -27,6 +27,7 @@ {% block workflow_content %} {% endblock workflow_content %} + {% include "efile/components/extraction_marker_modal.html" %} {% include "efile/components/footer.html" %} diff --git a/efile_app/efile/tests/test_filing_types_amount_in_controversy.py b/efile_app/efile/tests/test_filing_types_amount_in_controversy.py index d0e9a400..2b75943e 100644 --- a/efile_app/efile/tests/test_filing_types_amount_in_controversy.py +++ b/efile_app/efile/tests/test_filing_types_amount_in_controversy.py @@ -6,7 +6,7 @@ import pytest from django.urls import reverse -from efile.api.dropdown_views import prioritize_options +from efile.api.dropdown_views import DropdownAPIViews, prioritize_options from efile.models import FilingDraft from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY @@ -26,6 +26,125 @@ def test_prioritize_options_keeps_extra_fields_from_the_court(): assert answer["amountincontroversy"] == "NotApplicable" +def test_prioritize_options_marks_document_matches_with_an_asterisk(): + options = prioritize_options( + [{"code": "PET", "name": "Petition"}, {"code": "ANS", "name": "Answer"}], + guessed="Petition", + ) + + petition = next(opt for opt in options if opt["value"] == "PET") + assert petition["text"] == "Petition *" + assert "Recommended" not in petition["text"] + + +def test_prioritize_options_leaves_merely_similar_options_unmarked(): + """Three edits separate Motion from Notice, and the marker would claim the document said so.""" + options = prioritize_options( + [{"code": "MOT", "name": "Motion"}, {"code": "NOT", "name": "Notice"}], + guessed="Motion", + ) + + notice = next(opt for opt in options if opt["value"] == "NOT") + assert notice["text"] == "Notice" + assert not notice.get("selected") + + +def test_guessed_court_uses_extraction_marker_without_location_recommendation(): + courts = [ + {"value": "cook:law1", "text": "Cook County Law Division"}, + {"value": "will:law1", "text": "Will County Law Division"}, + ] + + options = DropdownAPIViews._prioritize_courts_by_location(courts, guessed_court="Cook County") + + assert options[0]["text"] == "Cook County Law Division *" + assert "Recommended" not in options[0]["text"] + + +def test_guessed_court_matches_whole_county_names_only(): + """A county name inside a longer one must not be picked, let alone auto-selected.""" + courts = [ + {"value": "henry", "text": "Henry County"}, + {"value": "mchenry", "text": "McHenry County"}, + {"value": "will", "text": "Will County"}, + ] + + options = DropdownAPIViews._prioritize_courts_by_location(courts, guessed_court="McHenry County") + + marked = [court for court in options if court["text"].endswith("*")] + assert [court["value"] for court in marked] == ["mchenry"] + assert options[0]["value"] == "mchenry" + assert options[0]["selected"] is True + assert not any(court.get("selected") for court in options[1:]) + + +def test_guessed_court_matches_a_county_named_inside_a_full_court_name(): + """The extracted guess is usually the caption, not the court code.""" + courts = [ + {"value": "cook:law1", "text": "Cook County Law Division"}, + {"value": "will", "text": "Will County"}, + ] + + options = DropdownAPIViews._prioritize_courts_by_location( + courts, guessed_court="Circuit Court of Cook County, Illinois" + ) + + assert options[0]["text"] == "Cook County Law Division *" + + +def test_guessed_court_matches_a_multi_word_county_written_with_spaces(): + courts = [ + {"value": "stclair", "text": "St. Clair County"}, + {"value": "clark", "text": "Clark County"}, + ] + + options = DropdownAPIViews._prioritize_courts_by_location(courts, guessed_court="St. Clair County Circuit Court") + + assert options[0]["text"] == "St. Clair County *" + assert not options[1]["text"].endswith("*") + + +def test_an_ambiguous_court_guess_is_prioritized_but_not_chosen_for_the_filer(): + """A caption naming only the county cannot pick between that county's divisions.""" + courts = [ + {"value": "cook:chd1", "text": "Cook County - Chancery"}, + {"value": "cook:law1", "text": "Cook County - Law"}, + {"value": "will", "text": "Will County"}, + ] + + options = DropdownAPIViews._prioritize_courts_by_location( + courts, guessed_court="Circuit Court of Cook County, Illinois" + ) + + assert [court["text"] for court in options[:2]] == ["Cook County - Chancery *", "Cook County - Law *"] + assert not any(court.get("selected") for court in options) + + +def test_a_court_guess_naming_the_division_is_still_chosen(): + courts = [ + {"value": "tazewell", "text": "Tazewell County"}, + {"value": "tazewell:tr", "text": "Tazewell County - Traffic"}, + ] + + options = DropdownAPIViews._prioritize_courts_by_location(courts, guessed_court="Tazewell County - Traffic") + + chosen = [court for court in options if court.get("selected")] + assert [court["value"] for court in chosen] == ["tazewell:tr"] + + +def test_document_match_outranks_a_location_recommendation(): + courts = [ + {"value": "cook:law1", "text": "Cook County Law Division"}, + {"value": "will", "text": "Will County"}, + ] + + options = DropdownAPIViews._prioritize_courts_by_location(courts, guessed_court="Will County", user_county="Cook") + + assert options[0]["text"] == "Will County *" + assert options[1]["text"] == "Cook County Law Division (Recommended)" + assert options[0]["selected"] is True + + class _FilingTypesResponse: status_code = 200 headers = {"Content-Type": "application/json"} diff --git a/efile_app/efile/tests/test_review_submit_flow.py b/efile_app/efile/tests/test_review_submit_flow.py index 6f22437a..ef9e99fd 100644 --- a/efile_app/efile/tests/test_review_submit_flow.py +++ b/efile_app/efile/tests/test_review_submit_flow.py @@ -72,6 +72,57 @@ def test_payment_saves_account_and_advances_durable_step(client, submission_draf assert submission_draft.current_step == WorkflowStepKey.REVIEW +@pytest.mark.django_db +def test_final_review_marks_extracted_values_and_explains_the_marker(client, submission_draft): + submission_draft.case_title = "Jordan Taylor v. Acme" + submission_draft.docket_number = "2026-CV-123" + submission_draft.extracted_guesses = { + "case title": "Jordan Taylor v. Acme", + "docket number": "2026-CV-123", + "court": "Cook County", + "case category": "Civil", + "case type": "Contract", + "filing type": "Petition", + } + submission_draft.selected_payment_account_id = "pay-123" + submission_draft.selected_payment_account_name = "Card ending in 4242" + submission_draft.save( + update_fields=[ + "case_title", + "docket_number", + "extracted_guesses", + "selected_payment_account_id", + "selected_payment_account_name", + "updated_at", + ] + ) + + response = client.get(reverse("case_review", kwargs={"jurisdiction": "illinois"})) + content = response.content.decode() + + assert response.status_code == 200 + assert "Jordan Taylor v. Acme *" in content + assert "A * marks a value that matches what we automatically detected in your document." in content + assert 'data-bs-target="#extraction-marker-modal"' in content + + +@pytest.mark.django_db +def test_final_review_drops_the_marker_from_a_docket_number_the_filer_corrected(client, submission_draft): + submission_draft.docket_number = "2026-CV-123" + submission_draft.extracted_guesses = {"docket number": "2026-CV-1234"} + submission_draft.selected_payment_account_id = "pay-123" + submission_draft.save( + update_fields=["docket_number", "extracted_guesses", "selected_payment_account_id", "updated_at"] + ) + + response = client.get(reverse("case_review", kwargs={"jurisdiction": "illinois"})) + content = response.content.decode() + + assert response.status_code == 200 + assert "2026-CV-123 *" not in content + assert "2026-CV-123" in content + + class _PaymentAccountTypesResponse: status_code = 200 diff --git a/efile_app/efile/utils/str_dist.py b/efile_app/efile/utils/str_dist.py deleted file mode 100644 index a4cc655e..00000000 --- a/efile_app/efile/utils/str_dist.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -A Levenshtein string distance function, for companing different option requests - -https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_full_matrix -""" - - -def levenshtein_distance(str1, str2): - if len(str1) == 0: - return len(str2) - if len(str2) == 0: - return len(str1) - - matrix_rows = len(str1) + 1 - matrix_cols = len(str2) + 1 - - matrix = [[0 for j in range(matrix_cols)] for i in range(matrix_rows)] - - for i in range(1, matrix_rows): - matrix[i][0] = i - - for j in range(1, matrix_cols): - matrix[0][j] = j - - for j in range(1, matrix_cols): - for i in range(1, matrix_rows): - if str1[i - 1] == str2[j - 1]: - sub_cost = 0 - else: - sub_cost = 1 - - prev_row_val = matrix[i - 1][j] - prev_col_val = matrix[i][j - 1] - matrix[i][j] = min(prev_row_val + 1, prev_col_val + 1, matrix[i - 1][j - 1] + sub_cost) - - return matrix[len(str1)][len(str2)] diff --git a/efile_app/efile/views/case_lookup.py b/efile_app/efile/views/case_lookup.py index 86e076a5..83d00dcf 100644 --- a/efile_app/efile/views/case_lookup.py +++ b/efile_app/efile/views/case_lookup.py @@ -73,7 +73,7 @@ def case_lookup(request, jurisdiction): context = { "is_logged_in": True, "filing_draft": draft_snapshot(draft), - "guessed_court": draft.court_name or (draft.extracted_guesses or {}).get("court", ""), + "guessed_court": (draft.extracted_guesses or {}).get("court", ""), "selected_court_code": draft.court_code, "docket_number": draft.docket_number or (draft.extracted_guesses or {}).get("docket number", ""), } diff --git a/efile_app/efile/views/review.py b/efile_app/efile/views/review.py index 623bb38b..f325be1d 100644 --- a/efile_app/efile/views/review.py +++ b/efile_app/efile/views/review.py @@ -12,6 +12,26 @@ from ..workflow import WorkflowStepKey, get_workflow_context +def _matches_extracted_value(current, extracted, exact=False): + """Return whether a saved value is still the value extraction suggested. + + Pass exact=True for short values where one character is the whole meaning: a + filer who corrects docket 2026-CV-1234 to 2026-CV-123 has not left our guess + in place, and the marker would tell them their document said otherwise. + """ + current_text = " ".join(str(current or "").casefold().split()) + extracted_text = " ".join(str(extracted or "").casefold().split()) + if not current_text or not extracted_text: + return False + if exact: + return current_text == extracted_text + return ( + current_text == extracted_text + or (len(extracted_text) >= 4 and extracted_text in current_text) + or (len(current_text) >= 4 and current_text in extracted_text) + ) + + def case_review(request, jurisdiction): """Render a single read-only summary from the durable draft before submit.""" if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): @@ -36,27 +56,53 @@ def case_review(request, jurisdiction): for key, value in (draft.supplemental_fields or {}).items() if not key.startswith("_") and value not in (None, "") ] - parties = FilingParty.objects.filter(draft=draft) + parties = list(FilingParty.objects.filter(draft=draft).order_by("sort_order", "created_at")) + filer = next((party for party in parties if party.role == "filer"), None) + other_parties = [party for party in parties if party.role != "filer"] + documents = FilingDocument.objects.filter(draft=draft).order_by("role", "sort_order", "created_at") + extracted_guesses = draft.extracted_guesses or {} + extracted_party_text = "; ".join( + str(extracted_guesses.get(key, "")) + for key in ("plaintiff or petitioner names", "defendant or respondent names", "other party names") + ) + extracted_markers = { + "case_title": _matches_extracted_value(draft.case_title, extracted_guesses.get("case title")), + "docket_number": _matches_extracted_value( + draft.docket_number, extracted_guesses.get("docket number"), exact=True + ), + "court": _matches_extracted_value(draft.court_name, extracted_guesses.get("court")), + "case_category": _matches_extracted_value(draft.case_category_name, extracted_guesses.get("case category")), + "case_type": _matches_extracted_value(draft.case_type_name, extracted_guesses.get("case type")), + "party_ids": { + party.id for party in parties if _matches_extracted_value(party_display_name(party), extracted_party_text) + }, + "document_ids": { + document.id + for document in documents + if _matches_extracted_value(document.filing_type_name, extracted_guesses.get("filing type")) + }, + } + extracted_markers["has_any"] = any( + value for key, value in extracted_markers.items() if key not in {"document_ids", "party_ids"} + ) or bool(extracted_markers["document_ids"] or extracted_markers["party_ids"]) context = { "is_logged_in": True, "case_data": read_case_data(draft), "upload_data": read_upload_data(draft), "filing_draft": draft_snapshot(draft), "draft": draft, - "filer": parties.filter(role="filer").first(), - "parties": parties.exclude(role="filer").order_by("sort_order", "created_at"), + "filer": filer, + "parties": other_parties, # Who the filing is on behalf of, when that is not the filer. Worth # saying out loud on the last screen before submission: a filing sent # under the wrong party's name is not something the filer can undo. - "filing_for": [ - party_display_name(party) - for party in parties.filter(is_filing_party=True).exclude(role="filer").order_by("sort_order", "created_at") - ], + "filing_for": [party_display_name(party) for party in other_parties if party.is_filing_party], # Only shown when it was actually asked for. A filer who is a party in # their own case is reached at their account address, and saying so # here would be one more line of screen for nothing. "notice_email": draft.notice_email, - "documents": FilingDocument.objects.filter(draft=draft).order_by("role", "sort_order", "created_at"), + "documents": documents, + "extracted_markers": extracted_markers, "question_answers": question_answers, # Everything in one envelope reaches the clerk together. This is the # last point at which adding a document is still free and easy, so say diff --git a/efile_app/js-tests/api-utils.test.js b/efile_app/js-tests/api-utils.test.js index 8ab61bb4..f486b83e 100644 --- a/efile_app/js-tests/api-utils.test.js +++ b/efile_app/js-tests/api-utils.test.js @@ -71,6 +71,14 @@ test("reference-data GETs are cached: repeated reads hit the network once", asyn assert.strictEqual(calls(), 1); }); +test("cleanOptionText removes recommendation markers before saving labels", () => { + const { + client + } = makeClient(); + assert.strictEqual(client.cleanOptionText("Cook County *"), "Cook County"); + assert.strictEqual(client.cleanOptionText("Cook County (Recommended)"), "Cook County"); +}); + test("reference-data GETs are isolated by the current jurisdiction", async () => { const { client,