Skip to content

Commit 8237c3a

Browse files
Support court-specific filing hints
1 parent e69a310 commit 8237c3a

4 files changed

Lines changed: 204 additions & 9 deletions

File tree

‎docs/developer-notes/issue-106-vermont-handoff-validation.md‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ minor-specific semantic hint was verified by a sender unit test.
100100

101101
## Final automated checks
102102

103-
- LITEFile: `uv run pytest -q` — 782 passed.
104-
- Vermont sender: `python -m pytest -q tests/test_litefile.py` — 16 passed.
103+
- LITEFile: `uv run pytest -q` — 784 passed.
104+
- Vermont sender: `python -m pytest -q tests/test_litefile.py` — 17 passed.
105105
- LITEFile: `uv run ruff check .` and `uv run ty check` — passed.
106106
- Django: `makemigrations --check --dry-run` — no model drift; migration 0023
107107
was applied to the isolated test database.
@@ -179,7 +179,7 @@ Installed the refactored adapter on the running Docassemble server and verified:
179179
- Retrying the correction kept one replacement receipt and two total transfer
180180
caches (initial and correction).
181181
- Text extraction from the stored affidavit confirmed the new correction text.
182-
- Sixteen adapter tests, Ruff, whitespace checks, and the documentation build
182+
- Seventeen adapter tests, Ruff, whitespace checks, and the documentation build
183183
pass.
184184

185185
The reusable person helper defaults to the standard AssemblyLine and
@@ -191,6 +191,15 @@ The final live retry used the flat `litefile` server configuration and logged an
191191
empty argument mapping for `litefile_upload`. Credentials, correction tokens,
192192
and interview answers therefore remained outside the background job arguments.
193193

194+
Follow-up contract tests cover county and court-specific semantic hints. They
195+
verify court-over-county-over-general precedence across case categories, case
196+
types, filing types, document types, and filing components. Validation rejects
197+
unknown document IDs and county keys that normalize to the same name. The
198+
sender test confirms that the declarative override mapping reaches the payload.
199+
The updated interview was also reinstalled and completed another live
200+
background handoff: draft 11 received four PDFs plus the default empty county
201+
and court override maps, and the success screen cleared the pending task.
202+
194203
The first attempt encountered a stopped LocalStack container and a recoverable
195204
HTTP 503. Restarting local storage allowed the same cached transfer to succeed.
196205
No additional court submission was made during this refactor validation.

‎docs/docs/partners-courts/interview-integration.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,52 @@ suggestions remain in the receipt; numeric suggestions never populate resolved
121121
court-code fields. Supported questionnaire answers `has_children` and
122122
`child_count` also populate their normal filing fields.
123123

124+
### County-specific and court-specific hints
125+
126+
Use `filing_hint_overrides` when a semantic name is correct in one filing
127+
location and wrong in another. Matching county values replace general hints
128+
field by field. Matching court values apply afterward and replace the county
129+
value for the same field. Fields that an override omits continue using the less
130+
specific value.
131+
132+
```json
133+
{
134+
"case_category_name_hints": ["Civil"],
135+
"case_type_name_hints": ["General case type"],
136+
"filing_hint_overrides": {
137+
"counties": {
138+
"Cook": {
139+
"case_type_name_hints": ["Cook County case type"],
140+
"documents": {
141+
"complaint": {
142+
"filing_type_name_hints": ["Cook County complaint"]
143+
}
144+
}
145+
}
146+
},
147+
"courts": {
148+
"First Municipal District": {
149+
"case_category_name_hints": ["Court-specific category"],
150+
"documents": {
151+
"complaint": {
152+
"filing_type_name_hints": ["Court-specific complaint"],
153+
"document_type_name_hints": ["Court-specific document type"],
154+
"filing_component_name_hints": ["Court-specific lead document"]
155+
}
156+
}
157+
}
158+
}
159+
}
160+
}
161+
```
162+
163+
County keys match `case.county`; they may include or omit the word `County`.
164+
Court keys match either the source's `case.court_name` or the official court
165+
name resolved from live metadata. Matching ignores capitalization and ordinary
166+
name punctuation. Scoped `documents` keys must be document IDs declared in the
167+
same request. Names remain semantic suggestions: LITEFile only saves a code
168+
when exactly one live option matches.
169+
124170
## Response, continuation, and retries
125171

126172
A new receipt returns HTTP 201. An identical retry returns HTTP 200 with the same

‎efile_app/efile/services/handoff.py‎

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,71 @@ def _hints(value, path):
7878
_string(hint, field)
7979

8080

81+
def _scope_name(value, scope):
82+
name = normalize_name(value)
83+
if scope == "counties":
84+
name = name.removesuffix(" county")
85+
return name
86+
87+
88+
def _validate_filing_hint_overrides(payload, document_ids):
89+
overrides = _object(payload.get("filing_hint_overrides", {}), "filing_hint_overrides")
90+
for scope in ("counties", "courts"):
91+
choices = _object(overrides.get(scope, {}), f"filing_hint_overrides.{scope}")
92+
if len(choices) > 100:
93+
raise HandoffError(f"filing_hint_overrides.{scope} supports up to 100 names.")
94+
normalized = set()
95+
for name, values in choices.items():
96+
_string(name, f"filing_hint_overrides.{scope} name")
97+
key = _scope_name(name, scope)
98+
if not key or key in normalized:
99+
raise HandoffError(f"filing_hint_overrides.{scope} names must be unique.")
100+
normalized.add(key)
101+
values = _object(values, f"filing_hint_overrides.{scope}.{name}")
102+
_hints(values, f"filing_hint_overrides.{scope}.{name}")
103+
documents = _object(values.get("documents", {}), f"filing_hint_overrides.{scope}.{name}.documents")
104+
if not set(documents).issubset(document_ids):
105+
raise HandoffError("Scoped document hints must use a declared document id.")
106+
for document_id, document_hints in documents.items():
107+
_hints(
108+
_object(
109+
document_hints,
110+
f"filing_hint_overrides.{scope}.{name}.documents.{document_id}",
111+
),
112+
f"filing_hint_overrides.{scope}.{name}.documents.{document_id}",
113+
)
114+
115+
116+
def _matching_hint_override(payload, scope, candidates):
117+
choices = payload.get("filing_hint_overrides", {}).get(scope, {})
118+
wanted = {_scope_name(candidate, scope) for candidate in candidates if candidate}
119+
return next(
120+
(values for name, values in choices.items() if _scope_name(name, scope) in wanted),
121+
{},
122+
)
123+
124+
125+
def effective_hints(payload, *, court_name="", document=None):
126+
"""Apply general, county, then court-specific semantic hint overrides."""
127+
base = document if document is not None else payload
128+
result = {field: list(value) for field in HINT_FIELDS if isinstance((value := base.get(field)), list)}
129+
case = payload.get("case", {})
130+
scopes = (
131+
_matching_hint_override(payload, "counties", [case.get("county", "")]),
132+
_matching_hint_override(
133+
payload,
134+
"courts",
135+
[case.get("court_name", ""), court_name],
136+
),
137+
)
138+
for override in scopes:
139+
selected = override.get("documents", {}).get(document.get("id", ""), {}) if document else override
140+
for field in HINT_FIELDS:
141+
if field in selected:
142+
result[field] = list(selected[field])
143+
return result
144+
145+
81146
def validate_payload(payload, source_config, files):
82147
_object(payload, "payload")
83148
if type(payload.get("schema_version")) is not int or payload["schema_version"] != 1:
@@ -98,6 +163,7 @@ def validate_payload(payload, source_config, files):
98163
raise HandoffError("case.existing_case must be true or false when known.")
99164
for field in CASE_FIELDS:
100165
_string(case.get(field, ""), f"case.{field}", FilingDraft._meta.get_field(field).max_length)
166+
_string(case.get("county", ""), "case.county")
101167
_object(payload.get("known_filing_facts", {}), "known_filing_facts")
102168
parties = payload.get("parties", [])
103169
if not isinstance(parties, list) or len(parties) > 100:
@@ -167,6 +233,7 @@ def validate_payload(payload, source_config, files):
167233
raise HandoffError("A document bundle needs exactly one lead PDF.")
168234
if set(files) != ids or any(len(files.getlist(key)) != 1 for key in files):
169235
raise HandoffError("Upload each declared document exactly once.")
236+
_validate_filing_hint_overrides(payload, ids)
170237
return payload
171238

172239

@@ -296,12 +363,13 @@ def choose(obj, field, options, hints):
296363
choose(draft, "court_code", _codes(draft.jurisdiction, "", with_names=True), court_hints(draft, payload))
297364
if not draft.court_code:
298365
return
366+
payload_hints = effective_hints(payload, court_name=draft.court_name)
299367
timing = "Subsequent" if draft.existing_case == ExistingCase.EXISTING else "Initial"
300368
choose(
301369
draft,
302370
"case_category_code",
303371
_codes(draft.jurisdiction, f"{draft.court_code}/categories", timing=timing, fileable_only=True),
304-
intent.get("case_category_name_aliases", []) + payload.get("case_category_name_hints", []),
372+
intent.get("case_category_name_aliases", []) + payload_hints.get("case_category_name_hints", []),
305373
)
306374
if not draft.case_category_code:
307375
return
@@ -311,7 +379,7 @@ def choose(obj, field, options, hints):
311379
_codes(
312380
draft.jurisdiction, f"{draft.court_code}/case_types/", category_id=draft.case_category_code, timing=timing
313381
),
314-
intent.get("case_type_name_aliases", []) + payload.get("case_type_name_hints", []),
382+
intent.get("case_type_name_aliases", []) + payload_hints.get("case_type_name_hints", []),
315383
)
316384
if not draft.case_type_code:
317385
return
@@ -325,16 +393,17 @@ def choose(obj, field, options, hints):
325393
for document in draft.documents.all():
326394
suggestion = draft.metadata_events.filter(path=f"documents.{document.pk}", kind="source_suggestion").first()
327395
hints = suggestion.value if suggestion else {}
396+
document_hints = effective_hints(payload, court_name=draft.court_name, document=hints)
328397
curated = intent.get("documents", {}).get(hints.get("id", ""), {})
329398
choose(
330399
document,
331400
"filing_type_code",
332401
options,
333402
curated.get("filing_type_name_aliases", [])
334403
+ (
335-
hints.get("filing_type_name_hints", [])
404+
document_hints.get("filing_type_name_hints", [])
336405
or (
337-
intent.get("filing_type_name_aliases", []) + payload.get("filing_type_name_hints", [])
406+
intent.get("filing_type_name_aliases", []) + payload_hints.get("filing_type_name_hints", [])
338407
if document.role == "lead"
339408
else [hints.get("form_name", "")]
340409
)
@@ -348,7 +417,12 @@ def choose(obj, field, options, hints):
348417
choices = _codes(
349418
draft.jurisdiction, f"{draft.court_code}/filing_types/{document.filing_type_code}/{endpoint}"
350419
)
351-
choose(document, field, choices, hints.get(field.replace("_code", "_name_hints"), []))
420+
choose(
421+
document,
422+
field,
423+
choices,
424+
document_hints.get(field.replace("_code", "_name_hints"), []),
425+
)
352426
party_options = _codes(draft.jurisdiction, f"{draft.court_code}/case_types/{draft.case_type_code}/party_types")
353427
for party in draft.parties.all():
354428
if party.role == "filer" and not (party.is_self or party.is_filing_party):

‎efile_app/efile/tests/test_handoff.py‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from django.urls import reverse
88

99
from efile.models import FilingDraft, InterviewHandoff
10-
from efile.services.handoff import HandoffError, create_correction, resolve_metadata, unique_match
10+
from efile.services.handoff import HandoffError, create_correction, effective_hints, resolve_metadata, unique_match
1111

1212
pytestmark = pytest.mark.django_db
1313
PDF = b"%PDF-1.4\nsynthetic test document"
@@ -159,6 +159,72 @@ def codes(jurisdiction, path, **params):
159159
assert InterviewHandoff.objects.get().payload["documents"][0]["filing_type_code"] == "9999"
160160

161161

162+
def test_county_and_court_hint_overrides_replace_general_hints(client, source, payload, storage):
163+
payload["case"]["county"] = "Cook County"
164+
payload["filing_hint_overrides"] = {
165+
"counties": {
166+
"Cook": {
167+
"case_category_name_hints": ["County category"],
168+
"case_type_name_hints": ["County case type"],
169+
"documents": {"complaint": {"document_type_name_hints": ["County confidential"]}},
170+
}
171+
},
172+
"courts": {
173+
"Test Family Court": {
174+
"case_category_name_hints": ["Court category"],
175+
"documents": {
176+
"complaint": {
177+
"filing_type_name_hints": ["Court complaint"],
178+
"filing_component_name_hints": ["Court lead"],
179+
}
180+
},
181+
}
182+
},
183+
}
184+
assert effective_hints(payload, court_name="Test family court")["case_category_name_hints"] == ["Court category"]
185+
send(client, source, payload)
186+
draft = FilingDraft.objects.get()
187+
188+
def codes(jurisdiction, path, **params):
189+
return {
190+
"": [{"code": "vt", "name": "Test family court"}],
191+
"vt/categories": [
192+
{"code": "county", "name": "County category"},
193+
{"code": "court", "name": "Court category"},
194+
],
195+
"vt/case_types/": [{"code": "county-type", "name": "County case type"}],
196+
"vt/filing_types/": [{"code": "court-filing", "name": "Court complaint"}],
197+
"vt/filing_types/court-filing/document_types": [
198+
{"code": "county-confidential", "name": "County confidential"}
199+
],
200+
"vt/filing_types/court-filing/filing_components": [{"code": "court-lead", "name": "Court lead"}],
201+
}.get(path, [])
202+
203+
with patch("efile.services.handoff._codes", side_effect=codes):
204+
resolve_metadata(draft)
205+
draft.refresh_from_db()
206+
document = draft.documents.get()
207+
assert draft.case_category_code == "court"
208+
assert draft.case_type_code == "county-type"
209+
assert document.filing_type_code == "court-filing"
210+
assert document.document_type_code == "county-confidential"
211+
assert document.filing_component_code == "court-lead"
212+
213+
214+
def test_scoped_hints_reject_unknown_documents_and_duplicate_counties(client, source, payload, storage):
215+
payload["filing_hint_overrides"] = {
216+
"counties": {"Cook": {"documents": {"missing": {"filing_type_name_hints": ["Complaint"]}}}}
217+
}
218+
assert send(client, source, payload).status_code == 400
219+
payload["filing_hint_overrides"] = {
220+
"counties": {
221+
"Cook": {"case_type_name_hints": ["One"]},
222+
"Cook County": {"case_type_name_hints": ["Two"]},
223+
}
224+
}
225+
assert send(client, source, payload).status_code == 400
226+
227+
162228
def test_correction_preserves_snapshot_and_blocks_ambiguous_attempts(
163229
client, source, payload, storage, django_user_model
164230
):

0 commit comments

Comments
 (0)