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 "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. #} +