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 %}
+
+ {% endif %}
{% if guessed_court %}
-
-
{% translate "The * means this is the value we automatically detected from your document." %}
+
{% translate "A * marks a value that matches what we automatically detected in your document." %}
{% translate "Automatic detection can make mistakes. Check this value against your document and correct it if needed." %}
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.