From 05824fe0d34ff402fc513b06c96ea4593ce85ee5 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Thu, 3 Sep 2026 12:36:02 -0400 Subject: [PATCH 1/3] Explain automatically detected filing values --- efile_app/efile/api/dropdown_views.py | 33 +++++++++----- .../efile/static/css/reorganized-flow.css | 40 +++++++++++++++++ efile_app/efile/static/js/case-lookup.js | 6 ++- .../efile/static/js/extraction-review.js | 19 +++++--- .../efile/static/js/organize-documents.js | 6 ++- .../efile/templates/efile/case_lookup.html | 6 +++ .../components/extraction_marker_help.html | 9 ++++ .../components/extraction_marker_modal.html | 22 ++++++++++ .../templates/efile/extraction_review.html | 5 ++- .../templates/efile/organize_documents.html | 6 +++ efile_app/efile/templates/efile/review.html | 24 +++++++---- .../efile/templates/efile/workflow_base.html | 1 + ...test_filing_types_amount_in_controversy.py | 11 +++++ .../efile/tests/test_review_submit_flow.py | 34 +++++++++++++++ efile_app/efile/views/case_lookup.py | 2 +- efile_app/efile/views/review.py | 43 ++++++++++++++++++- 16 files changed, 237 insertions(+), 30 deletions(-) create mode 100644 efile_app/efile/templates/efile/components/extraction_marker_help.html create mode 100644 efile_app/efile/templates/efile/components/extraction_marker_modal.html diff --git a/efile_app/efile/api/dropdown_views.py b/efile_app/efile/api/dropdown_views.py index 1a8c3c31..81fea7e9 100644 --- a/efile_app/efile/api/dropdown_views.py +++ b/efile_app/efile/api/dropdown_views.py @@ -59,9 +59,9 @@ def prioritize_options(api_data, guessed): 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 +429,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: @@ -450,13 +452,13 @@ def _prioritize_courts_by_location(courts, guessed_court="", user_zip=None, user 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", "") + guessed_court_norm = guessed_court.lower().replace("court", "").replace("illinois", "").strip() # Normalize county name for matching (lowercase, no spaces) - target_county_norm = target_county.lower().replace(" ", "").replace("county", "") + target_county_norm = (target_county or "").lower().replace(" ", "").replace("county", "") # Create prioritized list prioritized_courts = [] @@ -470,18 +472,29 @@ def _prioritize_courts_by_location(courts, guessed_court="", user_zip=None, user 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: + guessed_match = bool(guessed_court_norm) and ( + court_value == guessed_court_norm or court_value in guessed_court_norm + ) + location_match = bool(target_county_norm) and ( + court_value == target_county_norm or target_county_norm in court_text + ) + if guessed_match: is_match = True - elif court_value == target_county_norm or target_county_norm in court_text: + elif location_match: 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 + # A document match gets the extraction marker. Location-only + # recommendations keep their existing wording below. court_copy = court.copy() - court_copy["text"] = f"{court['text']} (Recommended)" + location_recommendation = location_match or (target_county_norm == "cook" and "cook:" in court_value) + if guessed_match: + court_copy["text"] = f"{court['text']} *" + elif location_recommendation: + court_copy["text"] = f"{court['text']} (Recommended)" prioritized_courts.append(court_copy) else: other_courts.append(court) diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css index 2566c404..8b448ce7 100644 --- a/efile_app/efile/static/css/reorganized-flow.css +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -472,6 +472,46 @@ padding: 1.25rem; } +.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/case-lookup.js b/efile_app/efile/static/js/case-lookup.js index 6a321a2c..42e544bf 100644 --- a/efile_app/efile/static/js/case-lookup.js +++ b/efile_app/efile/static/js/case-lookup.js @@ -10,6 +10,10 @@ const guessedCourt = JSON.parse(document.getElementById("guessed-court").textContent || '""'); const selectedCourtCode = JSON.parse(document.getElementById("selected-court-code").textContent || '""'); + function cleanOptionText(value) { + return String(value || "").replace(/ \(Recommended\)$/, "").replace(/ \*$/, ""); + } + async function loadCourts() { try { const response = await apiUtils.fetchJSON("/api/dropdowns/courts/", "GET", { @@ -61,7 +65,7 @@ }, body: JSON.stringify({ court: courtSelect.value, - court_name: selectedCourt?.textContent?.replace(" (Recommended)", "") || "", + court_name: 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..95fe367f 100644 --- a/efile_app/efile/static/js/extraction-review.js +++ b/efile_app/efile/static/js/extraction-review.js @@ -66,7 +66,11 @@ } function optionText(item) { - return (item.text || item.name || optionValue(item)).replace(/ \(Recommended\)$/, ""); + return item.text || item.name || optionValue(item); + } + + function cleanOptionText(value) { + return String(value || "").replace(/ \(Recommended\)$/, "").replace(/ \*$/, ""); } async function getJson(url) { @@ -132,8 +136,9 @@ if (chosen) { field.select.value = chosen.value; - field.nameInput.value = chosen.textContent; - field.valueEl.textContent = chosen.textContent; + const chosenText = cleanOptionText(chosen.textContent); + field.nameInput.value = chosenText; + field.valueEl.textContent = chosenText + (chosen.textContent.trim().endsWith("*") ? " *" : ""); setMode(key, "found"); await ADVANCE[key](); } else { @@ -307,19 +312,19 @@ }; fields.court.select.addEventListener("change", () => { - fields.court.nameInput.value = fields.court.select.selectedOptions[0]?.textContent || ""; + fields.court.nameInput.value = 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 = 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 = 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 = 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..531dfebc 100644 --- a/efile_app/efile/static/js/organize-documents.js +++ b/efile_app/efile/static/js/organize-documents.js @@ -41,6 +41,10 @@ return item.text || item.name || item.description || optionValue(item); } + function cleanOptionText(value) { + return String(value || "").replace(/ \(Recommended\)$/, "").replace(/ \*$/, ""); + } + function setOptions(select, options, savedValue, placeholder) { select.innerHTML = ""; if (!options.length) { @@ -444,7 +448,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: 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..13e84cf1 100644 --- a/efile_app/efile/templates/efile/case_lookup.html +++ b/efile_app/efile/templates/efile/case_lookup.html @@ -11,6 +11,12 @@

{% translate "Find your court case" %}

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

+ {% if guessed_court %} +
+ {% translate "Your document gave us a suggested court, marked with an asterisk." %} + {% include "efile/components/extraction_marker_help.html" %} +
+ {% 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..71cd8ed3 --- /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..25ecaacc 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 @@ -26,6 +26,17 @@ 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"] + + 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..a959cd7e 100644 --- a/efile_app/efile/tests/test_review_submit_flow.py +++ b/efile_app/efile/tests/test_review_submit_flow.py @@ -72,6 +72,40 @@ 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 "The * means this is the value we automatically detected from your document." in content + assert 'data-bs-target="#extraction-marker-modal"' in content + + class _PaymentAccountTypesResponse: status_code = 200 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..71e84de8 100644 --- a/efile_app/efile/views/review.py +++ b/efile_app/efile/views/review.py @@ -12,6 +12,19 @@ from ..workflow import WorkflowStepKey, get_workflow_context +def _matches_extracted_value(current, extracted): + """Return whether a saved value is still the value extraction suggested.""" + 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 + 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): @@ -37,6 +50,33 @@ def case_review(request, jurisdiction): if not key.startswith("_") and value not in (None, "") ] parties = FilingParty.objects.filter(draft=draft) + all_parties = list(parties) + 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")), + "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 all_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), @@ -56,7 +96,8 @@ def case_review(request, jurisdiction): # 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 From 31236cdc7c9a3be711743e732484014072d7ce62 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Thu, 3 Sep 2026 13:24:01 -0400 Subject: [PATCH 2/3] Separate extraction and location court markers --- efile_app/efile/api/dropdown_views.py | 20 +++++++++++++++---- efile_app/efile/static/js/api-utils.js | 4 ++++ efile_app/efile/static/js/case-lookup.js | 6 +----- .../efile/static/js/extraction-review.js | 14 +++++-------- .../efile/static/js/organize-documents.js | 6 +----- ...test_filing_types_amount_in_controversy.py | 14 ++++++++++++- efile_app/js-tests/api-utils.test.js | 8 ++++++++ 7 files changed, 48 insertions(+), 24 deletions(-) diff --git a/efile_app/efile/api/dropdown_views.py b/efile_app/efile/api/dropdown_views.py index 81fea7e9..b68b0fa2 100644 --- a/efile_app/efile/api/dropdown_views.py +++ b/efile_app/efile/api/dropdown_views.py @@ -447,15 +447,23 @@ 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 and not guessed_court: return courts - guessed_court_norm = guessed_court.lower().replace("court", "").replace("illinois", "").strip() + guessed_court_norm = ( + guessed_court.lower() + .replace("court", "") + .replace("illinois", "") + .replace("county", "") + .replace(" ", "") + .strip() + ) # Normalize county name for matching (lowercase, no spaces) target_county_norm = (target_county or "").lower().replace(" ", "").replace("county", "") @@ -466,6 +474,8 @@ def _prioritize_courts_by_location(courts, guessed_court="", user_zip=None, user for court in courts: court_value = court.get("value", "").lower().replace("county", "") + court_value_norm = court_value.replace(" ", "") + court_county = court_value_norm.split(":", 1)[0] court_text = court.get("text", "").lower() # Check if this court matches the user's county @@ -473,7 +483,9 @@ def _prioritize_courts_by_location(courts, guessed_court="", user_zip=None, user # Direct value match (e.g., 'cook' matches 'cook') or text match (e.g., 'Cook County' matches 'cook') guessed_match = bool(guessed_court_norm) and ( - court_value == guessed_court_norm or court_value in guessed_court_norm + court_county == guessed_court_norm + or court_value_norm == guessed_court_norm + or court_value_norm in guessed_court_norm ) location_match = bool(target_county_norm) and ( court_value == target_county_norm or target_county_norm in court_text 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 42e544bf..30b6d1e3 100644 --- a/efile_app/efile/static/js/case-lookup.js +++ b/efile_app/efile/static/js/case-lookup.js @@ -10,10 +10,6 @@ const guessedCourt = JSON.parse(document.getElementById("guessed-court").textContent || '""'); const selectedCourtCode = JSON.parse(document.getElementById("selected-court-code").textContent || '""'); - function cleanOptionText(value) { - return String(value || "").replace(/ \(Recommended\)$/, "").replace(/ \*$/, ""); - } - async function loadCourts() { try { const response = await apiUtils.fetchJSON("/api/dropdowns/courts/", "GET", { @@ -65,7 +61,7 @@ }, body: JSON.stringify({ court: courtSelect.value, - court_name: cleanOptionText(selectedCourt?.textContent), + 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 95fe367f..c210afd0 100644 --- a/efile_app/efile/static/js/extraction-review.js +++ b/efile_app/efile/static/js/extraction-review.js @@ -69,10 +69,6 @@ return item.text || item.name || optionValue(item); } - function cleanOptionText(value) { - return String(value || "").replace(/ \(Recommended\)$/, "").replace(/ \*$/, ""); - } - async function getJson(url) { const response = await fetch(url, { headers: { @@ -136,7 +132,7 @@ if (chosen) { field.select.value = chosen.value; - const chosenText = cleanOptionText(chosen.textContent); + const chosenText = apiUtils.cleanOptionText(chosen.textContent); field.nameInput.value = chosenText; field.valueEl.textContent = chosenText + (chosen.textContent.trim().endsWith("*") ? " *" : ""); setMode(key, "found"); @@ -312,19 +308,19 @@ }; fields.court.select.addEventListener("change", () => { - fields.court.nameInput.value = cleanOptionText(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 = cleanOptionText(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 = cleanOptionText(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 = cleanOptionText(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 531dfebc..1bd1f8f5 100644 --- a/efile_app/efile/static/js/organize-documents.js +++ b/efile_app/efile/static/js/organize-documents.js @@ -41,10 +41,6 @@ return item.text || item.name || item.description || optionValue(item); } - function cleanOptionText(value) { - return String(value || "").replace(/ \(Recommended\)$/, "").replace(/ \*$/, ""); - } - function setOptions(select, options, savedValue, placeholder) { select.innerHTML = ""; if (!options.length) { @@ -448,7 +444,7 @@ id: Number(card.dataset.documentId), name: card.querySelector(".document-name").value, filing_type: filingType.value, - filing_type_name: cleanOptionText(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/tests/test_filing_types_amount_in_controversy.py b/efile_app/efile/tests/test_filing_types_amount_in_controversy.py index 25ecaacc..4feaa9c1 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 @@ -37,6 +37,18 @@ def test_prioritize_options_marks_document_matches_with_an_asterisk(): assert "Recommended" not in petition["text"] +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"] + + class _FilingTypesResponse: status_code = 200 headers = {"Content-Type": "application/json"} 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, From 6d744dc0b88ff00ade08f37b32d01ffc0a795223 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Thu, 3 Sep 2026 14:10:36 -0400 Subject: [PATCH 3/3] Match courts on whole words, not substrings The court guess extracted from a document was compared to court codes with an unanchored substring test, so a short county name inside a longer one won: a document reading "McHenry County" put Henry County at the top of the list and pre-selected it, with an asterisk claiming the document said so. Williamson matched Will, Whiteside matched White, Crawford matched Ford. Compare runs of whole words instead. Court codes drop the spaces inside a county name ("stclair", "rockisland"), so a run of words is the smallest unit worth comparing, and it also lets a real caption match: "Circuit Court of Cook County, Illinois" now finds the Cook courts, where before it found none and the case lookup page still promised an asterisk that was not there. Where the guess names only the county, every division of that county matches and none of them is what the document said, so leave them at the top of the list unselected rather than filing the case in whichever one sorts first. A guess that names the division still selects it. Drop the Levenshtein fallback for the other dropdowns. At a threshold loose enough to forgive a typo it also pairs unrelated options - "Motion" and "Notice" are three edits apart - and the new marker turns that into a claim about the filer's document. Substring matching still covers the near misses that mattered ("Eviction" finds "Evictions"). Also: - Reveal the case lookup asterisk help only once a court in the list carries the marker. - Require an exact match before marking a docket number as extracted: a filer correcting 2026-CV-1234 to 2026-CV-123 has not left our guess in place. - Reword the modal now that several options can carry the marker. - Fetch the review page's parties once instead of three times. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JCsBkAUTE8jxP4BrbPexAm --- efile_app/efile/api/dropdown_views.py | 134 ++++++++++-------- .../efile/static/css/reorganized-flow.css | 4 + efile_app/efile/static/js/case-lookup.js | 6 + .../efile/templates/efile/case_lookup.html | 3 +- .../components/extraction_marker_modal.html | 2 +- ...test_filing_types_amount_in_controversy.py | 96 +++++++++++++ .../efile/tests/test_review_submit_flow.py | 19 ++- efile_app/efile/utils/str_dist.py | 36 ----- efile_app/efile/views/review.py | 33 +++-- 9 files changed, 219 insertions(+), 114 deletions(-) delete mode 100644 efile_app/efile/utils/str_dist.py diff --git a/efile_app/efile/api/dropdown_views.py b/efile_app/efile/api/dropdown_views.py index b68b0fa2..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,13 +72,12 @@ 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 matches from document extraction with a compact marker. opt_copy = opt.copy() @@ -456,70 +476,62 @@ def _prioritize_courts_by_location(courts, guessed_court="", user_zip=None, user if not target_county and not guessed_court: return courts - guessed_court_norm = ( - guessed_court.lower() - .replace("court", "") - .replace("illinois", "") - .replace("county", "") - .replace(" ", "") - .strip() - ) - - # Normalize county name for matching (lowercase, no spaces) - target_county_norm = (target_county or "").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_value_norm = court_value.replace(" ", "") - court_county = court_value_norm.split(":", 1)[0] - 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') - guessed_match = bool(guessed_court_norm) and ( - court_county == guessed_court_norm - or court_value_norm == guessed_court_norm - or court_value_norm in guessed_court_norm - ) - location_match = bool(target_county_norm) and ( - court_value == target_county_norm or target_county_norm in court_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: - is_match = True + # 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: - 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: - # A document match gets the extraction marker. Location-only - # recommendations keep their existing wording below. + # Location-only recommendations keep their existing wording. court_copy = court.copy() - location_recommendation = location_match or (target_county_norm == "cook" and "cook:" in court_value) - if guessed_match: - court_copy["text"] = f"{court['text']} *" - elif location_recommendation: - court_copy["text"] = f"{court['text']} (Recommended)" - prioritized_courts.append(court_copy) + court_copy["text"] = f"{court['text']} (Recommended)" + 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 8b448ce7..65cf029b 100644 --- a/efile_app/efile/static/css/reorganized-flow.css +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -472,6 +472,10 @@ padding: 1.25rem; } +.extraction-page-help[hidden] { + display: none; +} + .extraction-page-help { align-items: center; color: var(--text-muted); diff --git a/efile_app/efile/static/js/case-lookup.js b/efile_app/efile/static/js/case-lookup.js index 30b6d1e3..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; diff --git a/efile_app/efile/templates/efile/case_lookup.html b/efile_app/efile/templates/efile/case_lookup.html index 13e84cf1..248d6d9d 100644 --- a/efile_app/efile/templates/efile/case_lookup.html +++ b/efile_app/efile/templates/efile/case_lookup.html @@ -12,7 +12,8 @@

{% 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. #} + diff --git a/efile_app/efile/templates/efile/components/extraction_marker_modal.html b/efile_app/efile/templates/efile/components/extraction_marker_modal.html index 71cd8ed3..d4a94736 100644 --- a/efile_app/efile/templates/efile/components/extraction_marker_modal.html +++ b/efile_app/efile/templates/efile/components/extraction_marker_modal.html @@ -14,7 +14,7 @@
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 4feaa9c1..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 @@ -37,6 +37,18 @@ def test_prioritize_options_marks_document_matches_with_an_asterisk(): 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"}, @@ -49,6 +61,90 @@ def test_guessed_court_uses_extraction_marker_without_location_recommendation(): 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 a959cd7e..ef9e99fd 100644 --- a/efile_app/efile/tests/test_review_submit_flow.py +++ b/efile_app/efile/tests/test_review_submit_flow.py @@ -102,10 +102,27 @@ def test_final_review_marks_extracted_values_and_explains_the_marker(client, sub assert response.status_code == 200 assert "Jordan Taylor v. Acme *" in content - assert "The * means this is the value we automatically detected from your document." 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/review.py b/efile_app/efile/views/review.py index 71e84de8..f325be1d 100644 --- a/efile_app/efile/views/review.py +++ b/efile_app/efile/views/review.py @@ -12,12 +12,19 @@ from ..workflow import WorkflowStepKey, get_workflow_context -def _matches_extracted_value(current, extracted): - """Return whether a saved value is still the value extraction suggested.""" +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) @@ -49,8 +56,9 @@ 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) - all_parties = list(parties) + 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( @@ -59,14 +67,14 @@ def case_review(request, jurisdiction): ) 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")), + "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 all_parties - if _matches_extracted_value(party_display_name(party), extracted_party_text) + party.id for party in parties if _matches_extracted_value(party_display_name(party), extracted_party_text) }, "document_ids": { document.id @@ -83,15 +91,12 @@ def case_review(request, jurisdiction): "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.