Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 83 additions & 46 deletions efile_app/efile/api/dropdown_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import logging
import re

import requests
from django.conf import settings
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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"])
Expand Down
44 changes: 44 additions & 0 deletions efile_app/efile/static/css/reorganized-flow.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions efile_app/efile/static/js/api-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class ApiUtils {
return null;
}

cleanOptionText(value) {
return String(value || "").replace(/ \(Recommended\)$/, "").replace(/ \*$/, "");
}

getCache() {
try {
const cached = localStorage.getItem('apiResponseCache');
Expand Down
8 changes: 7 additions & 1 deletion efile_app/efile/static/js/case-lookup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -18,15 +19,20 @@
});
if (!response.success) throw new Error(response.error || "Could not load courts.");
courtSelect.innerHTML = '<option value="">Choose a court</option>';
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 = '<option value="">Courts could not be loaded</option>';
errorBox.textContent = error.message;
Expand Down Expand Up @@ -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 || "",
Expand Down
15 changes: 8 additions & 7 deletions efile_app/efile/static/js/extraction-review.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
});

Expand Down
2 changes: 1 addition & 1 deletion efile_app/efile/static/js/organize-documents.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "",
Expand Down
7 changes: 7 additions & 0 deletions efile_app/efile/templates/efile/case_lookup.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ <h1>{% translate "Find your court case" %}</h1>
<p class="workflow-lede">
{% translate "Choose the court and enter the case number exactly as it appears on your documents." %}
</p>
{% if guessed_court %}
{# Revealed by case-lookup.js only once a court in the list actually carries the marker. #}
<div class="extraction-page-help" id="court-extraction-help" hidden>
<span>{% translate "Your document gave us a suggested court, marked with an asterisk." %}</span>
{% include "efile/components/extraction_marker_help.html" %}
</div>
{% endif %}
<form id="case-lookup-form">
{% csrf_token %}
<div class="lookup-fields">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% load i18n %}
<button class="extraction-marker-help"
type="button"
data-bs-toggle="modal"
data-bs-target="#extraction-marker-modal"
aria-label="{% translate "What does the asterisk mean?" %}">
<i class="fa-regular fa-circle-question" aria-hidden="true"></i>
<span class="visually-hidden">{% translate "What does the asterisk mean?" %}</span>
</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% load i18n %}
<div class="modal fade extraction-marker-modal"
id="extraction-marker-modal"
tabindex="-1"
aria-labelledby="extraction-marker-modal-title"
aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title fs-5" id="extraction-marker-modal-title">{% translate "About the *" %}</h2>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="{% translate "Close" %}"></button>
</div>
<div class="modal-body">
<p>{% translate "A * marks a value that matches what we automatically detected in your document." %}</p>
<p class="mb-0">{% translate "Automatic detection can make mistakes. Check this value against your document and correct it if needed." %}</p>
</div>
</div>
</div>
</div>
Loading
Loading