From 118b57ee68c859706b023cbd1f2e1bba8b105c95 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Fri, 7 Aug 2026 10:59:42 -0700 Subject: [PATCH 01/29] feat(casework): read a court case and write scalars + a whole list in one PATCH Adds get_courtcase and list_hearings (read the NGM composite-key detail route and its hearings sub-resource, paging by page number the same way get_court_case_entities does) plus patch_case, which sends scalar fields and a whole-list path (e.g. entities) in ONE conditional PATCH -- the fix for the two-request/one-ETag failure build_replace_ops documents from enrich_card's 2026-08-04 smoke run. --- casework/common/api.py | 67 ++++++++++++++++++++++++++++++++++++++ tests/casework/test_api.py | 66 +++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/casework/common/api.py b/casework/common/api.py index 409d081a..20bfcffd 100644 --- a/casework/common/api.py +++ b/casework/common/api.py @@ -384,6 +384,73 @@ def get_court_case_entities(self, court, number, timeout=60): return rows page += 1 + def get_courtcase(self, court, number, timeout=60): + """One NGM court case: registration date, case_status, parties summary. + + The composite-key detail route, keyed on (court, case_number). Public on + the read plane, so this works with no credentials at all. + """ + path = (f"/courtcases/{urllib.parse.quote(str(court), safe='')}" + f"/{urllib.parse.quote(str(number), safe='')}/") + return self.get(path, timeout=timeout) + + def list_hearings(self, court, number, timeout=60): + """Every hearing row on one court case, following pagination. + + Rows are NOT returned in date order -- the deciding hearing can sort + before an earlier one. Callers pick by max `hearing_date_ad`, never by + list position. + + Pages by page NUMBER and ignores the response's `next` URL, for the same + reason `get_court_case_entities` does: `get()` concatenates path onto + base_url, so an absolute `next` would produce a doubled prefix. + """ + path = (f"/courtcases/{urllib.parse.quote(str(court), safe='')}" + f"/{urllib.parse.quote(str(number), safe='')}/hearings") + rows, page = [], 1 + while True: + data = self.get(path, {"page": page, "page_size": 100}, timeout=timeout) + batch = data.get("results") or [] + rows.extend(batch) + if len(batch) < 100: + return rows + page += 1 + + def patch_case(self, slug, *, fields=(), lists=(), timeout=60, if_match=None): + """Write scalar fields AND whole-list paths in ONE conditional request. + + `fields` is `[(name, value)]` of scalars; `lists` is `[(path, items)]` + of whole-list paths (`WHOLE_LIST_PATHS`). + + WHY THIS EXISTS. `patch_fields` refuses whole-list paths and + `replace_list` takes one path at a time, so a caller writing + `case_start_date` and `entities` had to send two requests -- and the + first changes the ETag, so the second 412s under the ETag read at the + top. `build_replace_ops` records the same failure from `enrich_card`. + + THE MERGE-FIRST CONTRACT OF `replace_list` APPLIES IN FULL to every + entry in `lists`: the server deletes every existing join row for that + path and recreates from exactly the items given. Pass the FULL merged + list, never a delta. Omitting a row deletes it, with no warning and no + recovery. + + Empty in -> no request and `{}` out, so a case with nothing to change + costs no write. + """ + fields, lists = list(fields), list(lists) + for name, _ in fields: + if name in WHOLE_LIST_PATHS: + raise ValueError( + f"{name} is a whole-list path -- pass it in `lists`, which " + "carries the merge-first contract") + for path, _ in lists: + if path not in WHOLE_LIST_PATHS: + raise ValueError(f"{path} is not a whole-list path") + ops = build_replace_ops(fields) + build_replace_ops(lists) + if not ops: + return {} + return self._patch(slug, ops, timeout, if_match=if_match) + def search_entities(self, query, *, page_size=ENTITY_SEARCH_PAGE_SIZE, pages=ENTITY_SEARCH_MAX_PAGES, timeout=60): """Candidate NES entities for `query`, from the unified search endpoint. diff --git a/tests/casework/test_api.py b/tests/casework/test_api.py index ab32b60b..2cf69642 100644 --- a/tests/casework/test_api.py +++ b/tests/casework/test_api.py @@ -1040,3 +1040,69 @@ def test_get_court_case_entities_stops_when_next_is_null(monkeypatch): api.get_court_case_entities("special", "080-cr-0111") assert len(seen) == 1 + + +# --------------------------------------------------------------------------- +# get_courtcase / list_hearings / patch_case -- the three methods the court- +# record binder depends on. get_courtcase reads the one composite-key detail +# route; list_hearings pages the hearings sub-resource by page NUMBER, same as +# get_court_case_entities; patch_case sends scalar fields and a whole-list path +# in ONE conditional request, because patch_fields refuses whole-list paths and +# replace_list takes one path at a time -- and two requests cannot share one +# ETag, which is the exact failure build_replace_ops documents. +# --------------------------------------------------------------------------- + + +def test_get_courtcase_hits_the_composite_path(monkeypatch): + api = CaseworkApi("http://127.0.0.1:48010", token="t") + seen = {} + + def fake_get(path, params=None, timeout=60): + seen["path"] = path + return {"case_number": "079-CR-0151", "registration_date_ad": "2023-06-22"} + + monkeypatch.setattr(api, "get", fake_get) + assert api.get_courtcase("special", "079-CR-0151")["registration_date_ad"] == "2023-06-22" + assert seen["path"] == "/courtcases/special/079-CR-0151/" + + +def test_list_hearings_follows_pages_by_number(monkeypatch): + api = CaseworkApi("http://127.0.0.1:48010", token="t") + pages = { + 1: {"results": [{"hearing_date_ad": "2024-06-04"}] * 100}, + 2: {"results": [{"hearing_date_ad": "2024-06-03"}]}, + } + monkeypatch.setattr(api, "get", lambda path, params=None, timeout=60: pages[params["page"]]) + rows = api.list_hearings("special", "079-CR-0151") + assert len(rows) == 101 + + +def test_patch_case_sends_scalars_and_a_whole_list_in_one_request(monkeypatch): + api = CaseworkApi("http://127.0.0.1:48010", token="t") + seen = {} + + def fake_patch(slug, ops, timeout=60, if_match=None): + seen.update(slug=slug, ops=ops, if_match=if_match) + return {} + + monkeypatch.setattr(api, "_patch", fake_patch) + api.patch_case( + "case-079-cr-0151", + fields=[("case_start_date", "2023-06-22")], + lists=[("entities", [{"nes_id": "x", "relationship_type": "accused"}])], + if_match='W/"7"', + ) + assert seen["if_match"] == 'W/"7"' + assert [op["path"] for op in seen["ops"]] == ["/case_start_date", "/entities"] + + +def test_patch_case_refuses_a_whole_list_path_passed_as_a_scalar(): + api = CaseworkApi("http://127.0.0.1:48010", token="t") + with pytest.raises(ValueError, match="whole-list path"): + api.patch_case("case-079-cr-0151", fields=[("entities", [])]) + + +def test_patch_case_makes_no_request_when_nothing_changed(monkeypatch): + api = CaseworkApi("http://127.0.0.1:48010", token="t") + monkeypatch.setattr(api, "_patch", lambda *a, **k: pytest.fail("should not PATCH")) + assert api.patch_case("case-079-cr-0151") == {} From 9e8e8e746637c245d7a8025a15bac0bc8fb07f3c Mon Sep 17 00:00:00 2001 From: GAURAV Date: Fri, 7 Aug 2026 11:04:13 -0700 Subject: [PATCH 02/29] feat(casework): read a case's whole court record, not just defendant names --- casework/court_record.py | 44 +++++++++++++++++ tests/casework/test_court_record.py | 73 ++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/casework/court_record.py b/casework/court_record.py index 3e5e46df..582d88e1 100644 --- a/casework/court_record.py +++ b/casework/court_record.py @@ -113,3 +113,47 @@ def defendant_names(api, case): seen.add(name) names.append(name) return names, skips + + +def court_record_for_case(api, case): + """`(records, skips)` -- the full court record behind every reference on `case`. + + Each record is `{"court", "number", "detail", "hearings", "parties"}`. The + three reads are made per reference; any one of them failing drops that + reference into `skips` with a human-readable reason and moves on, because 9 + of the 49 published court references 404 and one stale number must not cost + a case its other references. + + Deliberately separate from `defendant_names`, which answers the narrower + "who are the defendants" question and stays the entry point for callers that + need only names. + """ + refs = [ref for ref in (court_ref(raw) for raw in (case.get("court_cases") or [])) + if ref] + if not refs: + return [], ["no court reference on the case: neither dates nor accused " + "can be read from the court record"] + + records, skips = [], [] + for court, number in refs: + try: + record = { + "court": court, + "number": number, + "detail": api.get_courtcase(court, number) or {}, + "hearings": api.list_hearings(court, number) or [], + "parties": api.get_court_case_entities(court, number) or [], + } + except urllib.error.HTTPError as exc: + skips.append(f"court reference {court}/{number} could not be read " + f"(HTTP {exc.code})") + logger.warning("court record %s/%s unreadable: HTTP %s", + court, number, exc.code) + continue + except Exception as exc: # noqa: BLE001 - network, decode, anything else: a read failure is a skip + skips.append(f"court reference {court}/{number} could not be read " + f"({type(exc).__name__})") + logger.warning("court record %s/%s unreadable: %s", court, number, exc) + continue + records.append(record) + return records, skips diff --git a/tests/casework/test_court_record.py b/tests/casework/test_court_record.py index aaad8a6f..f9cf2125 100644 --- a/tests/casework/test_court_record.py +++ b/tests/casework/test_court_record.py @@ -12,7 +12,7 @@ import pytest -from casework.court_record import court_ref, defendant_names +from casework.court_record import court_record_for_case, court_ref, defendant_names class _Api: @@ -130,3 +130,74 @@ def test_blank_and_missing_names_are_dropped(): ]}) names, _ = defendant_names(api, CASE) assert names == ["सिताराम यादव"] + + +class _FullApi: + """Stub covering all three court reads. Values that are Exceptions raise.""" + + def __init__(self, detail=None, hearings=None, parties=None): + self.detail, self.hearings, self.parties = detail or {}, hearings or {}, parties or {} + + def _pick(self, store, court, number): + value = store.get(f"{court}/{number}") + if isinstance(value, Exception): + raise value + return value + + def get_courtcase(self, court, number, timeout=60): + return self._pick(self.detail, court, number) or {} + + def list_hearings(self, court, number, timeout=60): + return self._pick(self.hearings, court, number) or [] + + def get_court_case_entities(self, court, number, timeout=60): + return self._pick(self.parties, court, number) or [] + + +CASE_0151 = {"court_cases": ["https://jawafdehi.org/courtcase/special/079-cr-0151"]} + + +def test_reads_detail_hearings_and_parties_for_every_reference(): + api = _FullApi( + detail={"special/079-cr-0151": {"registration_date_ad": "2023-06-22"}}, + hearings={"special/079-cr-0151": [{"case_status": "फैसला", + "hearing_date_ad": "2024-06-04"}]}, + parties={"special/079-cr-0151": [{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}]}, + ) + records, skips = court_record_for_case(api, CASE_0151) + assert skips == [] + assert len(records) == 1 + assert records[0]["court"] == "special" + assert records[0]["detail"]["registration_date_ad"] == "2023-06-22" + assert records[0]["hearings"][0]["hearing_date_ad"] == "2024-06-04" + assert records[0]["parties"][0]["name"] == "कृष्ण प्रसाद यादव" + + +def test_an_unreadable_reference_is_a_skip_not_a_raise(): + api = _FullApi(detail={"special/079-cr-0151": urllib.error.HTTPError( + "u", 404, "Not Found", None, None)}) + records, skips = court_record_for_case(api, CASE_0151) + assert records == [] + assert "404" in skips[0] + + +def test_one_bad_reference_does_not_cost_the_others(): + case = {"court_cases": [ + "https://jawafdehi.org/courtcase/special/079-cr-0151", + "https://jawafdehi.org/courtcase/special/080-cr-0111", + ]} + api = _FullApi( + detail={ + "special/079-cr-0151": urllib.error.HTTPError("u", 404, "gone", None, None), + "special/080-cr-0111": {"registration_date_ad": "2024-01-01"}, + }, + ) + records, skips = court_record_for_case(api, case) + assert [r["number"] for r in records] == ["080-cr-0111"] + assert len(skips) == 1 + + +def test_no_court_reference_reports_why(): + records, skips = court_record_for_case(_FullApi(), {"court_cases": []}) + assert records == [] + assert "no court reference" in skips[0] From 8dc5a71279862e3e6bb5ca4ee156b2ceeb319b25 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Fri, 7 Aug 2026 11:11:25 -0700 Subject: [PATCH 03/29] feat(casework): derive case start and end dates from the court record --- casework/enrich_court_record.py | 131 +++++++++++++++++++++ tests/casework/test_enrich_court_record.py | 82 +++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 casework/enrich_court_record.py create mode 100644 tests/casework/test_enrich_court_record.py diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py new file mode 100644 index 00000000..4b0ead21 --- /dev/null +++ b/casework/enrich_court_record.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python +"""Accused binds and case dates, read from the case's own NGM court record. + +Zero LLM calls, zero Django, zero source documents. The court record states +these facts rather than inferring them: a defendant is a defendant because a +charge sheet says so, and a verdict date is a verdict date because the Special +Court's docket says so. + +WHAT IT WRITES, in one conditional PATCH per case (`CaseworkApi.patch_case`): + + case_start_date the earliest registration date across the case's court + references, and only when the field is currently empty. + case_end_date the latest deciding-hearing date, and only when the field is + empty AND every court reference on the case has decided. + entities the existing bind list with new `accused` binds appended -- + a whole-list replace of a list merged in application code, + never a delta (`merge_entity_binds`). + +MEASURED COVERAGE (2026-08-07, anonymous GET, all 307 cases of the FY078/079 +census): 307/307 carry a registration date, 306/307 carry an end date, and +307/307 name at least one defendant (1,343 rows). The rule reproduces the +hand-entered convention -- start matches on 46 of 48 published cases, end on +29 of 29 where a deciding hearing exists. + +WHY NOT THE SCORED RESOLVER. `casework.entity_resolver` binds the best candidate +above a threshold. NES holds 162,650 person entities dominated by Election +Commission candidate records, so a scored match can name a namesake as the +accused in a corruption case -- the worst error this platform can make. This +module matches on exact name equality within the `person` prefix, and creates +the entity when there is no unique exact match. The failure mode becomes a +duplicate entity, which is a merge, not a defamation. + +WHY IT NEVER WRITES `convicted`. `decision_type` sits on the CASE, not on each +defendant. `ठहर` on a 19-defendant case does not say who, and `आंशिक ठहर` means +some were convicted and some cleared. `सफाई` is a whole-case acquittal, so it +alone is distributed to each defendant -- and only ever corrects an unfairly +plain "Accused" label. `charged` is true by construction everywhere else: every +case in this corpus is a Special Court `-CR-` case, so CIAA filed a charge sheet. + +Usage: + uv run python -m casework.enrich_court_record --dry-run --verbose +""" + +import logging + +from courts.case_status import parse_case_status + +logger = logging.getLogger(__name__) + +#: Case states this stage may write to. Matches `enrich_related_entities`. +REQUIRED_WRITE_STATE = "DRAFT" + +#: `case_status` on a hearing row that decides the case. +DECIDING_STATUS = "फैसला" + +#: The whole-case acquittal. The ONLY disposition distributed to each defendant. +ACQUITTAL = "सफाई" + +#: NES prefix and schema.org type a court defendant is created under. +PERSON_PREFIX = "person" +PERSON_TYPE = "Person" + +#: A verdict is legal only on an accused bind (the `outcome_only_on_accused` +#: CHECK constraint). Sent explicitly so the claim is visible in the request +#: body rather than implied by the API's omitted-outcome fallback. +CHARGED = "charged" +ACQUITTED = "acquitted" + + +def deciding_hearing(hearings): + """The hearing that decided the case, or None. + + Picked by MAX `hearing_date_ad` among rows whose `case_status` names a + verdict -- never by list position. The hearings endpoint does not sort by + date: on special/079-CR-0151 the 2081-02-22 verdict is returned BEFORE the + 2081-02-21 order that precedes it. + """ + decided = [h for h in (hearings or ()) + if DECIDING_STATUS in (h.get("case_status") or "")] + if not decided: + return None + return max(decided, key=lambda h: h.get("hearing_date_ad") or "") + + +def _reference_end(record): + """`YYYY-MM-DD` this reference decided on, or "" if it has not. + + Two sources, checked in that order: the deciding hearing row, then the + `case_status` string (`फैसला (मिती: २०८१/०२/२२)`), which + `courts.case_status.parse_case_status` already converts BS->AD. Across the + 307-case census the two agreed 277 times out of 277, and 29 cases carry only + the second -- so the fallback is what those 29 depend on, not a tiebreak. + """ + hearing = deciding_hearing(record.get("hearings")) + if hearing and hearing.get("hearing_date_ad"): + return str(hearing["hearing_date_ad"]) + parsed = parse_case_status((record.get("detail") or {}).get("case_status")) + return parsed.verdict_date_ad.isoformat() if parsed.verdict_date_ad else "" + + +def start_date(records): + """The earliest `registration_date_ad` across every court reference, or "". + + Earliest, not first: a case citing two court references started when the + first of them was registered. + """ + dates = [str((r.get("detail") or {}).get("registration_date_ad") or "") + for r in records] + return min((d for d in dates if d), default="") + + +def end_date(records): + """`(value, reason)` -- when the case ended, or "" and why not. + + A case ends when EVERY court reference on it has been decided. One + undecided reference means the case is still being heard, and + `case_end_date` is load-bearing on the public site: the frontend's + `deriveCaseStatus` reads any non-empty value as "concluded" and changes the + status chip. Half-decided is not decided. + """ + if not records: + return "", "no readable court reference" + ends = [_reference_end(r) for r in records] + if not any(ends): + return "", "no decision on record: the case has not been decided" + if not all(ends): + undecided = [f"{r['court']}/{r['number']}" + for r, e in zip(records, ends) if not e] + return "", ("not every court reference has decided (still open: " + + ", ".join(undecided) + ")") + return max(ends), "" diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py new file mode 100644 index 00000000..f83a318b --- /dev/null +++ b/tests/casework/test_enrich_court_record.py @@ -0,0 +1,82 @@ +"""The court-record binder: dates, defendant resolution, and the patch it plans. + +Coverage measured 2026-08-07 across the 307-case FY078/079 census: every court +case carries a registration date, 306 of 307 carry an end date (277 stated by +BOTH a deciding hearing and the case_status string, agreeing 277/277), and all +307 name at least one defendant. +""" + +from casework.enrich_court_record import deciding_hearing, end_date, start_date + + +def _record(reg=None, hearings=(), status=None, parties=()): + return {"court": "special", "number": "079-cr-0151", + "detail": {"registration_date_ad": reg, "case_status": status}, + "hearings": list(hearings), "parties": list(parties)} + + +DECIDED = {"case_status": "फैसला", "decision_type": "सफाई", + "hearing_date_ad": "2024-06-04", "hearing_date_bs": "2081-02-22"} +ADJOURNED = {"case_status": "स्थगित", "decision_type": "पक्षबाट", + "hearing_date_ad": "2024-05-27", "hearing_date_bs": "2081-02-14"} + + +def test_start_date_is_the_court_registration_date(): + assert start_date([_record(reg="2023-06-22")]) == "2023-06-22" + + +def test_start_date_takes_the_earliest_across_references(): + records = [_record(reg="2024-01-01"), _record(reg="2023-06-22")] + assert start_date(records) == "2023-06-22" + + +def test_start_date_is_empty_when_no_reference_carries_one(): + assert start_date([_record(reg=None)]) == "" + + +def test_deciding_hearing_is_picked_by_date_not_list_position(): + # Real ordering from special/079-CR-0151: the verdict sorts BEFORE an + # earlier order in the API response. + later = {**DECIDED, "hearing_date_ad": "2024-06-04"} + earlier = {"case_status": "आदेश", "hearing_date_ad": "2024-06-03"} + assert deciding_hearing([later, earlier]) == later + + +def test_deciding_hearing_ignores_non_deciding_rows(): + assert deciding_hearing([ADJOURNED]) is None + + +def test_end_date_comes_from_the_deciding_hearing(): + value, reason = end_date([_record(reg="2023-06-22", hearings=[ADJOURNED, DECIDED])]) + assert value == "2024-06-04" + assert reason == "" + + +def test_end_date_falls_back_to_the_case_status_string(): + value, reason = end_date([_record(status="फैसला (मिती: २०८१/०२/२२)")]) + assert value == "2024-06-04" + assert reason == "" + + +def test_an_open_case_gets_no_end_date(): + value, reason = end_date([_record(status="विचाराधीन", hearings=[ADJOURNED])]) + assert value == "" + assert "no decision" in reason + + +def test_a_half_decided_case_gets_no_end_date(): + # Two references, only one decided. Writing an end date here would flip the + # public status chip to "concluded" on a case still being heard. + records = [_record(hearings=[DECIDED]), _record(status="विचाराधीन")] + value, reason = end_date(records) + assert value == "" + assert "not every court reference" in reason + + +def test_end_date_takes_the_latest_when_every_reference_decided(): + records = [ + _record(hearings=[DECIDED]), + _record(hearings=[{**DECIDED, "hearing_date_ad": "2025-01-15"}]), + ] + value, _ = end_date(records) + assert value == "2025-01-15" From a20321d480537f8ccfd4d967274b1526dae9db47 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Fri, 7 Aug 2026 11:17:08 -0700 Subject: [PATCH 04/29] test(casework): prove deciding_hearing picks by max date, not filter luck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing test's non-deciding row was filtered out before max() ever ran, so the assertion passed identically under max(), decided[0], or decided[-1]. Add a case with two फैसला-passing rows at different dates, asserted in both list orders, so only an actual max-by-date comparison can satisfy it. --- tests/casework/test_enrich_court_record.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index f83a318b..760a6df0 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -42,6 +42,15 @@ def test_deciding_hearing_is_picked_by_date_not_list_position(): assert deciding_hearing([later, earlier]) == later +def test_deciding_hearing_takes_the_latest_of_several_verdict_rows(): + # Both rows pass the फैसला filter, so this can only pass by comparing + # dates -- neither decided[0] nor decided[-1] would satisfy both asserts. + first = {**DECIDED, "hearing_date_ad": "2024-01-01"} + last = {**DECIDED, "hearing_date_ad": "2024-06-04"} + assert deciding_hearing([first, last]) == last + assert deciding_hearing([last, first]) == last + + def test_deciding_hearing_ignores_non_deciding_rows(): assert deciding_hearing([ADJOURNED]) is None From 9dffe05f28239c7fb3df12ebd65085a3b45246dd Mon Sep 17 00:00:00 2001 From: GAURAV Date: Fri, 7 Aug 2026 11:29:02 -0700 Subject: [PATCH 05/29] feat(casework): resolve court defendants by exact person name, or create them Adds the three-rung ladder for Task 4: a row's own nes_id is a pure copy, then exact name equality within the person prefix (never the scored resolver, which would pick a namesake by rank), then create-and-bind with run-scoped de-duplication. Fixes two defects found in the plan's own test snippet: the create payload was missing the server-required "slug" field, and one collision-path assertion hardcoded an IRI that doesn't match entity_slug's real (schwa-preserving) transliteration of the same name. --- casework/enrich_court_record.py | 115 +++++++++++++++++++ tests/casework/test_enrich_court_record.py | 127 +++++++++++++++++++++ 2 files changed, 242 insertions(+) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 4b0ead21..14ba7ce1 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -42,8 +42,17 @@ """ import logging +from dataclasses import dataclass +from casework.common.api import EntityAlreadyExists +from casework.entity_identity import entity_slug, prefix_is_creatable +from casework.entity_resolver import normalise_name from courts.case_status import parse_case_status +from jawafdehi_shared.entities.ids import ( + build_entity_iri, + is_valid_entity_iri, + parse_entity_iri, +) logger = logging.getLogger(__name__) @@ -129,3 +138,109 @@ def end_date(records): return "", ("not every court reference has decided (still open: " + ", ".join(undecided) + ")") return max(ends), "" + + +@dataclass(frozen=True) +class Resolution: + """One defendant name's outcome. `how` is the ladder rung it settled on.""" + nes_id: str + how: str + reason: str = "" + + +def _is_person(nes_id): + """Whether this IRI names a person entity. + + `startswith`, not equality: NES nests person categories (`person/politician`), + and every one of them is still a person. + """ + try: + return parse_entity_iri(nes_id).prefix.split("/")[0] == PERSON_PREFIX + except Exception: # noqa: BLE001 - a malformed IRI is simply not a person + return False + + +def exact_person_match(api, name): + """`(nes_id, reason)` -- the ONE person entity whose name is identical, or "". + + Equality after `normalise_name` (NFC, punctuation and case folded), not a + similarity score. Two entities sharing that exact name is an ambiguity and + binds nothing: NES holds 13 rows for `संजय प्रसाद यादव`, and picking one by + score is how a corruption case names the wrong person. + """ + wanted = normalise_name(name) + if not wanted: + return "", "empty name" + hits = {} + for result in api.search_entities(name) or (): + nes_id = (result.get("id") or "").strip() + if not is_valid_entity_iri(nes_id) or not _is_person(nes_id): + continue + titles = (result.get("title") or {}) + if any(normalise_name(t) == wanted for t in (titles.get("ne"), titles.get("en")) if t): + hits[nes_id] = True + if len(hits) == 1: + return next(iter(hits)), "" + if len(hits) > 1: + return "", f"{len(hits)} person entities carry this exact name" + return "", "no person entity carries this exact name" + + +def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, + run_entities, dry_run): + """Turn one court-record defendant name into an NES entity id. + + The ladder, top to bottom: + 1. the court row's own `nes_id` -- a pure copy, no judgment + 2. exactly one person entity with that identical name + 3. create the entity from the court record + + `run_entities` maps a normalised name to an IRI already created THIS RUN and + is shared across cases on purpose: without it, two cases naming the same + defendant create two entities. Nothing here raises -- a name that cannot + become an entity is reported and the case keeps its other defendants. + """ + row_nes_id = (row_nes_id or "").strip() + if row_nes_id and is_valid_entity_iri(row_nes_id): + return Resolution(row_nes_id, "nes_id") + + matched, why = exact_person_match(api, name) + if matched: + return Resolution(matched, "exact") + + key = normalise_name(name) + if key in run_entities: + return Resolution(run_entities[key], "created", "reused from this run") + + if not prefix_is_creatable(PERSON_PREFIX, live_prefixes): + return Resolution("", "failed", f"{why}; the person prefix is not creatable") + slug = entity_slug(name) + if not slug: + return Resolution("", "failed", f"{why}; the name cannot be slugged") + + iri = build_entity_iri(PERSON_PREFIX, slug) + if dry_run: + # POST nothing, but report the IRI an --apply run would use, so the + # printed patch is the one that would be sent. + run_entities[key] = iri + return Resolution(iri, "created", "would create") + + # `slug` is sent explicitly, not left for the server to derive: the create + # view's `normalize_authoring_payload` (entities/write_validation.py) + # raises "slug is required" on a payload missing it, since it has no `@id` + # to fall back on. Omitting it would 422 every single creation, which the + # brief's own stub-backed tests cannot catch because the stub never + # validates a payload shape. + payload = {"prefix": PERSON_PREFIX, "slug": slug, "type": PERSON_TYPE, "name": name} + if citation: + payload["citation"] = citation + try: + created = api.create_entity(payload) + iri = (created or {}).get("@id") or iri + except EntityAlreadyExists: + # The IRI is taken, which means the entity we wanted already exists. + pass + except Exception as exc: # noqa: BLE001 - one failed POST costs this name, not the case + return Resolution("", "failed", f"could not create the entity ({type(exc).__name__})") + run_entities[key] = iri + return Resolution(iri, "created") diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 760a6df0..29da95c7 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -89,3 +89,130 @@ def test_end_date_takes_the_latest_when_every_reference_decided(): ] value, _ = end_date(records) assert value == "2025-01-15" + + +from casework.common.api import EntityAlreadyExists # noqa: E402 +from casework.entity_identity import entity_slug # noqa: E402 +from casework.enrich_court_record import ( # noqa: E402 + PERSON_PREFIX, + exact_person_match, + resolve_defendant, +) +from jawafdehi_shared.entities.ids import build_entity_iri # noqa: E402 + +YADAV = "https://jawafdehi.org/entity/person/krishna-prasad-yadav" +ORG = "https://jawafdehi.org/entity/organization/krishna-prasad-yadav" + + +class _SearchApi: + def __init__(self, results=(), created=None): + self.results, self.created, self.posted = list(results), created, [] + + def search_entities(self, query, **kwargs): + return self.results + + def create_entity(self, payload, timeout=60): + self.posted.append(payload) + if isinstance(self.created, Exception): + raise self.created + return self.created or {"@id": YADAV} + + +def _hit(nes_id, ne): + return {"id": nes_id, "title": {"ne": ne}} + + +def test_a_row_carrying_an_nes_id_is_a_pure_copy(): + api = _SearchApi() + got = resolve_defendant(api, "कृष्ण प्रसाद यादव", YADAV, citation="", + live_prefixes=["person"], run_entities={}, dry_run=True) + assert (got.nes_id, got.how) == (YADAV, "nes_id") + assert api.posted == [] + + +def test_one_exact_person_match_binds(): + api = _SearchApi([_hit(YADAV, "कृष्ण प्रसाद यादव")]) + got = resolve_defendant(api, "कृष्ण प्रसाद यादव", None, citation="", + live_prefixes=["person"], run_entities={}, dry_run=True) + assert (got.nes_id, got.how) == (YADAV, "exact") + + +def test_two_entities_with_the_same_exact_name_do_not_bind(): + # The namesake case. NES holds 13 rows for "संजय प्रसाद यादव". + twin = "https://jawafdehi.org/entity/person/krishna-prasad-yadav-2" + api = _SearchApi([_hit(YADAV, "कृष्ण प्रसाद यादव"), _hit(twin, "कृष्ण प्रसाद यादव")]) + nes_id, reason = exact_person_match(api, "कृष्ण प्रसाद यादव") + assert nes_id == "" + assert "2 person entities" in reason + + +def test_a_non_person_entity_is_never_an_exact_match(): + api = _SearchApi([_hit(ORG, "कृष्ण प्रसाद यादव")]) + nes_id, reason = exact_person_match(api, "कृष्ण प्रसाद यादव") + assert nes_id == "" + assert "no person entity" in reason + + +def test_a_near_match_is_not_a_match(): + # कमला (feminine) must never satisfy कमल (masculine). The scored resolver + # gives this 0.96 through the English title; equality gives it nothing. + api = _SearchApi([_hit("https://jawafdehi.org/entity/person/kamala-thapa", + "कमला थापा")]) + nes_id, _ = exact_person_match(api, "कमल थापा") + assert nes_id == "" + + +def test_no_match_creates_the_entity_and_binds_it(): + api = _SearchApi(results=[], created={"@id": YADAV}) + got = resolve_defendant(api, "कृष्ण प्रसाद यादव", None, + citation="https://jawafdehi.org/material/court/special.079-cr-0151", + live_prefixes=["person"], run_entities={}, dry_run=False) + assert (got.nes_id, got.how) == (YADAV, "created") + assert api.posted[0]["prefix"] == "person" + assert api.posted[0]["type"] == "Person" + assert api.posted[0]["name"] == "कृष्ण प्रसाद यादव" + + +def test_a_dry_run_posts_nothing_but_reports_the_iri_it_would_use(): + api = _SearchApi(results=[]) + got = resolve_defendant(api, "कृष्ण प्रसाद यादव", None, citation="", + live_prefixes=["person"], run_entities={}, dry_run=True) + assert got.how == "created" + assert got.nes_id.startswith("https://jawafdehi.org/entity/person/") + assert api.posted == [] + + +def test_the_same_person_across_two_cases_creates_one_entity(): + api = _SearchApi(results=[], created={"@id": YADAV}) + run_entities = {} + for _ in range(2): + resolve_defendant(api, "कृष्ण प्रसाद यादव", None, citation="", + live_prefixes=["person"], run_entities=run_entities, + dry_run=False) + assert len(api.posted) == 1 + + +def test_an_existing_iri_collision_binds_the_existing_entity(): + # The stub raises before returning anything, so `resolve_defendant` never + # reads the exception's payload -- a real 409 body is an opaque error blob, + # not a clean IRI. What it keeps is the IRI it computed BEFORE the POST, + # which by construction of a same-slug collision IS the entity that was + # already there. `EntityAlreadyExists(YADAV)`'s argument is therefore + # unread by design; expressed here as the actual `entity_slug` output + # rather than `YADAV` itself, whose hand-picked spelling drops the schwas + # `entity_slug` keeps (`कृष्ण प्रसाद यादव` -> `krishna-prasada-yadava`, not + # `krishna-prasad-yadav`). + api = _SearchApi(results=[], created=EntityAlreadyExists(YADAV)) + got = resolve_defendant(api, "कृष्ण प्रसाद यादव", None, citation="", + live_prefixes=["person"], run_entities={}, dry_run=False) + assert got.how == "created" + assert got.nes_id == build_entity_iri(PERSON_PREFIX, + entity_slug("कृष्ण प्रसाद यादव")) + + +def test_a_name_that_cannot_be_slugged_fails_without_raising(): + api = _SearchApi(results=[]) + got = resolve_defendant(api, " ", None, citation="", + live_prefixes=["person"], run_entities={}, dry_run=True) + assert (got.nes_id, got.how) == ("", "failed") + assert got.reason From 00c4ac76d4767056bb595b44c2c5766d9565ea3e Mon Sep 17 00:00:00 2001 From: GAURAV Date: Fri, 7 Aug 2026 11:41:47 -0700 Subject: [PATCH 06/29] fix(casework): fail cautious on a truncated search window, and stop resolve_defendant from ever raising Review round 1 findings: - CRITICAL: exact_person_match ignored CandidateList.complete, so a single hit inside a search window that stopped on relevance (not exhaustion) read as a confirmed unique match. That is how a namesake gets bound as accused on a corruption case -- the exact failure this ladder exists to prevent. Now a lone hit from an incomplete window falls through to creation instead of binding. - IMPORTANT: resolve_defendant called exact_person_match unguarded, so a transient search-API error would propagate and kill the whole run. Wrapped it the same way the create POST already was. - IMPORTANT: _is_person's nested-category behavior (person/politician IS a person, personnel/organization are NOT) had no direct coverage. Also corrects _is_person's docstring (it does not use startswith) and drops a file-path citation from a comment. --- casework/enrich_court_record.py | 62 ++++++++++++----- tests/casework/test_enrich_court_record.py | 77 ++++++++++++++++++++-- 2 files changed, 120 insertions(+), 19 deletions(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 14ba7ce1..93baba0e 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -151,8 +151,12 @@ class Resolution: def _is_person(nes_id): """Whether this IRI names a person entity. - `startswith`, not equality: NES nests person categories (`person/politician`), - and every one of them is still a person. + Compares only the IRI's FIRST slash-segment, not the whole prefix and not + `startswith`: NES nests person categories (`person/politician`), and every + one of them is still a person, so plain equality against `PERSON_PREFIX` + would wrongly refuse them. A literal `startswith` check goes too far the + other way -- it would also match an unrelated `personnel/...` prefix -- + which `.split("/")[0] ==` does not. """ try: return parse_entity_iri(nes_id).prefix.split("/")[0] == PERSON_PREFIX @@ -167,23 +171,47 @@ def exact_person_match(api, name): similarity score. Two entities sharing that exact name is an ambiguity and binds nothing: NES holds 13 rows for `संजय प्रसाद यादव`, and picking one by score is how a corruption case names the wrong person. + + A SINGLE hit is refused too, when it came from an incomplete search + window. `CaseworkApi.search_entities` returns a `CandidateList` whose + `.complete` is False when paging stopped on relevance rather than running + out of rows -- `संजय प्रसाद यादव` fills a full 50-row page and stops there + on relevance, and same-title rows do not score identically (that name's + own duplicates sit at 130.981 and 130.564), so a block of namesakes can + straddle the page edge. One of them landing inside the fetched window then + looks "unique" while its twins sit unseen just past it -- the exact + failure this ladder exists to prevent. The asymmetry is why this fails + cautious rather than optimistic: a true match sitting outside the window + just becomes a duplicate entity (a merge), but a truncated window + promoting a namesake to "unique" binds the wrong person to a corruption + case (a defamation). `getattr(..., "complete", False)` so a plain list -- + what a stub or a hand-built candidate list returns -- gets the cautious + answer by default. """ wanted = normalise_name(name) if not wanted: return "", "empty name" + results = api.search_entities(name) or () + complete = getattr(results, "complete", False) hits = {} - for result in api.search_entities(name) or (): + for result in results: + if not isinstance(result, dict): + continue nes_id = (result.get("id") or "").strip() if not is_valid_entity_iri(nes_id) or not _is_person(nes_id): continue - titles = (result.get("title") or {}) + titles = result.get("title") or {} if any(normalise_name(t) == wanted for t in (titles.get("ne"), titles.get("en")) if t): hits[nes_id] = True - if len(hits) == 1: - return next(iter(hits)), "" + if not hits: + return "", "no person entity carries this exact name" if len(hits) > 1: return "", f"{len(hits)} person entities carry this exact name" - return "", "no person entity carries this exact name" + if not complete: + return "", ("exactly one exact match, but the search window is " + "incomplete: a namesake could be sitting just past the " + "edge where this check could not see it") + return next(iter(hits)), "" def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, @@ -198,13 +226,18 @@ def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, `run_entities` maps a normalised name to an IRI already created THIS RUN and is shared across cases on purpose: without it, two cases naming the same defendant create two entities. Nothing here raises -- a name that cannot - become an entity is reported and the case keeps its other defendants. + become an entity is reported and the case keeps its other defendants. That + covers the search read too: one transient 502 on one of a case's several + defendant rows costs that row, not the run. """ row_nes_id = (row_nes_id or "").strip() if row_nes_id and is_valid_entity_iri(row_nes_id): return Resolution(row_nes_id, "nes_id") - matched, why = exact_person_match(api, name) + try: + matched, why = exact_person_match(api, name) + except Exception as exc: # noqa: BLE001 - one bad search costs this name, not the case + return Resolution("", "failed", f"could not search for a match ({type(exc).__name__})") if matched: return Resolution(matched, "exact") @@ -225,12 +258,11 @@ def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, run_entities[key] = iri return Resolution(iri, "created", "would create") - # `slug` is sent explicitly, not left for the server to derive: the create - # view's `normalize_authoring_payload` (entities/write_validation.py) - # raises "slug is required" on a payload missing it, since it has no `@id` - # to fall back on. Omitting it would 422 every single creation, which the - # brief's own stub-backed tests cannot catch because the stub never - # validates a payload shape. + # `slug` is sent explicitly, not left for the server to derive: + # `normalize_authoring_payload` raises "slug is required" on a payload + # missing it, since it has no `@id` to fall back on. Omitting it would 422 + # every single creation, which the brief's own stub-backed tests cannot + # catch because the stub never validates a payload shape. payload = {"prefix": PERSON_PREFIX, "slug": slug, "type": PERSON_TYPE, "name": name} if citation: payload["citation"] = citation diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 29da95c7..6f34c0a8 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -91,10 +91,13 @@ def test_end_date_takes_the_latest_when_every_reference_decided(): assert value == "2025-01-15" +import urllib.error # noqa: E402 + from casework.common.api import EntityAlreadyExists # noqa: E402 from casework.entity_identity import entity_slug # noqa: E402 from casework.enrich_court_record import ( # noqa: E402 PERSON_PREFIX, + _is_person, exact_person_match, resolve_defendant, ) @@ -104,12 +107,23 @@ def test_end_date_takes_the_latest_when_every_reference_decided(): ORG = "https://jawafdehi.org/entity/organization/krishna-prasad-yadav" +class _Results(list): + """A plain list plus `.complete`, standing in for `CandidateList`.""" + complete = False + + class _SearchApi: - def __init__(self, results=(), created=None): + def __init__(self, results=(), created=None, complete=False): self.results, self.created, self.posted = list(results), created, [] + # Cautious by default, matching `CandidateList`'s own default: a test + # that wants a bind on a single hit must say `complete=True` itself + # rather than get it for free from an unmarked plain list. + self.complete = complete def search_entities(self, query, **kwargs): - return self.results + results = _Results(self.results) + results.complete = self.complete + return results def create_entity(self, payload, timeout=60): self.posted.append(payload) @@ -131,14 +145,30 @@ def test_a_row_carrying_an_nes_id_is_a_pure_copy(): def test_one_exact_person_match_binds(): - api = _SearchApi([_hit(YADAV, "कृष्ण प्रसाद यादव")]) + # A COMPLETE window with one hit is the clean case: nothing else can be + # hiding, so the match is safe to bind. + api = _SearchApi([_hit(YADAV, "कृष्ण प्रसाद यादव")], complete=True) got = resolve_defendant(api, "कृष्ण प्रसाद यादव", None, citation="", live_prefixes=["person"], run_entities={}, dry_run=True) assert (got.nes_id, got.how) == (YADAV, "exact") +def test_a_single_hit_from_an_incomplete_window_does_not_bind(): + # Same premise as the two-namesake test below, caught one page earlier. + # `संजय प्रसाद यादव` fills a full 50-row page and stops on relevance, so a + # lone hit inside an INCOMPLETE window can have a dozen unseen twins just + # past the edge -- exactly the failure this ladder exists to prevent. + # `_SearchApi` defaults to `complete=False`, so this is the plain case. + api = _SearchApi([_hit(YADAV, "कृष्ण प्रसाद यादव")]) + nes_id, reason = exact_person_match(api, "कृष्ण प्रसाद यादव") + assert nes_id == "" + assert "incomplete" in reason + + def test_two_entities_with_the_same_exact_name_do_not_bind(): - # The namesake case. NES holds 13 rows for "संजय प्रसाद यादव". + # The namesake case. NES holds 13 rows for "संजय प्रसाद यादव". Two CONFIRMED + # hits are ambiguous regardless of window completeness, so this is written + # against the (default) incomplete window on purpose. twin = "https://jawafdehi.org/entity/person/krishna-prasad-yadav-2" api = _SearchApi([_hit(YADAV, "कृष्ण प्रसाद यादव"), _hit(twin, "कृष्ण प्रसाद यादव")]) nes_id, reason = exact_person_match(api, "कृष्ण प्रसाद यादव") @@ -216,3 +246,42 @@ def test_a_name_that_cannot_be_slugged_fails_without_raising(): live_prefixes=["person"], run_entities={}, dry_run=True) assert (got.nes_id, got.how) == ("", "failed") assert got.reason + + +def test_a_search_failure_fails_only_this_name(): + # `search_entities` -> `CaseworkApi.get` -> `_request` can raise + # `urllib.error.HTTPError` on a transient 502; one bad row out of a case's + # several defendants must not kill the run that is processing the rest. + class _FlakyApi(_SearchApi): + def search_entities(self, query, **kwargs): + raise urllib.error.HTTPError("https://jawafdehi.org", 502, + "Bad Gateway", {}, None) + + got = resolve_defendant(_FlakyApi(), "कृष्ण प्रसाद यादव", None, citation="", + live_prefixes=["person"], run_entities={}, dry_run=True) + assert got.how == "failed" + assert got.reason + + +def test_is_person_recognises_a_nested_person_category(): + # `person/politician` is a category NES nests under `person`, and it must + # still count as a person -- the whole reason `_is_person` compares only + # the first slash-segment rather than the whole prefix. + assert _is_person(YADAV) is True + assert _is_person(build_entity_iri("person/politician", "some-slug")) is True + + +def test_is_person_refuses_a_lookalike_prefix_and_other_types(): + # `personnel` shares a spelling prefix with `person` but is not one -- the + # case a literal `startswith` would get wrong. A nested non-person prefix + # (`organization/government`) must be refused too. + assert _is_person(build_entity_iri("personnel", "someone")) is False + assert _is_person( + build_entity_iri("organization/government", "ministry-of-example") + ) is False + + +def test_is_person_never_raises_on_a_malformed_iri(): + assert _is_person("not-a-valid-iri") is False + assert _is_person("") is False + assert _is_person(None) is False From bdaa351f6f038facf43829ea93f4e0d7daa8b59b Mon Sep 17 00:00:00 2001 From: GAURAV Date: Fri, 7 Aug 2026 11:47:31 -0700 Subject: [PATCH 07/29] feat(casework): plan the court-record patch, merging binds and never overwriting --- casework/enrich_court_record.py | 118 ++++++++++++++++++++- tests/casework/test_enrich_court_record.py | 110 +++++++++++++++++++ 2 files changed, 227 insertions(+), 1 deletion(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 93baba0e..0c83d6af 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -42,11 +42,17 @@ """ import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from casework.common.api import EntityAlreadyExists +from casework.court_record import court_record_for_case from casework.entity_identity import entity_slug, prefix_is_creatable from casework.entity_resolver import normalise_name +from casework.enrich_related_entities import ( + bind_key, + merge_entity_binds, + validate_bind_item, +) from courts.case_status import parse_case_status from jawafdehi_shared.entities.ids import ( build_entity_iri, @@ -276,3 +282,113 @@ def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, return Resolution("", "failed", f"could not create the entity ({type(exc).__name__})") run_entities[key] = iri return Resolution(iri, "created") + + +@dataclass +class CasePlan: + """The write for one case, or the reason there isn't one.""" + slug: str + status: str + fields: list = field(default_factory=list) + entities: object = None # merged full list, or None for "no change" + if_match: str = "" + rows: list = field(default_factory=list) + skips: list = field(default_factory=list) + + +def bind_outcome(records): + """The `outcome` every defendant on this case gets. + + ACQUITTED only when every decided reference decided `सफाई` -- a whole-case + acquittal, which applies to each defendant and can only ever correct an + unfairly plain "Accused" label. Everything else is CHARGED, which is true by + construction: CIAA filed a charge sheet on every case in this corpus. + + Never `convicted`. `ठहर` on a 19-defendant case does not say who, and + `आंशिक ठहर` means some were convicted and some cleared. + """ + dispositions = [ + (deciding_hearing(r.get("hearings")) or {}).get("decision_type") or "" + for r in records + ] + decided = [d for d in dispositions if d] + if decided and all(ACQUITTAL in d for d in decided): + return ACQUITTED + return CHARGED + + +def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run): + """`(items, rows)` -- one bind per named defendant, plus a report row each. + + De-duplicated by name across every court reference on the case, order + preserved, exactly like `defendant_names` does. + """ + outcome = bind_outcome(records) + citation = (records[0].get("detail") or {}).get("material_id", "") if records else "" + items, rows, seen = [], [], set() + for record in records: + for party in record.get("parties") or (): + if (party.get("side") or "").strip().lower() != "defendant": + continue + name = (party.get("name") or "").strip() + if not name or name in seen: + continue + seen.add(name) + got = resolve_defendant( + api, name, party.get("nes_id"), citation=citation, + live_prefixes=live_prefixes, run_entities=run_entities, + dry_run=dry_run) + row = {"slug": case.get("slug"), "name": name, "how": got.how, + "nes_id": got.nes_id, "outcome": outcome, "reason": got.reason, + "court_case": f"{record['court']}/{record['number']}"} + rows.append(row) + if not got.nes_id: + continue + item = {"nes_id": got.nes_id, "relationship_type": "accused", + "outcome": outcome, + "notes": f"प्रतिवादी — विशेष अदालत मुद्दा {record['number']}"} + try: + items.append(validate_bind_item(item)) + except ValueError as exc: + row.update(how="failed", reason=str(exc)) + return items, rows + + +def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run): + """Build the write for one case. Reads the court record; writes nothing.""" + slug = case.get("slug") or "" + if (case.get("state") or "").upper() != REQUIRED_WRITE_STATE: + return CasePlan(slug, "skip-state", + skips=[f"state is {case.get('state')!r}, not " + f"{REQUIRED_WRITE_STATE}"]) + + records, skips = court_record_for_case(api, case) + if not records: + return CasePlan(slug, "no-court-reference", skips=skips) + + fields = [] + if not case.get("case_start_date"): + if start := start_date(records): + fields.append(("case_start_date", start)) + if not case.get("case_end_date"): + end, why = end_date(records) + if end: + fields.append(("case_end_date", end)) + elif why: + skips.append(f"case_end_date left empty: {why}") + + items, rows = _accused_binds( + api, case, records, live_prefixes=live_prefixes, + run_entities=run_entities, dry_run=dry_run) + + current = list(case.get("entities") or []) + merged = merge_entity_binds(current, items) + # `merge_entity_binds` appends only what is missing, so an unchanged length + # means every proposed bind was already present -- send no list at all + # rather than a destructive replace with identical contents. + have = {bind_key(b) for b in current} + entities = merged if any(bind_key(i) not in have for i in items) else None + + status = "would-patch" if (fields or entities is not None) else "nothing-to-do" + return CasePlan(slug, status, fields=fields, entities=entities, + if_match=etag or "", rows=rows, skips=skips) diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 6f34c0a8..e450facb 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -285,3 +285,113 @@ def test_is_person_never_raises_on_a_malformed_iri(): assert _is_person("not-a-valid-iri") is False assert _is_person("") is False assert _is_person(None) is False + + +from casework.enrich_court_record import ACQUITTED, CHARGED, bind_outcome, plan_case # noqa: E402 + +CASE_IRI = "https://jawafdehi.org/courtcase/special/079-cr-0151" + + +class _PlanApi(_SearchApi): + def __init__(self, detail=None, hearings=(), parties=(), **kw): + super().__init__(**kw) + self._detail, self._hearings, self._parties = detail or {}, list(hearings), list(parties) + + def get_courtcase(self, court, number, timeout=60): + return self._detail + + def list_hearings(self, court, number, timeout=60): + return self._hearings + + def get_court_case_entities(self, court, number, timeout=60): + return self._parties + + +def _case(**over): + base = {"slug": "case-079-cr-0151", "state": "DRAFT", "court_cases": [CASE_IRI], + "case_start_date": None, "case_end_date": None, "entities": []} + base.update(over) + return base + + +def _plan(api, case, **kw): + kw.setdefault("live_prefixes", ["person"]) + kw.setdefault("run_entities", {}) + kw.setdefault("dry_run", True) + return plan_case(api, case, 'W/"7"', **kw) + + +def test_a_whole_case_acquittal_labels_every_defendant_acquitted(): + assert bind_outcome([_record(hearings=[DECIDED])]) == ACQUITTED + + +def test_a_conviction_still_labels_defendants_charged(): + convicted = {**DECIDED, "decision_type": "ठहर"} + assert bind_outcome([_record(hearings=[convicted])]) == CHARGED + + +def test_a_partial_conviction_labels_defendants_charged(): + partial = {**DECIDED, "decision_type": "आंशिक ठहर"} + assert bind_outcome([_record(hearings=[partial])]) == CHARGED + + +def test_an_undecided_case_labels_defendants_charged(): + assert bind_outcome([_record(status="विचाराधीन")]) == CHARGED + + +def test_the_plan_carries_both_dates_and_the_accused_binds(): + api = _PlanApi( + detail={"registration_date_ad": "2023-06-22"}, + hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}, + {"side": "plaintiff", "name": "नेपाल सरकार"}], + ) + plan = _plan(api, _case()) + assert dict(plan.fields) == {"case_start_date": "2023-06-22", + "case_end_date": "2024-06-04"} + assert plan.entities == [{"nes_id": YADAV, "relationship_type": "accused", + "outcome": ACQUITTED, + "notes": "प्रतिवादी — विशेष अदालत मुद्दा 079-cr-0151"}] + assert plan.status == "would-patch" + + +def test_a_plaintiff_is_never_bound(): + api = _PlanApi(detail={"registration_date_ad": "2023-06-22"}, + parties=[{"side": "plaintiff", "name": "नेपाल सरकार"}]) + assert _plan(api, _case()).entities is None + + +def test_an_existing_bind_survives_untouched(): + existing = {"nes_id": YADAV, "relationship_type": "accused", + "outcome": "convicted", "notes": "hand-written by a caseworker"} + api = _PlanApi(detail={"registration_date_ad": "2023-06-22"}, + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", + "nes_id": YADAV}]) + plan = _plan(api, _case(entities=[existing])) + # Same (nes_id, relationship_type) -> already present -> nothing to write. + assert plan.entities is None + + +def test_a_populated_date_is_never_overwritten(): + api = _PlanApi(detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED]) + plan = _plan(api, _case(case_start_date="2020-01-01", case_end_date="2021-01-01")) + assert plan.fields == [] + + +def test_a_case_with_nothing_to_change_is_a_skip(): + api = _PlanApi(detail={}, parties=[]) + plan = _plan(api, _case()) + assert plan.status == "nothing-to-do" + assert plan.fields == [] and plan.entities is None + + +def test_a_case_with_no_court_reference_reports_why(): + plan = _plan(_PlanApi(), _case(court_cases=[])) + assert plan.status == "no-court-reference" + assert "no court reference" in plan.skips[0] + + +def test_a_non_draft_case_is_refused(): + plan = _plan(_PlanApi(detail={"registration_date_ad": "2023-06-22"}), + _case(state="PUBLISHED")) + assert plan.status == "skip-state" From cfc05a9e4a14267ab6fef89377b114212056fd34 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Fri, 7 Aug 2026 11:59:42 -0700 Subject: [PATCH 08/29] fix(casework): merge against the PATCH shape, refuse a payload with no entities key, and require every reference to plainly acquit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plan_case now merges via current_entity_binds (PATCH shape), not the raw read list, which keyed every existing bind as (nes_id, "") and appended duplicate accused binds while re-sending outcome. - Refuse a case payload missing the "entities" key outright, matching enrich_related_entities.plan_case_entities's own guard against the same destructive-replace hazard. - bind_outcome now requires every court reference to have decided, using the same two-source truth end_date already relies on, and refuses to call a hearing cell a plain acquittal when it also carries आंशिक or ठहर. --- casework/enrich_court_record.py | 81 +++++++++++++++++++--- tests/casework/test_enrich_court_record.py | 57 ++++++++++++++- 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 0c83d6af..5ded68c5 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -50,6 +50,7 @@ from casework.entity_resolver import normalise_name from casework.enrich_related_entities import ( bind_key, + current_entity_binds, merge_entity_binds, validate_bind_item, ) @@ -296,23 +297,56 @@ class CasePlan: skips: list = field(default_factory=list) +def _reference_disposition(record): + """`(decided, is_plain_acquittal)` for one court reference. + + `decided` reuses `_reference_end`'s own truth -- non-empty means decided -- + so this function and `_reference_end` (and therefore `bind_outcome` and + `end_date`) can never disagree about whether a reference has concluded. A + reference decided only through the `case_status` paren-date fallback (29 + cases in the census carry only that source, per `_reference_end`'s own + docstring) carries no outcome text at all, so it is `decided` but never + `is_plain_acquittal` -- conservative in the direction this function + already leans, since CHARGED is the default outcome throughout. + + `is_plain_acquittal` is read off the deciding hearing's `decision_type` + ONLY, and only when that free-text cell says `सफाई` and nothing else + qualifies it. The hearings API returns raw portal text, and this corpus + contains compounds that qualify the word rather than standing alone (e.g. + `आदेश >> आंशिक कसुर ठहर सजाय निर्धारणको लागि पेश गर्ने`). `courts.case_status`'s + own hearing-decision map puts `आंशिक` first for exactly this reason -- a + bare substring test on `ठहर` once recorded 593 court_cases as a full + CONVICTED from a cell that actually said `आंशिक ...ठहर`. The same care + applies here: a cell naming `आंशिक` or `ठहर` alongside `सफाई` is not a plain + acquittal, so it is refused rather than guessed at. + """ + decided = bool(_reference_end(record)) + text = (deciding_hearing(record.get("hearings")) or {}).get("decision_type") or "" + plain_acquittal = bool(text) and ACQUITTAL in text and "आंशिक" not in text and "ठहर" not in text + return decided, plain_acquittal + + def bind_outcome(records): """The `outcome` every defendant on this case gets. - ACQUITTED only when every decided reference decided `सफाई` -- a whole-case - acquittal, which applies to each defendant and can only ever correct an - unfairly plain "Accused" label. Everything else is CHARGED, which is true by - construction: CIAA filed a charge sheet on every case in this corpus. + ACQUITTED only when EVERY court reference on the case has decided AND every + one of those decisions was a plain `सफाई` -- a whole-case acquittal, which + applies to each defendant and can only ever correct an unfairly plain + "Accused" label. Everything else is CHARGED, which is true by construction: + CIAA filed a charge sheet on every case in this corpus. + + A single undecided reference must not acquit the rest: half-decided is not + decided here any more than it is in `end_date`, and stamping ACQUITTED on a + case that is still being heard is the opposite of true. `_reference_disposition` + is what keeps the two functions from disagreeing about what "decided" means. Never `convicted`. `ठहर` on a 19-defendant case does not say who, and `आंशिक ठहर` means some were convicted and some cleared. """ - dispositions = [ - (deciding_hearing(r.get("hearings")) or {}).get("decision_type") or "" - for r in records - ] - decided = [d for d in dispositions if d] - if decided and all(ACQUITTAL in d for d in decided): + dispositions = [_reference_disposition(r) for r in records] + if (dispositions + and all(decided for decided, _ in dispositions) + and all(acquitted for _, acquitted in dispositions)): return ACQUITTED return CHARGED @@ -362,6 +396,20 @@ def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run): skips=[f"state is {case.get('state')!r}, not " f"{REQUIRED_WRITE_STATE}"]) + if "entities" not in case: + # `case.get("entities") or []` cannot tell "this case has no binds" + # from "this payload does not carry binds at all" (a trimmed dict from + # a list endpoint, a projected read). Merging against a false-empty + # `current` would produce a fully-shaped, validly-formed `entities` + # list containing only the NEW binds -- which PATCHes clean and + # silently deletes every bind the case actually has. Refused outright, + # matching `enrich_related_entities.plan_case_entities`'s own guard for + # the identical hazard. + return CasePlan(slug, "no-entities-key", + skips=["case payload has no 'entities' key -- absent " + "is not empty; refusing to plan a write from " + "an incomplete read"]) + records, skips = court_record_for_case(api, case) if not records: return CasePlan(slug, "no-court-reference", skips=skips) @@ -381,7 +429,18 @@ def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run): api, case, records, live_prefixes=live_prefixes, run_entities=run_entities, dry_run=dry_run) - current = list(case.get("entities") or []) + # `current_entity_binds`, NOT the raw `case["entities"]` list: the read + # shape keys the relationship type under `type`, and `relationship_type` + # never appears on a read at all. Merging against the raw list means + # `bind_key` reads every existing bind as `(nes_id, "")`, so an + # already-present accused bind never matches the proposed one -- + # `merge_entity_binds` then appends a SECOND bind for the same person, the + # merged rows carry no `relationship_type` at all (a 400 from + # `EntityPatchItemSerializer` on every case that already has any bind), and + # the existing `outcome` gets re-sent instead of staying dropped. The + # translator produces the PATCH shape and deliberately drops `outcome`, so + # an existing verdict is preserved rather than reset. + current = current_entity_binds(case) merged = merge_entity_binds(current, items) # `merge_entity_binds` appends only what is missing, so an unchanged length # means every proposed bind was already present -- send no list at all diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index e450facb..bf7140ad 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -339,6 +339,39 @@ def test_an_undecided_case_labels_defendants_charged(): assert bind_outcome([_record(status="विचाराधीन")]) == CHARGED +def test_a_decided_reference_plus_an_undecided_one_is_charged(): + # One reference decided सफाई, the other still open. Half-decided is not + # decided -- the same doctrine `end_date` already applies -- so this must + # not acquit a case that is still being heard. + records = [_record(hearings=[DECIDED]), _record(status="विचाराधीन")] + assert bind_outcome(records) == CHARGED + + +def test_a_decided_acquittal_plus_a_conviction_is_charged(): + convicted = {**DECIDED, "decision_type": "ठहर"} + records = [_record(hearings=[DECIDED]), _record(hearings=[convicted])] + assert bind_outcome(records) == CHARGED + + +def test_a_reference_decided_only_via_case_status_cannot_acquit(): + # This reference decided (the paren-date form parses to a date), but + # carries no hearing row and therefore no outcome text at all -- it can + # never be confirmed a plain acquittal, so mixed with a सफाई hearing on + # the other reference the case still reads CHARGED. + records = [_record(hearings=[DECIDED]), + _record(status="फैसला (मिती: २०८१/०२/२२)")] + assert bind_outcome(records) == CHARGED + + +def test_a_qualified_acquittal_cell_is_not_a_plain_acquittal(): + # The corpus contains compounds that qualify सफाई rather than standing + # alone. A bare substring test on सफाई would wrongly acquit here, the same + # class of bug `courts.case_status` fixed for ठहर (593 court_cases once + # recorded CONVICTED from a cell that actually said आंशिक ...ठहर). + qualified = {**DECIDED, "decision_type": "आंशिक सफाई"} + assert bind_outcome([_record(hearings=[qualified])]) == CHARGED + + def test_the_plan_carries_both_dates_and_the_accused_binds(): api = _PlanApi( detail={"registration_date_ad": "2023-06-22"}, @@ -362,7 +395,12 @@ def test_a_plaintiff_is_never_bound(): def test_an_existing_bind_survives_untouched(): - existing = {"nes_id": YADAV, "relationship_type": "accused", + # The REAL read shape: the relationship type comes back under `type`, and + # `relationship_type` never appears on a read at all. A fixture written + # with `relationship_type` directly would pass even if `plan_case` merged + # against the raw read list instead of `current_entity_binds` -- which is + # exactly the bug this shape catches. + existing = {"nes_id": YADAV, "type": "accused", "outcome": "convicted", "notes": "hand-written by a caseworker"} api = _PlanApi(detail={"registration_date_ad": "2023-06-22"}, parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", @@ -395,3 +433,20 @@ def test_a_non_draft_case_is_refused(): plan = _plan(_PlanApi(detail={"registration_date_ad": "2023-06-22"}), _case(state="PUBLISHED")) assert plan.status == "skip-state" + + +def test_a_case_payload_missing_the_entities_key_is_refused(): + # `case.get("entities") or []` cannot tell "no binds" from "this payload + # does not carry binds at all" -- a trimmed dict from a list endpoint, say. + # Merging against a false-empty `current` would PATCH a valid `entities` + # list holding only the new binds, silently deleting every one the case + # actually has. Must refuse outright rather than plan that write. + case = _case() + del case["entities"] + api = _PlanApi(detail={"registration_date_ad": "2023-06-22"}, + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", + "nes_id": YADAV}]) + plan = _plan(api, case) + assert plan.status == "no-entities-key" + assert plan.entities is None + assert "entities" in plan.skips[0] From d9c116afb3625d303713f0ced3a1a0b348e5c35c Mon Sep 17 00:00:00 2001 From: GAURAV Date: Fri, 7 Aug 2026 12:08:43 -0700 Subject: [PATCH 09/29] test(casework): prove bind_outcome's ACQUITTED path, and normalise misspelled qualifiers before testing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add the missing positive-branch test: every reference deciding a plain सफाई, one via its own hearing row and the other via the case_status fallback, asserts ACQUITTED. Nothing before this proved the rewrite's all(decided) and all(acquitted) branch survives; every prior multi-reference test only asserted CHARGED. - Normalise the hearing decision_type cell through courts.case_status._order_key before testing for आंशिक/ठहर qualifiers, so a documented misspelling (आंशीक) can't slip a partial conviction past the guard and read as a plain acquittal. Reuses the same normalisation outcome_from_hearings already applies to this identical field, rather than hand-copying the spelling table. --- casework/enrich_court_record.py | 15 +++++++++-- tests/casework/test_enrich_court_record.py | 29 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 5ded68c5..693f31ac 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -54,7 +54,7 @@ merge_entity_binds, validate_bind_item, ) -from courts.case_status import parse_case_status +from courts.case_status import _order_key, parse_case_status from jawafdehi_shared.entities.ids import ( build_entity_iri, is_valid_entity_iri, @@ -319,10 +319,21 @@ def _reference_disposition(record): CONVICTED from a cell that actually said `आंशिक ...ठहर`. The same care applies here: a cell naming `आंशिक` or `ठहर` alongside `सफाई` is not a plain acquittal, so it is refused rather than guessed at. + + The cell is normalised through `courts.case_status._order_key` before any + of that testing, not compared raw. The portal spells `आंशिक` two more ways + in this corpus (`आंशीक`, `आशिंक` -- `_order_key`'s own `_ORDER_SPELLING` + table says so), and a misspelled qualifier must block ACQUITTED exactly as + well as the canonical spelling does. `_order_key` is already how this same + `decision_type`/`order_type` text is normalised elsewhere in that module + (`outcome_from_hearings`'s own fallback branch), so this reuses the one + normalisation the corpus's hearing text already goes through, rather than + hand-copying its variant table and drifting from it later. """ decided = bool(_reference_end(record)) text = (deciding_hearing(record.get("hearings")) or {}).get("decision_type") or "" - plain_acquittal = bool(text) and ACQUITTAL in text and "आंशिक" not in text and "ठहर" not in text + key = _order_key(text) if text else "" + plain_acquittal = bool(key) and ACQUITTAL in key and "आंशिक" not in key and "ठहर" not in key return decided, plain_acquittal diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index bf7140ad..c7b9cc4a 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -372,6 +372,35 @@ def test_a_qualified_acquittal_cell_is_not_a_plain_acquittal(): assert bind_outcome([_record(hearings=[qualified])]) == CHARGED +def test_a_misspelled_qualifier_still_blocks_the_acquittal(): + # `आंशीक` (दीर्घ ई) is a real portal misspelling of `आंशिक`, documented in + # `courts.case_status._ORDER_SPELLING`. An exact-string qualifier check + # would miss it and read this cell as a plain acquittal -- every defendant + # on a partially-convicted case would then be labelled acquitted. Proves + # the cell is normalised (via `_order_key`) before the qualifier test. + misspelled = {**DECIDED, "decision_type": "आंशीक सफाई"} + assert bind_outcome([_record(hearings=[misspelled])]) == CHARGED + + +def test_every_reference_deciding_a_plain_acquittal_is_acquitted(): + # Both references decided सफाई, but through the two DIFFERENT sources + # `_reference_end` itself draws on. Reference 1's decided-ness (and its + # outcome text) come straight off its own hearing row, which carries a + # usable `hearing_date_ad`. Reference 2's hearing carries the outcome text + # but NO usable `hearing_date_ad`, so its decided-ness falls through to + # the `case_status` paren-date fallback -- the same two-source path + # `_reference_end` uses for `end_date`, now exercised on the ACQUITTED + # branch rather than only the CHARGED one. Nothing before this test + # proved the positive path survives the `all(decided) and all(acquitted)` + # rewrite -- every earlier multi-reference test asserted CHARGED. + acquittal_no_hearing_date = {"case_status": "फैसला", "decision_type": "सफाई"} + records = [ + _record(hearings=[DECIDED]), + _record(status="फैसला (मिती: २०८१/०२/२२)", hearings=[acquittal_no_hearing_date]), + ] + assert bind_outcome(records) == ACQUITTED + + def test_the_plan_carries_both_dates_and_the_accused_binds(): api = _PlanApi( detail={"registration_date_ad": "2023-06-22"}, From 2f71f18c697d22ee13e9cc2ed2ac6de11467a62c Mon Sep 17 00:00:00 2001 From: GAURAV Date: Fri, 7 Aug 2026 12:23:39 -0700 Subject: [PATCH 10/29] feat(casework): the court-record binder CLI, with every step logged --- casework/enrich_court_record.py | 188 ++++++++++++++++++++- tests/casework/test_enrich_court_record.py | 125 +++++++++++++- 2 files changed, 311 insertions(+), 2 deletions(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 693f31ac..06c0663f 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -41,10 +41,25 @@ uv run python -m casework.enrich_court_record --dry-run --verbose """ +import argparse import logging +import sys +import time from dataclasses import dataclass, field -from casework.common.api import EntityAlreadyExists +from casework.common.api import CaseworkApi, EntityAlreadyExists +from casework.common.cli import ( + add_common_args, + basic_auth_from_env, + configure_run_logging, + log_event, + log_run_footer, + log_run_header, + print_summary, + setup_logging, +) +from casework.common.review import ReviewRow, build_review_file +from casework.common.select import select_for_run from casework.court_record import court_record_for_case from casework.entity_identity import entity_slug, prefix_is_creatable from casework.entity_resolver import normalise_name @@ -52,6 +67,7 @@ bind_key, current_entity_binds, merge_entity_binds, + read_live_prefixes, validate_bind_item, ) from courts.case_status import _order_key, parse_case_status @@ -462,3 +478,173 @@ def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run): status = "would-patch" if (fields or entities is not None) else "nothing-to-do" return CasePlan(slug, status, fields=fields, entities=entities, if_match=etag or "", rows=rows, skips=skips) + + +STAGE = "court_record" + + +def build_api(args): + """`CaseworkApi` from parsed args -- Bearer when a token is set, else Basic.""" + if args.api_token: + return CaseworkApi(args.api_base_url, token=args.api_token, + allow_remote_writes=args.allow_remote_writes) + return CaseworkApi(args.api_base_url, basic=basic_auth_from_env(), + allow_remote_writes=args.allow_remote_writes) + + +def apply_plan(api, plan): + """Execute a would-patch plan as ONE conditional request. + + Fails closed with no ETag: without If-Match the whole-list replace is + unconditional and a concurrent edit would be silently clobbered. + + NEITHER RETRIES NOR FORCES. A 412 means the case changed between the read + and this write, so the merged list is stale and writing it would drop + someone else's edit. It propagates; `main` records the case as an error and + emits no bind row, so nothing claims a bind that never landed. + """ + if not plan.if_match: + raise ValueError( + f"refusing to write {plan.slug} with no ETag: the whole-list " + "replace would be unconditional") + lists = [] if plan.entities is None else [("entities", plan.entities)] + return api.patch_case(plan.slug, fields=plan.fields, lists=lists, + if_match=plan.if_match) + + +#: `plan_case` statuses that end a case before any court-record work happens: +#: no `court_read`, `dates`, `defendant_resolve`, `bind_plan` or `patch` event +#: follows one of these, only the `select` event below carrying the mapped +#: status. Anything else falls through to `"selected"`. +#: +#: `"no-entities-key"` reached `plan_case` after this CLI's event vocabulary +#: was first drafted: a case payload with no `entities` key at all cannot be +#: told apart from one that genuinely carries zero binds, so `plan_case` +#: refuses to plan a write rather than merge against a false-empty current +#: list and PATCH a replace that would delete every bind the case actually +#: has (see `plan_case`'s own guard). That refusal is a SKIP exactly like +#: `skip-state` and `no-court-reference` -- nothing downstream was read or +#: planned -- so it is counted and logged the same way, under its own +#: `skip_no_entities_key` status so the events file still records which of +#: the three reasons applied. +_SKIP_SELECT_STATUS = { + "skip-state": "skip_state", + "no-court-reference": "skip_no_court_ref", + "no-entities-key": "skip_no_entities_key", +} + + +def _log_plan(logger, events, run_id, plan, case): + """Emit the per-step events for one planned case. + + `run_id`/`stage`/`slug` are passed as explicit keywords on every call + rather than once via a `**common` dict: `ty` cannot verify that a plain + `dict[str, str]` splatted into `log_event`'s keyword-only signature never + lands in `elapsed_ms: int | None` or `level: int`, and flags every call + site as a type error even though no such collision is possible here. + `enrich_related_entities.py`'s own `log_event` calls use the same + explicit-keyword style for the identical reason. + """ + for row in plan.rows: + status = {"nes_id": "nes_id_copied", "exact": "exact_match", + "created": "created", "failed": "failed"}[row["how"]] + log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, + step="defendant_resolve", status=status, + detail=f"{row['name']} -> {row['nes_id'] or row['reason']}") + if plan.fields: + log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, + step="dates", status="proposed", + detail=", ".join(f"{k}={v}" for k, v in plan.fields)) + for skip in plan.skips: + status = "skip_open_case" if "not every court reference" in skip else "no_source" + log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, + step="dates", status=status, detail=skip) + log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, + step="bind_plan", + status="merged" if plan.entities is not None else "no_additions", + detail=f"{len(plan.rows)} defendant(s) on the court record") + + +def main(argv=None): + parser = add_common_args(argparse.ArgumentParser( + description="Bind court-record defendants and fill the case date fields.")) + args = parser.parse_args(argv) + setup_logging(args.verbose) + logger, run_id, paths = configure_run_logging(STAGE, verbose=args.verbose) + events = paths["events"] + started = time.time() + + api = build_api(args) + cases = select_for_run(list(api.iter_cases()), args) + log_run_header(logger, stage=STAGE, base_url=args.api_base_url, + dry_run=args.dry_run, provider=args.provider, model=args.model, + n_selected=len(cases), run_id=run_id, paths=paths) + + review = build_review_file(args, stage=STAGE, field_name="accused + case dates", + run_id=run_id) + live_prefixes = read_live_prefixes(api) + run_entities, stats = {}, {} + + for case in cases: + slug = case.get("slug") or "" + try: + detail, etag = api.get_case_with_etag(slug) + except Exception as exc: # noqa: BLE001 - one case's read failure is not the run's + log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, + step="court_read", status="unreadable", + detail=f"{type(exc).__name__}") + stats["error"] = stats.get("error", 0) + 1 + continue + + plan = plan_case(api, detail, etag, live_prefixes=live_prefixes, + run_entities=run_entities, dry_run=args.dry_run) + log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, + step="select", status=_SKIP_SELECT_STATUS.get(plan.status, "selected")) + if plan.status in _SKIP_SELECT_STATUS: + stats[plan.status] = stats.get(plan.status, 0) + 1 + continue + log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, + step="court_read", status="ok") + _log_plan(logger, events, run_id, plan, detail) + + generated = "; ".join( + [f"{k}={v}" for k, v in plan.fields] + + [f"accused+{len(plan.rows)}" if plan.entities is not None else ""]).strip("; ") + review.add(ReviewRow( + slug=slug, status=plan.status, + before=(f"case_start_date={detail.get('case_start_date')}, " + f"case_end_date={detail.get('case_end_date')}, " + f"{len(detail.get('entities') or [])} bind(s)"), + generated=generated, + note="; ".join(plan.skips))) + + if plan.status == "nothing-to-do": + stats["nothing-to-do"] = stats.get("nothing-to-do", 0) + 1 + continue + if args.dry_run: + log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, + step="patch", status="dry_run", detail=generated) + stats["would-patch"] = stats.get("would-patch", 0) + 1 + continue + try: + apply_plan(api, plan) + except Exception as exc: # noqa: BLE001 - a 412 or a 400 costs this case only + status = "etag_conflict" if "412" in str(exc) else "rejected" + log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, + step="patch", status=status, + detail=f"{type(exc).__name__}: {exc}") + stats["error"] = stats.get("error", 0) + 1 + continue + log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, + step="patch", status="applied", detail=generated) + stats["patched"] = stats.get("patched", 0) + 1 + + review.write() + log_run_footer(logger, stage=STAGE, stats=stats, duration_s=time.time() - started) + print_summary(stats, args.dry_run, "court-record binder") + print(f"review file: {review.path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index c7b9cc4a..560a35d6 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -287,7 +287,13 @@ def test_is_person_never_raises_on_a_malformed_iri(): assert _is_person(None) is False -from casework.enrich_court_record import ACQUITTED, CHARGED, bind_outcome, plan_case # noqa: E402 +from casework.enrich_court_record import ( # noqa: E402 + ACQUITTED, + CHARGED, + CasePlan, + bind_outcome, + plan_case, +) CASE_IRI = "https://jawafdehi.org/courtcase/special/079-cr-0151" @@ -479,3 +485,120 @@ def test_a_case_payload_missing_the_entities_key_is_refused(): assert plan.status == "no-entities-key" assert plan.entities is None assert "entities" in plan.skips[0] + + +import json # noqa: E402 + +import pytest # noqa: E402 + +from casework.enrich_court_record import apply_plan, main # noqa: E402 + + +def test_apply_plan_refuses_to_write_without_an_etag(): + plan = CasePlan("case-079-cr-0151", "would-patch", + fields=[("case_start_date", "2023-06-22")], if_match="") + with pytest.raises(ValueError, match="ETag"): + apply_plan(_PlanApi(), plan) + + +def test_apply_plan_sends_one_conditional_request(): + seen = {} + + class _Api: + def patch_case(self, slug, *, fields=(), lists=(), if_match=None): + seen.update(slug=slug, fields=list(fields), lists=list(lists), + if_match=if_match) + return {} + + plan = CasePlan("case-079-cr-0151", "would-patch", + fields=[("case_start_date", "2023-06-22")], + entities=[{"nes_id": YADAV, "relationship_type": "accused"}], + if_match='W/"7"') + apply_plan(_Api(), plan) + assert seen["if_match"] == 'W/"7"' + assert seen["fields"] == [("case_start_date", "2023-06-22")] + assert seen["lists"][0][0] == "entities" + + +class _CliApi(_PlanApi): + """`_PlanApi` plus the list/detail entry points `main()` calls before it + ever reaches `plan_case` -- one case in, its own ETag on the read.""" + + def __init__(self, case, **kw): + super().__init__(**kw) + self._case = case + + def iter_cases(self, params=None, timeout=60, progress=None): + yield self._case + + def get_case_with_etag(self, slug, timeout=60): + return self._case, 'W/"7"' + + def entity_prefixes(self, timeout=60): + return ["person"] + + +def _events(tmp_path): + """Every JSON line from the one `*.events.jsonl` a run leaves in `tmp_path`.""" + paths = list(tmp_path.glob("*.events.jsonl")) + assert paths, "the run must leave an events file" + return [json.loads(line) for line in paths[0].read_text().splitlines() if line] + + +def test_a_dry_run_writes_the_events_file_and_no_patch(tmp_path, monkeypatch): + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + monkeypatch.setenv("CASEWORK_API_USER", "dev") + monkeypatch.setenv("CASEWORK_API_PASSWORD", "dev") + # Stub the corpus read and the court reads; assert nothing PATCHes. + api = _CliApi( + _case(), + detail={"registration_date_ad": "2023-06-22"}, + hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}], + ) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + events = _events(tmp_path) + steps = {e["step"] for e in events} + assert {"select", "court_read", "patch"} <= steps + # No real PATCH: every `patch` event this run emits is the dry-run kind. + assert all(e["status"] == "dry_run" for e in events if e["step"] == "patch") + + +def test_a_case_missing_the_entities_key_is_skipped_and_logged(tmp_path, monkeypatch): + # `plan_case` refuses to plan a write off a payload with no `entities` key + # at all -- merging would fabricate a false-empty current list and PATCH a + # replace that deletes every bind the case actually has (see `plan_case`). + # The CLI's job is to treat that refusal as a SKIP: no court_read, no + # bind_plan, no patch -- and log why, the same as `skip-state` and + # `no-court-reference` already do. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + case = _case() + del case["entities"] + api = _CliApi(case, detail={"registration_date_ad": "2023-06-22"}) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + events = _events(tmp_path) + assert [e["step"] for e in events] == ["select"] + assert events[0]["status"] == "skip_no_entities_key" + + +def test_the_module_imports_without_django(tmp_path): + """The standalone constraint, pinned. One convenience import re-adds Django.""" + import os + import subprocess + import sys + + env = {k: v for k, v in os.environ.items() if k != "DJANGO_SETTINGS_MODULE"} + proc = subprocess.run( + [sys.executable, "-c", "import casework.enrich_court_record"], + env=env, capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr From 14373517cb01906756d58393429020a08c1197c1 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Fri, 7 Aug 2026 12:41:09 -0700 Subject: [PATCH 11/29] fix(casework): route court-read failures and 412s to the right event, and log why every skip happened - select-skip events now carry `plan.skips` as `detail`, so an operator replaying *.events.jsonl can tell "no court reference at all" from "every reference on it 404'd" instead of seeing an identical line for both. - _log_plan routes per-reference read failures to court_read/unreadable instead of dates/no_source -- a broken read is not a date fact. - the 412-vs-rejected split now checks isinstance(exc, HTTPError) and exc.code == 412 instead of a substring test on the exception's own message, which a slug like case-...-cr-0412 could spuriously match on the unrelated no-ETag ValueError. - casework.ledger.NON_OUTCOME_STATUSES gains "dry_run" so a clean --dry-run patch event stops folding into the "what did we change, when" audit, the same way bind_materials' "planned" already does. Covered by new tests for each: a skip-state detail check, a two-court- reference partial-read case, an --apply 412 conflict plus its companion success path, a slug-containing-412 regression proof, a dry-run test that now falls through to the entity-creation rung instead of short-circuiting on an existing nes_id, and a ledger test for the new non-outcome status. Co-Authored-By: Claude Opus 5 (1M context) --- casework/enrich_court_record.py | 48 ++++- casework/ledger.py | 10 +- tests/casework/test_enrich_court_record.py | 196 ++++++++++++++++++++- tests/casework/test_ledger.py | 17 +- 4 files changed, 255 insertions(+), 16 deletions(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 06c0663f..aeff537e 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -45,6 +45,7 @@ import logging import sys import time +import urllib.error from dataclasses import dataclass, field from casework.common.api import CaseworkApi, EntityAlreadyExists @@ -534,7 +535,19 @@ def apply_plan(api, plan): } -def _log_plan(logger, events, run_id, plan, case): +#: Prefix `court_record_for_case` puts on every per-reference read failure it +#: reports (`f"court reference {court}/{number} could not be read (...)"`). +#: `_log_plan` matches on this exact prefix to route those skips to +#: `court_read`/`unreadable` rather than `dates` -- a reference that 404s cost +#: this case its defendants and/or its dates from THAT reference, and it is +#: not a fact about date-derivation the way "case_end_date left empty: ..." +#: is. Checked as a prefix, not a substring: the OTHER skip this function +#: sees, "case_end_date left empty: not every court reference has decided +#: ...", contains the words "court reference" too, just never at position 0. +_COURT_READ_FAILURE_PREFIX = "court reference " + + +def _log_plan(logger, events, run_id, plan): """Emit the per-step events for one planned case. `run_id`/`stage`/`slug` are passed as explicit keywords on every call @@ -556,6 +569,16 @@ def _log_plan(logger, events, run_id, plan, case): step="dates", status="proposed", detail=", ".join(f"{k}={v}" for k, v in plan.fields)) for skip in plan.skips: + if skip.startswith(_COURT_READ_FAILURE_PREFIX): + # A per-reference read failure, not a date fact: `plan.status` is + # not one of the SKIP statuses here (this case had at least one + # readable reference, or `plan_case` would have returned + # "no-court-reference" and `_log_plan` would never run), so the + # earlier `court_read`/`ok` event already logged for this case + # stands -- this event says the SAME court read was only partial. + log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, + step="court_read", status="unreadable", detail=skip) + continue status = "skip_open_case" if "not every court reference" in skip else "no_source" log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, step="dates", status=status, detail=skip) @@ -598,14 +621,23 @@ def main(argv=None): plan = plan_case(api, detail, etag, live_prefixes=live_prefixes, run_entities=run_entities, dry_run=args.dry_run) + # `detail=` carries `plan.skips` even on a clean "selected": a case + # can reach "would-patch"/"nothing-to-do" with a partially-unreadable + # court record (some references 404, at least one did not), and the + # SAME line then tells an operator replaying the ledger what a bare + # "skip_state"/"skip_no_court_ref" status alone cannot -- WHICH state, + # WHICH missing/unreadable reference. Without this, "no-court-reference" + # (a case naming none at all) and "every reference on this case + # 404'd" produced an identical events-file line. log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, - step="select", status=_SKIP_SELECT_STATUS.get(plan.status, "selected")) + step="select", status=_SKIP_SELECT_STATUS.get(plan.status, "selected"), + detail="; ".join(plan.skips)) if plan.status in _SKIP_SELECT_STATUS: stats[plan.status] = stats.get(plan.status, 0) + 1 continue log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, step="court_read", status="ok") - _log_plan(logger, events, run_id, plan, detail) + _log_plan(logger, events, run_id, plan) generated = "; ".join( [f"{k}={v}" for k, v in plan.fields] @@ -629,7 +661,15 @@ def main(argv=None): try: apply_plan(api, plan) except Exception as exc: # noqa: BLE001 - a 412 or a 400 costs this case only - status = "etag_conflict" if "412" in str(exc) else "rejected" + # `isinstance(exc, HTTPError) and exc.code == 412`, never a + # substring test on the message: `apply_plan`'s own no-ETag + # `ValueError` interpolates `plan.slug`, so a case slugged + # `...-cr-0412` hitting that (permanent) refusal would otherwise + # be logged `etag_conflict` -- telling an operator to re-read and + # retry a write that will refuse again every time. + status = ("etag_conflict" + if isinstance(exc, urllib.error.HTTPError) and exc.code == 412 + else "rejected") log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, step="patch", status=status, detail=f"{type(exc).__name__}: {exc}") diff --git a/casework/ledger.py b/casework/ledger.py index e6eb984d..b2ce68b2 100644 --- a/casework/ledger.py +++ b/casework/ledger.py @@ -39,12 +39,18 @@ # EXCEPT the known intermediate step signals below. A ledger must fail toward # inclusion, not omission. # -# `planned` is the one dry-run status that IS excluded: bind_materials maps a +# `planned` is one dry-run status that IS excluded: bind_materials maps a # dry-run WOULD_PATCH to `planned` precisely so it stays out of the "what did we # change, when" audit (bind_materials.py::_ledger_status). It is a non-outcome # by design -- a bind dry run changed nothing -- so it belongs here, not with # the `would-*` extraction previews that DO record a per-case verdict. -NON_OUTCOME_STATUSES = frozenset({"ok", "start", "fallback", "none", "planned"}) +# +# `dry_run` joins it for the same reason, on `enrich_court_record.py`'s +# `patch` step: that stage plans a whole-case scalar+list PATCH against +# EXISTING case fields (dates, accused binds), not an extraction preview of +# NEW content the way `would-convert`/`would-enrich` are -- so, like a bind +# dry run, it changed nothing and must not read as a per-case outcome either. +NON_OUTCOME_STATUSES = frozenset({"ok", "start", "fallback", "none", "planned", "dry_run"}) _DEFAULT_LEDGER = _REPO_ROOT / "work" / "enrichment-ledger.jsonl" diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 560a35d6..848f034a 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -521,22 +521,34 @@ def patch_case(self, slug, *, fields=(), lists=(), if_match=None): class _CliApi(_PlanApi): - """`_PlanApi` plus the list/detail entry points `main()` calls before it - ever reaches `plan_case` -- one case in, its own ETag on the read.""" + """`_PlanApi` plus the list/detail/write entry points `main()` calls + before and beyond `plan_case` -- one case in, its own ETag on the read, + and an optional canned `patch_case` outcome for the `--apply` path. + """ - def __init__(self, case, **kw): + def __init__(self, case, *, etag='W/"7"', patch_error=None, **kw): super().__init__(**kw) self._case = case + self._etag = etag + self._patch_error = patch_error + self.patch_calls = [] def iter_cases(self, params=None, timeout=60, progress=None): yield self._case def get_case_with_etag(self, slug, timeout=60): - return self._case, 'W/"7"' + return self._case, self._etag def entity_prefixes(self, timeout=60): return ["person"] + def patch_case(self, slug, *, fields=(), lists=(), timeout=60, if_match=None): + self.patch_calls.append({"slug": slug, "fields": list(fields), + "lists": list(lists), "if_match": if_match}) + if self._patch_error is not None: + raise self._patch_error + return {} + def _events(tmp_path): """Every JSON line from the one `*.events.jsonl` a run leaves in `tmp_path`.""" @@ -550,11 +562,18 @@ def test_a_dry_run_writes_the_events_file_and_no_patch(tmp_path, monkeypatch): monkeypatch.setenv("CASEWORK_API_USER", "dev") monkeypatch.setenv("CASEWORK_API_PASSWORD", "dev") # Stub the corpus read and the court reads; assert nothing PATCHes. + # The defendant carries NO `nes_id` on purpose: with one, `resolve_defendant` + # returns at ladder rung 1 and never reaches the creation rung, so the + # `args.dry_run -> plan_case -> _accused_binds -> resolve_defendant(dry_run=...)` + # wiring would be untested at the CLI level -- a bug that hardcoded + # `dry_run=False` somewhere in that chain would still pass this test. + # Dropping the nes_id forces the creation rung and lets `api.posted == []` + # prove the CLI's `--dry-run` really reaches it and suppresses the POST. api = _CliApi( _case(), detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], - parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}], ) import casework.enrich_court_record as ecr monkeypatch.setattr(ecr, "build_api", lambda args: api) @@ -567,6 +586,10 @@ def test_a_dry_run_writes_the_events_file_and_no_patch(tmp_path, monkeypatch): assert {"select", "court_read", "patch"} <= steps # No real PATCH: every `patch` event this run emits is the dry-run kind. assert all(e["status"] == "dry_run" for e in events if e["step"] == "patch") + # No real POST either, even though this defendant would need a new entity + # under `--apply`. + assert api.posted == [] + assert api.patch_calls == [] def test_a_case_missing_the_entities_key_is_skipped_and_logged(tmp_path, monkeypatch): @@ -589,16 +612,175 @@ def test_a_case_missing_the_entities_key_is_skipped_and_logged(tmp_path, monkeyp events = _events(tmp_path) assert [e["step"] for e in events] == ["select"] assert events[0]["status"] == "skip_no_entities_key" + # The ONE line this case leaves must say WHY, not just THAT -- an operator + # replaying the ledger can't otherwise tell this apart from any other + # select-skip on the same case. + assert "entities" in events[0]["detail"] + + +def test_a_non_draft_case_is_skipped_with_the_state_in_the_detail(tmp_path, monkeypatch): + # `plan_case` already puts the actual state into `skips` for this path + # ("state is 'PUBLISHED', not 'DRAFT'"); this pins that the CLI actually + # surfaces it, so a `skip_state` line in the events file says WHICH state + # rather than just that one applied. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + api = _CliApi(_case(state="PUBLISHED")) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + # `--slug` bypasses `select_for_run`'s DRAFT/IN_REVIEW gate (see + # `casework.common.select.select_cases`) -- needed here only to get a + # PUBLISHED case through selection so `plan_case`'s OWN state check (the + # thing under test) is what produces the skip, not the selector dropping + # it before `main` ever sees it. + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--slug", "case-079-cr-0151", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + events = _events(tmp_path) + assert [e["step"] for e in events] == ["select"] + assert events[0]["status"] == "skip_state" + assert "PUBLISHED" in events[0]["detail"] + + +def test_a_partially_unreadable_court_record_is_logged_as_court_read_not_dates( + tmp_path, monkeypatch, +): + # Two court references on one case; the second 404s. `court_record_for_case` + # still returns the one successfully-read record, so the case proceeds -- + # but the skip describing the 404 must land under `court_read`/`unreadable`, + # not `dates`: it is a fact about a broken read, not about date derivation, + # and the case's own `court_read`/`ok` event (logged because at least one + # reference succeeded) must not be the only word on the subject. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + second_ref = "https://jawafdehi.org/courtcase/special/079-cr-0999" + + class _TwoRefApi(_CliApi): + def get_courtcase(self, court, number, timeout=60): + if number == "079-cr-0999": + raise urllib.error.HTTPError(second_ref, 404, "Not Found", {}, None) + return super().get_courtcase(court, number, timeout=timeout) + + case = _case(court_cases=[CASE_IRI, second_ref]) + api = _TwoRefApi(case, detail={"registration_date_ad": "2023-06-22"}) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + events = _events(tmp_path) + + court_read = [e for e in events if e["step"] == "court_read"] + assert any(e["status"] == "ok" for e in court_read) + assert any(e["status"] == "unreadable" and "079-cr-0999" in e["detail"] + for e in court_read) + # The 404 must not also (or instead) show up as a `dates` event -- only + # the genuine date-source skip belongs there. + dates = [e for e in events if e["step"] == "dates"] + assert not any("079-cr-0999" in e.get("detail", "") for e in dates) + assert any(e["status"] == "no_source" for e in dates) + + +def test_apply_run_records_a_412_as_etag_conflict_with_no_applied_event( + tmp_path, monkeypatch, capsys, +): + # The load-bearing chain under `--apply`: a stale read (412 on the write) + # must record `etag_conflict`, count as an error, and emit NO `applied` + # event -- nothing here claims a bind that never landed. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + conflict = urllib.error.HTTPError( + "https://jawafdehi.org/api/cases/case-079-cr-0151/", 412, + "Precondition Failed", {}, None) + api = _CliApi( + _case(), patch_error=conflict, + detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}], + ) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--apply", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + assert api.patch_calls, "apply_plan must have actually called patch_case" + events = _events(tmp_path) + patch_events = [e for e in events if e["step"] == "patch"] + assert len(patch_events) == 1 + assert patch_events[0]["status"] == "etag_conflict" + assert not any(e["status"] == "applied" for e in patch_events) + assert "error: 1" in capsys.readouterr().out + + +def test_apply_run_records_a_successful_write(tmp_path, monkeypatch): + # The companion success path: a clean `--apply` PATCH logs `applied`, + # carrying the merged `if_match` through to the one real write. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + api = _CliApi( + _case(), + detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}], + ) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--apply", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + events = _events(tmp_path) + patch_events = [e for e in events if e["step"] == "patch"] + assert len(patch_events) == 1 + assert patch_events[0]["status"] == "applied" + assert len(api.patch_calls) == 1 + assert api.patch_calls[0]["if_match"] == 'W/"7"' + + +def test_a_slug_containing_412_does_not_mislabel_a_missing_etag_as_a_conflict( + tmp_path, monkeypatch, +): + # `apply_plan`'s own no-ETag `ValueError` interpolates `plan.slug` into its + # message. A slug that happens to contain "412" must not make a plain + # string-search read that as an HTTP 412 -- this refusal is PERMANENT + # (there will never be an ETag to retry with), not a transient conflict. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + case = _case(slug="case-079-cr-0412") + api = _CliApi(case, etag="", # no ETag at all: apply_plan refuses before any HTTP call + detail={"registration_date_ad": "2023-06-22"}) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--apply", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + assert api.patch_calls == [], "refused before ever reaching patch_case" + events = _events(tmp_path) + patch_events = [e for e in events if e["step"] == "patch"] + assert len(patch_events) == 1 + assert patch_events[0]["status"] == "rejected" def test_the_module_imports_without_django(tmp_path): - """The standalone constraint, pinned. One convenience import re-adds Django.""" + """The standalone constraint, pinned deterministically. + + Checking only `returncode == 0` proves little on its own: + `casework.common.llm.bootstrap` (never called by this module, but the + thing this test guards against a future edit calling) sets + `DJANGO_SETTINGS_MODULE` itself via `os.environ.setdefault` and would + fail closed here only because this shell has no `SECRET_KEY` -- a shell + that exports a complete `.env` would let Django configure successfully, + and the subprocess would exit 0 with Django fully loaded. Asserting + `"django" not in sys.modules` INSIDE the subprocess is true regardless of + what the environment happens to provide. + """ import os import subprocess import sys env = {k: v for k, v in os.environ.items() if k != "DJANGO_SETTINGS_MODULE"} proc = subprocess.run( - [sys.executable, "-c", "import casework.enrich_court_record"], + [sys.executable, "-c", + "import casework.enrich_court_record, sys\n" + "loaded = sorted(m for m in sys.modules if m == 'django' or m.startswith('django.'))\n" + "assert not loaded, loaded"], env=env, capture_output=True, text=True) assert proc.returncode == 0, proc.stderr diff --git a/tests/casework/test_ledger.py b/tests/casework/test_ledger.py index f601a67f..c55f8b07 100644 --- a/tests/casework/test_ledger.py +++ b/tests/casework/test_ledger.py @@ -99,10 +99,11 @@ def test_convert_and_dryrun_outcomes_are_not_dropped(self, tmp_path): assert led[("c5", "allegations")]["status"] == "llm-error" def test_non_outcome_statuses_are_the_step_signals(self): - # 'planned' joins the step signals: it is bind_materials' dry-run status, - # deliberately folded into no outcome (a dry run changed nothing). + # 'planned' (bind_materials) and 'dry_run' (enrich_court_record) join + # the step signals: both are dry-run statuses for a write-to-existing- + # fields preview, deliberately folded into no outcome (nothing changed). assert set(NON_OUTCOME_STATUSES) == { - "ok", "start", "fallback", "none", "planned"} + "ok", "start", "fallback", "none", "planned", "dry_run"} def test_planned_bind_dryrun_is_not_an_outcome(self, tmp_path): # bind_materials maps a dry-run WOULD_PATCH to 'planned' precisely so it @@ -113,6 +114,16 @@ def test_planned_bind_dryrun_is_not_an_outcome(self, tmp_path): ]) assert ("case-8", "bind") not in build_ledger(tmp_path) + def test_court_record_dry_run_patch_is_not_an_outcome(self, tmp_path): + # enrich_court_record's `patch`/`dry_run` status is the same kind of + # "changed nothing" preview as bind_materials' `planned` -- it must + # not pollute the "what did we change, when" audit either. + _write_events(tmp_path / "court_record.events.jsonl", [ + _ev("2026-08-07T10:00:00Z", "court_record", "case-10", "patch", "dry_run", + "case_start_date=2023-06-22"), + ]) + assert ("case-10", "court_record") not in build_ledger(tmp_path) + def test_applied_bind_supersedes_earlier_planned(self, tmp_path): # A later real APPLY must be recorded; the earlier dry-run 'planned' was # never an outcome, so 'enriched' is the ledger state. From c6de859233aa34036e5d582c10cd21a83314edd9 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Fri, 7 Aug 2026 12:52:57 -0700 Subject: [PATCH 12/29] feat(casework): register the court_record stage and correct the date help text STAGES/TIERS both gain court_record so test_stage_names_match_llm_tier_names stays pinned. case_start_date/case_end_date help text now says what the field actually holds (court registration date on 46 of 48 published cases with a readable court record; deciding hearing date on 29 of 29), not the "alleged incident" convention nobody uses. --- .../0056_correct_case_date_help_text.py | 23 +++++++++++++++++++ cases/models.py | 9 ++++++-- casework/README.md | 1 + casework/common/llm.py | 4 ++++ casework/common/pipeline.py | 11 +++++++++ 5 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 cases/migrations/0056_correct_case_date_help_text.py diff --git a/cases/migrations/0056_correct_case_date_help_text.py b/cases/migrations/0056_correct_case_date_help_text.py new file mode 100644 index 00000000..6109a660 --- /dev/null +++ b/cases/migrations/0056_correct_case_date_help_text.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-08-07 19:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cases', '0055_alter_case_banner_url_alter_case_thumbnail_url'), + ] + + operations = [ + migrations.AlterField( + model_name='case', + name='case_end_date', + field=models.DateField(blank=True, help_text='When the case concluded — the deciding hearing date. Leave empty for a case still being heard: a value here renders the case as concluded on the public site', null=True), + ), + migrations.AlterField( + model_name='case', + name='case_start_date', + field=models.DateField(blank=True, help_text='When the case began — the court registration date for a case with a court record', null=True), + ), + ] diff --git a/cases/models.py b/cases/models.py index 42951c23..470bafdc 100644 --- a/cases/models.py +++ b/cases/models.py @@ -642,10 +642,15 @@ class Case(models.Model): ) # Date fields case_start_date = models.DateField( - null=True, blank=True, help_text="When the alleged incident began" + null=True, blank=True, + help_text="When the case began — the court registration date for a " + "case with a court record", ) case_end_date = models.DateField( - null=True, blank=True, help_text="When the alleged incident ended" + null=True, blank=True, + help_text="When the case concluded — the deciding hearing date. Leave " + "empty for a case still being heard: a value here renders the " + "case as concluded on the public site", ) # Entity relationships live on the CaseEntityRelationship bind (the diff --git a/casework/README.md b/casework/README.md index aa915f50..1d18ee6b 100644 --- a/casework/README.md +++ b/casework/README.md @@ -133,6 +133,7 @@ flowchart LR | timeline | `enrich_timeline` | `timeline` | `patch_field` | yes ¹ | premium | | allegations | `enrich_allegations` | `key_allegations` | `patch_field` | yes ¹ | premium | | entities | `enrich_related_entities` | `entities` (+ NES entities with `--create-entities`) | `patch_field`, `POST /api/entities` | yes ¹ | premium | +| court_record | `enrich_court_record` | `case_start_date`, `case_end_date`, `entities` (accused) (+ NES person entities) | `patch_case`, `POST /api/entities` | yes ¹ | — (no LLM) | | ledger | `ledger` | ledger JSON (local file) | none (reads run logs) | local file | — | ¹ Remote write requires **`--apply` + `--allow-remote-writes` + `--api-token`** together. diff --git a/casework/common/llm.py b/casework/common/llm.py index 73dbac0e..3cc3f122 100644 --- a/casework/common/llm.py +++ b/casework/common/llm.py @@ -38,6 +38,10 @@ # into this stage as `--only title`, and the brief resolves the conflict in # favour of cheap. See `casework/enrich_card.py`'s deviation 2. "card": "cheap", + # Registered because `test_stage_names_match_llm_tier_names` pins the pair, + # not because a model runs. `court_record` makes ZERO LLM calls -- a run + # that spends no tokens is its success case, not a shortfall. + "court_record": "cheap", } DEFAULT_TIER = "cheap" diff --git a/casework/common/pipeline.py b/casework/common/pipeline.py index 9bf21395..b511426b 100644 --- a/casework/common/pipeline.py +++ b/casework/common/pipeline.py @@ -183,6 +183,17 @@ class Stage: requires_fields=("description",), requires_stages=("description",), ), + # `court_record` reads the case's NGM court record over HTTP -- not a bound + # document -- so unlike `entities` it needs no material and no `convert` + # pass. That independence is the point: the accused path used to live inside + # `enrich_related_entities`, where five gates it had no use for (the + # already-enriched skip, the MARKDOWN-role prerequisite, the no-source gate, + # the empty-prompt gate, and any LLM failure) cost a case all its defendants + # whenever its press release lacked a MARKDOWN role. + "court_record": Stage( + "court_record", + provides=("case_start_date", "case_end_date", "entities"), + ), } From 3010e9d88eeeb42385fc1c25feb9a1bcb59a746f Mon Sep 17 00:00:00 2001 From: GAURAV Date: Fri, 7 Aug 2026 13:28:05 -0700 Subject: [PATCH 13/29] fix(casework): refuse the colliding bind, and stop the dry run polluting the ledger The court-record binder had two paths that bound a person the ladder had just declined to identify. On a 409 the create kept the IRI it computed BEFORE the POST and bound it -- but that branch fires precisely when rung 2 refused ("13 person entities carry this exact name", or the truncation veto), so the bind went to whoever already owned the slug. And `run_entities`, shared across cases by design, was keyed on the bare name, so two different people sharing a name on two cases collapsed into one entity carrying both accusations. Both now fail safe. The 409 path binds nothing and reports the taken IRI for a human; the run key is name AND the court party row's address. This trades bind coverage for safety and is an interim ruling -- see the fix report for the alternatives (re-search and compare, or an explicit review queue). Three more corrections: - An unreadable prefix list (a transient 502 at run start) reported every defendant as needing a prefix that is not creatable -- false for a prefix as ordinary as `person`. It now says the list was never read, retry the case, matching `enrich_related_entities._cannot_create`. - Every intermediate event now reports `ok` with its classification in the detail, the way every sibling enricher does. Adding `dry_run` to NON_OUTCOME_STATUSES achieved nothing while `bind_plan`/`merged` still landed in the "what did we change, when" audit for every dry-run case. A `nothing-to-do` case gains a terminal `idempotency`/`already` event so it does not vanish from the ledger instead. - `court_record.py` no longer claims to be unwired, `defendant_names` and `_accused_binds` share one `is_defendant`/`party_name` pair rather than two copies of the filter, and the unread `CHARGED` is gone. The guard-wiring pin covers `enrich_court_record` now, plus `enrich_card` and `enrich_description` which had drifted out of it. Co-Authored-By: Claude Opus 5 (1M context) --- casework/common/api.py | 10 +- casework/court_record.py | 65 +++-- casework/enrich_court_record.py | 177 ++++++++++--- casework/ledger.py | 6 + tests/casework/test_build_api_guard_wiring.py | 20 +- tests/casework/test_enrich_court_record.py | 234 ++++++++++++++++-- tests/casework/test_ledger.py | 38 ++- 7 files changed, 468 insertions(+), 82 deletions(-) diff --git a/casework/common/api.py b/casework/common/api.py index 20bfcffd..64950ac0 100644 --- a/casework/common/api.py +++ b/casework/common/api.py @@ -13,9 +13,13 @@ class EntityAlreadyExists(Exception): """A create POST hit an entity that is already there. - Its own type because the caller's response is to BIND the existing entity, - not to record an error: someone -- a caseworker, or an earlier run over the - same case -- got there first, which is the outcome we wanted. The server + Its own type because the caller's response is usually to BIND the existing + entity, not to record an error: someone -- a caseworker, or an earlier run + over the same case -- got there first, which is the outcome we wanted. NOT + universally, though: a caller that reached the create BECAUSE it could not + identify the entity by name must refuse instead, since the collision says + only that the slug is taken, not that it is taken by this person. See + `casework.enrich_court_record.resolve_defendant`. The server answers 409 `ENTITY_EXISTS`: `_map_service_value_error` maps the duplicate `@id` check (`entities/services/publication/service.py:68`) to 409, and reserves 422 for `validate_create_payload` failures (`entities/views.py:220, diff --git a/casework/court_record.py b/casework/court_record.py index 582d88e1..010fa692 100644 --- a/casework/court_record.py +++ b/casework/court_record.py @@ -1,16 +1,21 @@ """Accused names, read from the case's own NGM court record. -CURRENTLY UNWIRED -- NOTHING IMPORTS `defendant_names`. This module is complete and -tested; it just has no caller yet. `enrich_related_entities` used to call it, and -that was removed: reading accused needs neither a document nor an LLM, so sitting -inside a document-and-LLM enricher put it behind five gates it has no use for (the -already-enriched skip, the MARKDOWN-role prerequisite, the no-source gate, the -empty-prompt gate, and any LLM failure). A case with a complete court record bound -zero defendants whenever its press-release PDF lacked a MARKDOWN role. - -The intended home is its own CLI -- pure HTTP, no model, no token spend, minutes -across the corpus instead of hours. That is pending a decision; do not delete this -in the meantime, and do not wire it back into an LLM enricher. +WHO CALLS WHAT. `casework.enrich_court_record` is this module's CLI -- pure HTTP, +no model, no token spend -- and it calls `court_record_for_case`, the full read +(detail + hearings + parties), because it needs the dates and the party rows' +`nes_id`/`address`, not just the names. `defendant_names` is the narrow read for +callers that want ONLY the names; it is kept for them and is not on the enricher's +path. Neither is authoritative over the other on WHO COUNTS AS A DEFENDANT: both +route that judgement through `is_defendant`/`party_name` below, so the filter, the +strip and the de-dup cannot drift apart. + +Do not wire either of them back into an LLM enricher. `enrich_related_entities` +used to call this module, and that was removed: reading accused needs neither a +document nor an LLM, so sitting inside a document-and-LLM enricher put it behind +five gates it has no use for (the already-enriched skip, the MARKDOWN-role +prerequisite, the no-source gate, the empty-prompt gate, and any LLM failure). A +case with a complete court record bound zero defendants whenever its +press-release PDF lacked a MARKDOWN role. WHY THIS EXISTS. Accused binds used to have no source at all: the LLM prompt's PART 3 extracted accused names, counted them, and threw them away. The obvious @@ -40,16 +45,34 @@ logger = logging.getLogger(__name__) -# A verdict is legal only on an accused bind (the `outcome_only_on_accused` CHECK -# constraint). Every case in this corpus is a Special Court `-CR-` case, which -# means CIAA filed a charge sheet, so 'charged' is true by construction rather -# than inferred. Sent explicitly so the claim is visible in the request body -# instead of implied by the API's omitted-outcome fallback -# (`cases/api_views.py`). -CHARGED = "charged" - _COURTCASE_MARKER = "/courtcase/" +#: The one spelling of "defendant" NGM's party rows use (`courts.models`'s +#: `CaseEntity.side` is free text, documented `plaintiff | defendant`). +_DEFENDANT_SIDE = "defendant" + + +def is_defendant(party): + """Whether this party row names a defendant rather than a plaintiff. + + The ONE place that test lives. `enrich_court_record._accused_binds` needs + the whole party row (its `nes_id` and `address`) and so cannot call + `defendant_names`, but it must not re-spell the filter either: a side test + that drifts between the two would bind plaintiffs on one path and not the + other, and `नेपाल सरकार` is the plaintiff on every case in this corpus. + """ + return (party.get("side") or "").strip().lower() == _DEFENDANT_SIDE + + +def party_name(party): + """The party's name, stripped, or "" when the row carries none. + + Shared with `is_defendant` for the same reason: the de-dup on both paths + keys on this exact string, so one path stripping and the other not would + make the same person two entities. + """ + return (party.get("name") or "").strip() + def court_ref(iri): """`(court, case_number)` from a courtcase IRI, or None. @@ -105,9 +128,9 @@ def defendant_names(api, case): continue for party in parties: - if (party.get("side") or "").strip().lower() != "defendant": + if not is_defendant(party): continue - name = (party.get("name") or "").strip() + name = party_name(party) if not name or name in seen: continue seen.add(name) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index aeff537e..6aba0cdc 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -26,9 +26,21 @@ above a threshold. NES holds 162,650 person entities dominated by Election Commission candidate records, so a scored match can name a namesake as the accused in a corruption case -- the worst error this platform can make. This -module matches on exact name equality within the `person` prefix, and creates -the entity when there is no unique exact match. The failure mode becomes a -duplicate entity, which is a merge, not a defamation. +module matches on exact name equality within the `person` prefix, and creates a +NEW entity when there is no unique exact match. Where that works the failure +mode is a duplicate entity, which is a merge, not a defamation. + +WHERE THE CREATION COLLIDES, NOTHING IS BOUND. A 409 on the create says the slug +this name yields is already TAKEN -- and by an entity the ladder just declined to +identify, because a unique exact match would have bound at rung 2 and never +reached the POST at all. The collision is therefore rung 2's own refusal +condition restated: this name is not unique to this person. So the 409 path binds +nothing. It reports `failed`, names the IRI that was taken, and leaves the case +for a human. Keeping the pre-POST IRI and binding it -- what this module did +until 2026-08-07 -- silently converts "I could not identify this person" into "I +identified this person", on exactly the common names the truncation veto was +written for. The cost is a bind this run does not make; the alternative is the +one error this platform must never make. WHY IT NEVER WRITES `convicted`. `decision_type` sits on the CASE, not on each defendant. `ठहर` on a 19-defendant case does not say who, and `आंशिक ठहर` means @@ -61,7 +73,7 @@ ) from casework.common.review import ReviewRow, build_review_file from casework.common.select import select_for_run -from casework.court_record import court_record_for_case +from casework.court_record import court_record_for_case, is_defendant, party_name from casework.entity_identity import entity_slug, prefix_is_creatable from casework.entity_resolver import normalise_name from casework.enrich_related_entities import ( @@ -238,8 +250,35 @@ def exact_person_match(api, name): return next(iter(hits)), "" +def run_entity_key(name, address): + """The `run_entities` key for one court-record party row. + + NAME PLUS ADDRESS, never the bare name. `run_entities` is shared across + every case in the run so that one person named on two cases becomes ONE + entity rather than two. Keyed on the name alone that reuse is + indiscriminate: two DIFFERENT people who merely share a name, each a + defendant on a different case, collapse into a single entity, and case A's + person then carries case B's accusation -- the same wrong-person bind the + match rungs refuse, arriving through the create rung instead. + + `address` is the only other identifying column NGM stores on a party + (`courts.serializers`'s `CaseEntitySerializer` exposes `side`, `name`, + `address`, `nes_id` and nothing else), and it is what the charge sheet uses + to tell namesakes apart, so it is what separates them here. + + EMPTY-TOLERANT, and the residual hole is deliberate: a row with no address + keys on `(name, "")` and still reuses across cases, so two same-named, + address-less defendants on two cases can still collapse. Closing that would + mean keying on the case, which defeats the cross-case reuse this map exists + for and mints a duplicate entity for every case a person appears on. Both + halves go through `normalise_name` so a spacing or punctuation difference in + the portal's transcription does not split one person into two entities. + """ + return normalise_name(name), normalise_name(address or "") + + def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, - run_entities, dry_run): + run_entities, dry_run, address=""): """Turn one court-record defendant name into an NES entity id. The ladder, top to bottom: @@ -247,12 +286,12 @@ def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, 2. exactly one person entity with that identical name 3. create the entity from the court record - `run_entities` maps a normalised name to an IRI already created THIS RUN and - is shared across cases on purpose: without it, two cases naming the same - defendant create two entities. Nothing here raises -- a name that cannot - become an entity is reported and the case keeps its other defendants. That - covers the search read too: one transient 502 on one of a case's several - defendant rows costs that row, not the run. + `run_entities` maps a `run_entity_key` (name AND address) to an IRI already + created THIS RUN, and is shared across cases on purpose: without it, two + cases naming the same defendant create two entities. Nothing here raises -- + a name that cannot become an entity is reported and the case keeps its other + defendants. That covers the search read too: one transient 502 on one of a + case's several defendant rows costs that row, not the run. """ row_nes_id = (row_nes_id or "").strip() if row_nes_id and is_valid_entity_iri(row_nes_id): @@ -265,10 +304,23 @@ def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, if matched: return Resolution(matched, "exact") - key = normalise_name(name) + key = run_entity_key(name, address) if key in run_entities: return Resolution(run_entities[key], "created", "reused from this run") + if live_prefixes is None: + # NOT a judgement on `person` -- nothing was checked. `read_live_prefixes` + # returns None for exactly this case, but `prefix_is_creatable` folds + # None and [] to the same empty set, so without this branch one transient + # 502 at run start reports every defendant on all 307 cases as needing a + # prefix that is not creatable. That sentence is false for a prefix as + # ordinary as `person`, and the dates still PATCH, so a re-run finds them + # populated and the missing binds look deliberate. + # `enrich_related_entities._cannot_create` draws the same distinction. + return Resolution("", "failed", + f"{why}; the live entity prefix list could not be " + f"read, so {PERSON_PREFIX!r} was never checked -- " + "retry this case") if not prefix_is_creatable(PERSON_PREFIX, live_prefixes): return Resolution("", "failed", f"{why}; the person prefix is not creatable") slug = entity_slug(name) @@ -294,8 +346,23 @@ def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, created = api.create_entity(payload) iri = (created or {}).get("@id") or iri except EntityAlreadyExists: - # The IRI is taken, which means the entity we wanted already exists. - pass + # BINDS NOTHING. A 409 says the slug is taken -- by an entity this + # ladder just declined to identify, since a unique exact match would + # have bound at rung 2 and never reached this POST. The collision is + # therefore rung 2's own refusal restated (this name is not unique to + # this person), and the pre-POST IRI names whoever already owns the + # slug, not necessarily this defendant. `search_entities` marks + # `complete=False` for any name whose results fill a page on relevance, + # so this is the COMMON path for exactly the common names the + # truncation veto was written for -- 13 rows carry `संजय प्रसाद यादव`, + # and one of them owns the slug. Binding it turns "I could not identify + # this person" into "I identified this person"; report it instead and + # let a human decide. + return Resolution("", "failed", + f"{why}; creating it collided with the existing " + f"{iri}, so this name is not unique to this person " + "-- refusing to bind an entity this run did not " + "identify") except Exception as exc: # noqa: BLE001 - one failed POST costs this name, not the case return Resolution("", "failed", f"could not create the entity ({type(exc).__name__})") run_entities[key] = iri @@ -383,23 +450,28 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run): """`(items, rows)` -- one bind per named defendant, plus a report row each. De-duplicated by name across every court reference on the case, order - preserved, exactly like `defendant_names` does. + preserved, exactly like `defendant_names` does -- through the SAME + `is_defendant`/`party_name` pair that module exposes, so the two paths + cannot drift on who counts as a defendant. The whole party row is needed + here (its `nes_id` for ladder rung 1, its `address` for the run-entity key), + which is why this reads the parties itself rather than calling + `defendant_names`. """ outcome = bind_outcome(records) citation = (records[0].get("detail") or {}).get("material_id", "") if records else "" items, rows, seen = [], [], set() for record in records: for party in record.get("parties") or (): - if (party.get("side") or "").strip().lower() != "defendant": + if not is_defendant(party): continue - name = (party.get("name") or "").strip() + name = party_name(party) if not name or name in seen: continue seen.add(name) got = resolve_defendant( api, name, party.get("nes_id"), citation=citation, live_prefixes=live_prefixes, run_entities=run_entities, - dry_run=dry_run) + dry_run=dry_run, address=party.get("address")) row = {"slug": case.get("slug"), "name": name, "how": got.how, "nes_id": got.nes_id, "outcome": outcome, "reason": got.reason, "court_case": f"{record['court']}/{record['number']}"} @@ -516,7 +588,8 @@ def apply_plan(api, plan): #: `plan_case` statuses that end a case before any court-record work happens: #: no `court_read`, `dates`, `defendant_resolve`, `bind_plan` or `patch` event #: follows one of these, only the `select` event below carrying the mapped -#: status. Anything else falls through to `"selected"`. +#: status -- which makes these three TERMINAL, and so the only `select` +#: statuses that may be distinctive. Anything else falls through to `"ok"`. #: #: `"no-entities-key"` reached `plan_case` after this CLI's event vocabulary #: was first drafted: a case payload with no `entities` key at all cannot be @@ -547,9 +620,28 @@ def apply_plan(api, plan): _COURT_READ_FAILURE_PREFIX = "court reference " +#: The ladder rung each `plan.rows` entry settled on, spelled for the events +#: file. It rides in the event's DETAIL, not its status: every event this +#: function emits is an INTERMEDIATE step, and `casework.ledger.build_ledger` +#: treats any status outside `NON_OUTCOME_STATUSES` as the case's outcome for +#: the stage. A per-defendant `failed` or a per-case `merged` would therefore be +#: recorded as what this stage DID to the case, which on a dry run is nothing at +#: all -- and `failed` is a real terminal status for `casework.convert`, so it +#: cannot simply be added to that shared frozenset. Every sibling enricher +#: resolves this the same way: intermediate steps report `ok` and put the +#: specifics in `detail` (`step="source", status="ok"`, +#: `step="resolve", status="ok"`), leaving distinctive statuses to the one +#: terminal event per case. +_RUNG_WORDS = {"nes_id": "nes_id_copied", "exact": "exact_match", + "created": "created", "failed": "failed"} + + def _log_plan(logger, events, run_id, plan): """Emit the per-step events for one planned case. + Every event here is intermediate and therefore `ok`-statused; see + `_RUNG_WORDS` for why, and `main` for the terminal events that follow. + `run_id`/`stage`/`slug` are passed as explicit keywords on every call rather than once via a `**common` dict: `ty` cannot verify that a plain `dict[str, str]` splatted into `log_event`'s keyword-only signature never @@ -559,15 +651,14 @@ def _log_plan(logger, events, run_id, plan): explicit-keyword style for the identical reason. """ for row in plan.rows: - status = {"nes_id": "nes_id_copied", "exact": "exact_match", - "created": "created", "failed": "failed"}[row["how"]] log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, - step="defendant_resolve", status=status, - detail=f"{row['name']} -> {row['nes_id'] or row['reason']}") + step="defendant_resolve", status="ok", + detail=f"{_RUNG_WORDS[row['how']]}: {row['name']} -> " + f"{row['nes_id'] or row['reason']}") if plan.fields: log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, - step="dates", status="proposed", - detail=", ".join(f"{k}={v}" for k, v in plan.fields)) + step="dates", status="ok", + detail="proposed " + ", ".join(f"{k}={v}" for k, v in plan.fields)) for skip in plan.skips: if skip.startswith(_COURT_READ_FAILURE_PREFIX): # A per-reference read failure, not a date fact: `plan.status` is @@ -575,17 +666,19 @@ def _log_plan(logger, events, run_id, plan): # readable reference, or `plan_case` would have returned # "no-court-reference" and `_log_plan` would never run), so the # earlier `court_read`/`ok` event already logged for this case - # stands -- this event says the SAME court read was only partial. + # stands -- this event says the SAME court read was only partial, + # which is an annotation on that read rather than this case's + # outcome. log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, - step="court_read", status="unreadable", detail=skip) + step="court_read", status="ok", detail=f"unreadable: {skip}") continue - status = "skip_open_case" if "not every court reference" in skip else "no_source" + kind = "skip_open_case" if "not every court reference" in skip else "no_source" log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, - step="dates", status=status, detail=skip) + step="dates", status="ok", detail=f"{kind}: {skip}") log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, - step="bind_plan", - status="merged" if plan.entities is not None else "no_additions", - detail=f"{len(plan.rows)} defendant(s) on the court record") + step="bind_plan", status="ok", + detail=f"{'merged' if plan.entities is not None else 'no_additions'}: " + f"{len(plan.rows)} defendant(s) on the court record") def main(argv=None): @@ -629,8 +722,13 @@ def main(argv=None): # WHICH missing/unreadable reference. Without this, "no-court-reference" # (a case naming none at all) and "every reference on this case # 404'd" produced an identical events-file line. + # `"ok"` -- not `"selected"` -- for a case that proceeds: selection is + # an intermediate step, and any status outside + # `casework.ledger.NON_OUTCOME_STATUSES` is recorded as the case's + # outcome for this stage. The three SKIP statuses keep their own + # spellings because they ARE the outcome: nothing follows them. log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, - step="select", status=_SKIP_SELECT_STATUS.get(plan.status, "selected"), + step="select", status=_SKIP_SELECT_STATUS.get(plan.status, "ok"), detail="; ".join(plan.skips)) if plan.status in _SKIP_SELECT_STATUS: stats[plan.status] = stats.get(plan.status, 0) + 1 @@ -651,6 +749,19 @@ def main(argv=None): note="; ".join(plan.skips))) if plan.status == "nothing-to-do": + # The one TERMINAL event this path gets. Without it the case ends on + # `ok`-statused intermediates only and vanishes from the ledger + # entirely, which cannot be told apart from a run that crashed + # before reaching it. `already` is the vocabulary every sibling uses + # for "the fields were populated before we got here" + # (`step="idempotency", status="already"`), and it is what this is: + # no date field was empty and needed filling, and every bind the + # court record names is already on the case. + log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, + step="idempotency", status="already", + detail="nothing to add: no empty date this run could " + f"fill, and all {len(plan.rows)} court-record " + "defendant(s) are already bound") stats["nothing-to-do"] = stats.get("nothing-to-do", 0) + 1 continue if args.dry_run: diff --git a/casework/ledger.py b/casework/ledger.py index b2ce68b2..18f58680 100644 --- a/casework/ledger.py +++ b/casework/ledger.py @@ -50,6 +50,12 @@ # EXISTING case fields (dates, accused binds), not an extraction preview of # NEW content the way `would-convert`/`would-enrich` are -- so, like a bind # dry run, it changed nothing and must not read as a per-case outcome either. +# Excluding the terminal status is only half of that: every INTERMEDIATE event +# a stage emits must carry one of these statuses too, or the latest of THOSE +# becomes the recorded outcome instead. That is why every enricher reports its +# intermediate steps as `ok` and puts the specifics in `detail`; nothing +# stage-specific belongs in this frozenset (`failed`, for one, is a real +# terminal status for `casework.convert`). NON_OUTCOME_STATUSES = frozenset({"ok", "start", "fallback", "none", "planned", "dry_run"}) _DEFAULT_LEDGER = _REPO_ROOT / "work" / "enrichment-ledger.jsonl" diff --git a/tests/casework/test_build_api_guard_wiring.py b/tests/casework/test_build_api_guard_wiring.py index 4ff225fc..6c995727 100644 --- a/tests/casework/test_build_api_guard_wiring.py +++ b/tests/casework/test_build_api_guard_wiring.py @@ -1,11 +1,17 @@ -"""Guard-wiring unit tests for every ported enricher's `build_api(args)`. +"""Guard-wiring unit tests for every enricher CLI's `build_api(args)`. Task PP2 wires `args.allow_remote_writes` into BOTH branches of `build_api` -(the `token=` branch and the `basic=` branch) across all six enricher CLIs, so +(the `token=` branch and the `basic=` branch) across the enricher CLIs, so that `--allow-remote-writes` actually reaches `CaseworkApi` and its `_patch` write-guard. This is the mutation-sensitive test the task calls out explicitly: dropping `allow_remote_writes=` from a `build_api` call must make -one of these fail (pinned by `TestMutationDropsAllowRemoteWrites` below). +one of these fail. + +`MODULES` must list EVERY module that defines a `build_api`. It carried the six +PP2 enrichers only, so `enrich_card`, `enrich_description` and +`enrich_court_record` -- each with the identical two-branch `build_api` -- were +unpinned: dropping `allow_remote_writes=` from either of their branches was +caught for six files and silently allowed in three. All nine are listed now. No network -- `build_api` only constructs a `CaseworkApi` object, it never makes a request. @@ -16,12 +22,16 @@ from casework import convert as c_convert from casework import enrich_allegations as c_allegations +from casework import enrich_card as c_card +from casework import enrich_court_record as c_court_record +from casework import enrich_description as c_description from casework import enrich_missing_bigo as c_bigo from casework import enrich_related_entities as c_entities from casework import enrich_tags as c_tags from casework import enrich_timeline as c_timeline -MODULES = [c_bigo, c_tags, c_timeline, c_allegations, c_entities, c_convert] +MODULES = [c_bigo, c_tags, c_timeline, c_allegations, c_entities, c_convert, + c_card, c_description, c_court_record] def _args(*, api_base_url, **overrides): @@ -37,7 +47,7 @@ def _args(*, api_base_url, **overrides): @pytest.mark.parametrize("module", MODULES, ids=lambda m: m.__name__.rsplit(".", 1)[-1]) class TestBuildApiGuardWiring: - """Both auth branches of `build_api`, both flag values, all six files. + """Both auth branches of `build_api`, both flag values, every file. The `basic=` branch requires a loopback `api_base_url` (`CaseworkApi` itself rejects `basic=` against a non-loopback host -- see diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 848f034a..a9098051 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -222,22 +222,130 @@ def test_the_same_person_across_two_cases_creates_one_entity(): assert len(api.posted) == 1 -def test_an_existing_iri_collision_binds_the_existing_entity(): - # The stub raises before returning anything, so `resolve_defendant` never - # reads the exception's payload -- a real 409 body is an opaque error blob, - # not a clean IRI. What it keeps is the IRI it computed BEFORE the POST, - # which by construction of a same-slug collision IS the entity that was - # already there. `EntityAlreadyExists(YADAV)`'s argument is therefore - # unread by design; expressed here as the actual `entity_slug` output - # rather than `YADAV` itself, whose hand-picked spelling drops the schwas - # `entity_slug` keeps (`कृष्ण प्रसाद यादव` -> `krishna-prasada-yadava`, not - # `krishna-prasad-yadav`). +def test_an_existing_iri_collision_refuses_to_bind(): + # A 409 means the slug is TAKEN -- by an entity the ladder just declined to + # identify, since a unique exact match would have bound at rung 2 and never + # reached the POST. Keeping the pre-POST IRI and binding it (what this did + # until 2026-08-07) hands the case to whoever already owns that slug: after + # "13 person entities carry this exact name", or after the truncation veto + # declined candidate X, the create collides with X and X gets bound anyway + # with no ambiguity check. Nothing may be bound here. api = _SearchApi(results=[], created=EntityAlreadyExists(YADAV)) got = resolve_defendant(api, "कृष्ण प्रसाद यादव", None, citation="", live_prefixes=["person"], run_entities={}, dry_run=False) - assert got.how == "created" - assert got.nes_id == build_entity_iri(PERSON_PREFIX, - entity_slug("कृष्ण प्रसाद यादव")) + assert (got.nes_id, got.how) == ("", "failed") + # The report must name the IRI that was taken, so a human can look at it. + # Spelled through `entity_slug` rather than as `YADAV`, whose hand-picked + # spelling drops the schwas `entity_slug` keeps (`कृष्ण प्रसाद यादव` -> + # `krishna-prasada-yadava`, not `krishna-prasad-yadav`). + taken = build_entity_iri(PERSON_PREFIX, entity_slug("कृष्ण प्रसाद यादव")) + assert taken in got.reason + assert "collided" in got.reason + + +def test_a_collision_is_not_remembered_for_the_rest_of_the_run(): + # The refusal must not poison `run_entities` either: caching the taken IRI + # would make every LATER case naming this defendant bind it at the "reused + # from this run" rung, turning one refused bind into a run-wide one. + api = _SearchApi(results=[], created=EntityAlreadyExists(YADAV)) + run_entities = {} + resolve_defendant(api, "कृष्ण प्रसाद यादव", None, citation="", + live_prefixes=["person"], run_entities=run_entities, + dry_run=False) + assert run_entities == {} + + +def test_two_same_named_defendants_on_different_cases_do_not_collapse(): + # `run_entities` is shared across cases so ONE person named on two cases + # becomes one entity. Keyed on the bare name that reuse is indiscriminate: + # two DIFFERENT people who merely share a name -- a defendant on case A and + # a defendant on case B -- collapse into a single entity, and case A's + # person then carries case B's accusation. The court party row's `address` + # is what the charge sheet uses to tell them apart, so it is part of the key. + # + # Written against a stub that behaves like the server (one slug, one + # entity), because both halves of the fix have to hold for this to pass: + # keying on the bare name reuses A's entity for B outright, and keeping the + # old 409 handling binds A's entity to B after the create collides. + class _SlugAwareApi(_SearchApi): + def create_entity(self, payload, timeout=60): + taken = {p["slug"] for p in self.posted} + self.posted.append(payload) + iri = build_entity_iri(PERSON_PREFIX, payload["slug"]) + if payload["slug"] in taken: + raise EntityAlreadyExists(iri) + return {"@id": iri} + + api = _SlugAwareApi(results=[]) + run_entities = {} + common = {"citation": "", "live_prefixes": ["person"], + "run_entities": run_entities, "dry_run": False} + first = resolve_defendant(api, "कृष्ण प्रसाद यादव", None, + address="सर्लाही, हरिपुर-४", **common) + second = resolve_defendant(api, "कृष्ण प्रसाद यादव", None, + address="मोरङ, विराटनगर-१२", **common) + assert (first.how, bool(first.nes_id)) == ("created", True) + # A different person must not inherit the first one's entity, by either + # route -- not from the run cache, and not from the collision. + assert second.nes_id != first.nes_id + assert (second.nes_id, second.how) == ("", "failed") + + +def test_the_same_person_on_two_cases_still_creates_one_entity(): + # The other half of the same key: same name AND same address is one person, + # and must still be created once no matter how many cases name them -- + # otherwise the address in the key would have cost the cross-case reuse + # `run_entities` exists for. + api = _SearchApi(results=[], created={"@id": YADAV}) + run_entities = {} + for _ in range(2): + resolve_defendant(api, "कृष्ण प्रसाद यादव", None, citation="", + live_prefixes=["person"], run_entities=run_entities, + dry_run=False, address="सर्लाही, हरिपुर-४") + assert len(api.posted) == 1 + assert len(run_entities) == 1 + + +def test_an_address_is_normalised_before_it_keys_the_run(): + # Spacing/punctuation drift in the portal's transcription of one address + # must not split one person into two entities -- the same `normalise_name` + # the name half of the key already goes through. + api = _SearchApi(results=[], created={"@id": YADAV}) + run_entities = {} + for address in ("सर्लाही, हरिपुर-४", " सर्लाही, हरिपुर-४ "): + resolve_defendant(api, "कृष्ण प्रसाद यादव", None, citation="", + live_prefixes=["person"], run_entities=run_entities, + dry_run=False, address=address) + assert len(api.posted) == 1 + + +def test_an_unreadable_prefix_list_is_not_a_verdict_on_the_prefix(): + # `read_live_prefixes` returns None on any error (a transient 502 at run + # start), and `prefix_is_creatable` folds None to the empty set -- so + # without a dedicated branch every defendant needing creation across all + # 307 cases is reported "the person prefix is not creatable", a false + # statement about a prefix as ordinary as `person`. The dates still PATCH, + # so a re-run finds them populated and the missing binds look deliberate. + api = _SearchApi(results=[]) + got = resolve_defendant(api, "कृष्ण प्रसाद यादव", None, citation="", + live_prefixes=None, run_entities={}, dry_run=True) + assert (got.nes_id, got.how) == ("", "failed") + assert "could not be read" in got.reason and "retry this case" in got.reason + # The distinction is the whole point: this must NOT read as a judgement on + # the prefix the way a genuinely refused prefix does. + assert "not creatable" not in got.reason + assert api.posted == [] + + +def test_a_genuinely_unusable_prefix_still_says_so(): + # The companion: an EMPTY (successfully read) prefix list is a real verdict + # -- `person` is in use nowhere -- and must keep saying "not creatable", + # or the None branch above would have swallowed both cases into one reason. + api = _SearchApi(results=[]) + got = resolve_defendant(api, "कृष्ण प्रसाद यादव", None, citation="", + live_prefixes=[], run_entities={}, dry_run=True) + assert (got.nes_id, got.how) == ("", "failed") + assert "not creatable" in got.reason def test_a_name_that_cannot_be_slugged_fails_without_raising(): @@ -445,6 +553,22 @@ def test_an_existing_bind_survives_untouched(): assert plan.entities is None +def test_the_party_row_address_reaches_the_run_entity_key(): + # Wiring: `_accused_binds` must pass the court party row's `address` + # through to `resolve_defendant`, or the name-plus-address key is dead code + # at the only call site that matters and two same-named defendants on two + # cases still collapse into one entity. Two cases, one name, two addresses, + # one shared `run_entities` -- two keys. + run_entities = {} + for slug, address in (("case-a", "सर्लाही, हरिपुर-४"), + ("case-b", "मोरङ, विराटनगर-१२")): + api = _PlanApi(detail={"registration_date_ad": "2023-06-22"}, + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", + "address": address}]) + _plan(api, _case(slug=slug), run_entities=run_entities) + assert len(run_entities) == 2 + + def test_a_populated_date_is_never_overwritten(): api = _PlanApi(detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED]) plan = _plan(api, _case(case_start_date="2020-01-01", case_end_date="2021-01-01")) @@ -672,14 +796,92 @@ def get_courtcase(self, court, number, timeout=60): events = _events(tmp_path) court_read = [e for e in events if e["step"] == "court_read"] - assert any(e["status"] == "ok" for e in court_read) - assert any(e["status"] == "unreadable" and "079-cr-0999" in e["detail"] + assert any(e["detail"] == "" for e in court_read), "the successful read" + assert any("unreadable: " in e["detail"] and "079-cr-0999" in e["detail"] for e in court_read) # The 404 must not also (or instead) show up as a `dates` event -- only # the genuine date-source skip belongs there. dates = [e for e in events if e["step"] == "dates"] assert not any("079-cr-0999" in e.get("detail", "") for e in dates) - assert any(e["status"] == "no_source" for e in dates) + assert any(e["detail"].startswith("no_source: ") for e in dates) + # Both are INTERMEDIATE steps, so both report `ok` and carry the + # classification in the detail; see `_RUNG_WORDS`. A distinctive status + # here would be recorded by `casework.ledger` as this case's outcome. + assert {e["status"] for e in court_read + dates} == {"ok"} + + +def test_a_dry_run_leaves_the_case_out_of_the_ledger_entirely(tmp_path, monkeypatch): + # Fix 3, proved against a REAL run rather than a hand-written fixture: the + # events this CLI actually emits, folded by the real + # `casework.ledger.build_ledger`, must leave nothing behind for a dry run. + # A dry run changed nothing, so the "what did we change, when" audit must + # not carry a row for it -- and excluding the terminal `patch`/`dry_run` + # status alone does not achieve that: whatever distinctive status the + # LATEST surviving event carries becomes the outcome instead, which is how + # `bind_plan`/`merged` was landing in the ledger for every dry-run case. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + api = _CliApi( + _case(), + detail={"registration_date_ad": "2023-06-22"}, + hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}], + ) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + assert main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) == 0 + + # The run really did emit the full sequence -- otherwise "the ledger is + # empty" would be true for the boring reason that nothing was logged. + steps = [e["step"] for e in _events(tmp_path)] + assert {"select", "court_read", "defendant_resolve", "bind_plan", + "patch"} <= set(steps) + + from casework.ledger import build_ledger + assert build_ledger(tmp_path) == {} + + +def test_an_apply_run_is_recorded_in_the_ledger(tmp_path, monkeypatch): + # The companion: "the ledger is empty" must not be achieved by excluding + # every status this stage emits. The same sequence ending in a real PATCH + # records `applied` against the case. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + api = _CliApi( + _case(), + detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}], + ) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + assert main(["--api-base-url", "http://127.0.0.1:48010", "--apply", + "--review-file", str(tmp_path / "review.md")]) == 0 + + from casework.ledger import build_ledger + ledger = build_ledger(tmp_path) + assert ledger[("case-079-cr-0151", "court_record")]["status"] == "applied" + + +def test_a_case_with_nothing_to_change_records_already_not_nothing(tmp_path, monkeypatch): + # A case that needed no write ends on `ok`-statused intermediates only, so + # without a terminal event of its own it would vanish from the ledger -- + # indistinguishable from a run that crashed before reaching it. The ledger's + # stated value is telling "we enriched it" from "it was already populated", + # so this path emits the sibling vocabulary for the latter. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + api = _CliApi(_case(case_start_date="2020-01-01", case_end_date="2021-01-01"), + detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[]) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + assert main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) == 0 + assert api.patch_calls == [] + + from casework.ledger import build_ledger + assert build_ledger(tmp_path)[("case-079-cr-0151", "court_record")]["status"] == "already" def test_apply_run_records_a_412_as_etag_conflict_with_no_applied_event( diff --git a/tests/casework/test_ledger.py b/tests/casework/test_ledger.py index c55f8b07..c1d2c452 100644 --- a/tests/casework/test_ledger.py +++ b/tests/casework/test_ledger.py @@ -114,16 +114,46 @@ def test_planned_bind_dryrun_is_not_an_outcome(self, tmp_path): ]) assert ("case-8", "bind") not in build_ledger(tmp_path) - def test_court_record_dry_run_patch_is_not_an_outcome(self, tmp_path): + def test_court_record_dry_run_leaves_no_outcome_for_the_whole_case(self, tmp_path): # enrich_court_record's `patch`/`dry_run` status is the same kind of - # "changed nothing" preview as bind_materials' `planned` -- it must - # not pollute the "what did we change, when" audit either. + # "changed nothing" preview as bind_materials' `planned` -- it must not + # pollute the "what did we change, when" audit either. + # + # Written against the case's WHOLE event sequence, not the `patch` line + # alone: excluding the terminal status achieves nothing if an earlier + # intermediate carries a distinctive one, because the fold then records + # THAT as the outcome. A lone-`patch` fixture passed while + # `bind_plan`/`merged` was still landing `merged` in the ledger for + # every dry-run case. _write_events(tmp_path / "court_record.events.jsonl", [ - _ev("2026-08-07T10:00:00Z", "court_record", "case-10", "patch", "dry_run", + _ev("2026-08-07T10:00:00Z", "court_record", "case-10", "select", "ok"), + _ev("2026-08-07T10:00:01Z", "court_record", "case-10", "court_read", "ok"), + _ev("2026-08-07T10:00:02Z", "court_record", "case-10", + "defendant_resolve", "ok", "created: कृष्ण प्रसाद यादव -> person/..."), + _ev("2026-08-07T10:00:03Z", "court_record", "case-10", "dates", "ok", + "proposed case_start_date=2023-06-22"), + _ev("2026-08-07T10:00:04Z", "court_record", "case-10", "bind_plan", "ok", + "merged: 1 defendant(s) on the court record"), + _ev("2026-08-07T10:00:05Z", "court_record", "case-10", "patch", "dry_run", "case_start_date=2023-06-22"), ]) assert ("case-10", "court_record") not in build_ledger(tmp_path) + def test_a_court_record_apply_is_still_recorded(self, tmp_path): + # The companion, so "nothing lands" is not achieved by excluding + # everything: the same sequence ending in a REAL patch must record it. + _write_events(tmp_path / "court_record.events.jsonl", [ + _ev("2026-08-07T11:00:00Z", "court_record", "case-11", "select", "ok"), + _ev("2026-08-07T11:00:01Z", "court_record", "case-11", "court_read", "ok"), + _ev("2026-08-07T11:00:02Z", "court_record", "case-11", + "defendant_resolve", "ok", "exact_match: कृष्ण प्रसाद यादव -> person/..."), + _ev("2026-08-07T11:00:03Z", "court_record", "case-11", "bind_plan", "ok", + "merged: 1 defendant(s) on the court record"), + _ev("2026-08-07T11:00:04Z", "court_record", "case-11", "patch", "applied", + "case_start_date=2023-06-22; accused+1"), + ]) + assert build_ledger(tmp_path)[("case-11", "court_record")]["status"] == "applied" + def test_applied_bind_supersedes_earlier_planned(self, tmp_path): # A later real APPLY must be recorded; the earlier dry-run 'planned' was # never an outcome, so 'enriched' is the ledger state. From ddf0947f0e03af1ff309132d88ba895ba8e1c53b Mon Sep 17 00:00:00 2001 From: GAURAV Date: Sat, 8 Aug 2026 04:03:29 -0700 Subject: [PATCH 14/29] fix(casework): classify the court case number, and bind only prosecutions OA, RE and the W* writ codes name a government office in the court record's defendant column, not a person. case_number_code() reads the case-type letters out of the court case number, and _accused_binds now skips a whole record whose code isn't on the CR/CB/FJ/"" allow-list before it ever reads that record's parties -- start_date/end_date are untouched, since the filter is on binding only. --- casework/court_record.py | 21 ++++++ casework/enrich_court_record.py | 30 ++++++-- tests/casework/test_court_record.py | 33 ++++++++- tests/casework/test_enrich_court_record.py | 82 ++++++++++++++++++++++ 4 files changed, 160 insertions(+), 6 deletions(-) diff --git a/casework/court_record.py b/casework/court_record.py index 010fa692..d418f6bb 100644 --- a/casework/court_record.py +++ b/casework/court_record.py @@ -41,6 +41,7 @@ """ import logging +import re import urllib.error logger = logging.getLogger(__name__) @@ -51,6 +52,26 @@ #: `CaseEntity.side` is free text, documented `plaintiff | defendant`). _DEFENDANT_SIDE = "defendant" +#: Case-type codes whose defendant column names an actual defendant. `""` is +#: the pre-FY073 format (`93-068-0194`), which carries no type segment and is +#: a prosecution -- 139 references in the corpus. Everything else (`OA`, `RE`, +#: the `W*` writ codes, and any code not yet seen) is an allow-list miss and +#: skips: an unrecognised code risks naming a government office as accused, +#: where skipping one only costs a bind a later run recovers. +BINDABLE_CODES = frozenset({"CR", "CB", "FJ", ""}) + +_CODE_SEGMENT = re.compile(r"-([A-Za-z]+)-") + + +def case_number_code(number): + """The court's case-type letters from `079-CR-0151`, upper-cased, or "". + + Matches the first `--` segment; a number with none (the old + pre-FY073 format, or anything malformed) yields "". + """ + match = _CODE_SEGMENT.search(str(number or "")) + return match.group(1).upper() if match else "" + def is_defendant(party): """Whether this party row names a defendant rather than a plaintiff. diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 6aba0cdc..bfdc22c0 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -73,7 +73,13 @@ ) from casework.common.review import ReviewRow, build_review_file from casework.common.select import select_for_run -from casework.court_record import court_record_for_case, is_defendant, party_name +from casework.court_record import ( + BINDABLE_CODES, + case_number_code, + court_record_for_case, + is_defendant, + party_name, +) from casework.entity_identity import entity_slug, prefix_is_creatable from casework.entity_resolver import normalise_name from casework.enrich_related_entities import ( @@ -447,7 +453,8 @@ def bind_outcome(records): def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run): - """`(items, rows)` -- one bind per named defendant, plus a report row each. + """`(items, rows, skips)` -- one bind per named defendant, a report row each, + and a line per record whose case type is not a prosecution. De-duplicated by name across every court reference on the case, order preserved, exactly like `defendant_names` does -- through the SAME @@ -456,11 +463,23 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run): here (its `nes_id` for ladder rung 1, its `address` for the run-entity key), which is why this reads the parties itself rather than calling `defendant_names`. + + A record whose `case_number_code` is not in `BINDABLE_CODES` (`OA`, `RE`, + the `W*` writ codes, ...) is skipped whole: on those the defendant column + names a government office, not a person. This is the only filter on this + path -- `start_date`/`end_date` still read every record. """ outcome = bind_outcome(records) citation = (records[0].get("detail") or {}).get("material_id", "") if records else "" - items, rows, seen = [], [], set() + items, rows, skips, seen = [], [], [], set() for record in records: + code = case_number_code(record["number"]) + if code not in BINDABLE_CODES: + skips.append( + f"skipping accused bind for court reference " + f"{record['court']}/{record['number']}: case type {code!r} " + "is not a prosecution") + continue for party in record.get("parties") or (): if not is_defendant(party): continue @@ -485,7 +504,7 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run): items.append(validate_bind_item(item)) except ValueError as exc: row.update(how="failed", reason=str(exc)) - return items, rows + return items, rows, skips def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run): @@ -525,9 +544,10 @@ def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run): elif why: skips.append(f"case_end_date left empty: {why}") - items, rows = _accused_binds( + items, rows, accused_skips = _accused_binds( api, case, records, live_prefixes=live_prefixes, run_entities=run_entities, dry_run=dry_run) + skips.extend(accused_skips) # `current_entity_binds`, NOT the raw `case["entities"]` list: the read # shape keys the relationship type under `type`, and `relationship_type` diff --git a/tests/casework/test_court_record.py b/tests/casework/test_court_record.py index f9cf2125..736aada6 100644 --- a/tests/casework/test_court_record.py +++ b/tests/casework/test_court_record.py @@ -12,7 +12,13 @@ import pytest -from casework.court_record import court_record_for_case, court_ref, defendant_names +from casework.court_record import ( + BINDABLE_CODES, + case_number_code, + court_record_for_case, + court_ref, + defendant_names, +) class _Api: @@ -201,3 +207,28 @@ def test_no_court_reference_reports_why(): records, skips = court_record_for_case(_FullApi(), {"court_cases": []}) assert records == [] assert "no court reference" in skips[0] + + +@pytest.mark.parametrize("number, expected", [ + ("079-CR-0151", "CR"), + # Lower-case in, upper-case out: a case-sensitive `"-CR-" in number` check + # would miss this and wrongly skip the bind. + ("078-cb-1372", "CB"), + # The pre-FY073 format carries no type segment at all. A rule spelled + # "must contain a `-XX-` code" would misclassify this as unrecognised and + # skip 139 real prosecutions in the corpus. + ("93-068-0194", ""), + ("081-RE-1730", "RE"), + # No hyphens at all: a `.split("-")[1]` implementation would raise + # IndexError here instead of falling back to "". + ("0791234", ""), +]) +def test_case_number_code_classifies_the_court_case_type(number, expected): + assert case_number_code(number) == expected + + +def test_bindable_codes_names_only_the_prosecution_codes(): + # Pins the allow-list itself: a deny-list rewrite (skip only OA/RE/W*) + # would still pass every `case_number_code` test above but silently bind + # an unrecognised code instead of skipping it. + assert BINDABLE_CODES == frozenset({"CR", "CB", "FJ", ""}) diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index a9098051..fc906761 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -97,6 +97,7 @@ def test_end_date_takes_the_latest_when_every_reference_decided(): from casework.entity_identity import entity_slug # noqa: E402 from casework.enrich_court_record import ( # noqa: E402 PERSON_PREFIX, + _accused_binds, _is_person, exact_person_match, resolve_defendant, @@ -435,6 +436,14 @@ def _plan(api, case, **kw): return plan_case(api, case, 'W/"7"', **kw) +def _court_record(number, name, *, nes_id=None, court="special"): + """One court reference, one defendant party, for `_accused_binds` tests.""" + party = {"side": "defendant", "name": name} + if nes_id: + party["nes_id"] = nes_id + return {"court": court, "number": number, "detail": {}, "hearings": [], "parties": [party]} + + def test_a_whole_case_acquittal_labels_every_defendant_acquitted(): assert bind_outcome([_record(hearings=[DECIDED])]) == ACQUITTED @@ -537,6 +546,79 @@ def test_a_plaintiff_is_never_bound(): assert _plan(api, _case()).entities is None +def test_accused_binds_skips_a_non_prosecution_record_but_binds_a_prosecution_one(): + # The OA party is deliberately named something other than नेपाल सरकार (its + # real value per the brief's probe): a wrong implementation that filters + # on THAT literal name string would still pass a fixture using it, for the + # wrong reason. Naming it "कुनै व्यक्ति" (an ordinary person's placeholder) + # means only a code-based filter can make this record skip. + cr_record = _court_record("079-cr-0151", "कृष्ण प्रसाद यादव", nes_id=YADAV) + oa_record = _court_record("079-oa-0014", "कुनै व्यक्ति") + items, rows, skips = _accused_binds( + _SearchApi(), _case(), [cr_record, oa_record], + live_prefixes=["person"], run_entities={}, dry_run=True) + assert [i["nes_id"] for i in items] == [YADAV] + assert [r["name"] for r in rows] == ["कृष्ण प्रसाद यादव"] + assert len(skips) == 1 + assert "079-oa-0014" in skips[0] and "OA" in skips[0] + + +def test_accused_binds_binds_the_pre_fy073_no_code_format(): + # `93-068-0194`-style numbers carry no `--` segment at all -- 139 + # references in the corpus. A rule spelled "the number must contain + # `-CR-`" would misclassify this as an unrecognised code and silently + # drop these prosecutions. + record = _court_record("93-068-0194", "सिताराम यादव", nes_id=YADAV) + items, rows, skips = _accused_binds( + _SearchApi(), _case(), [record], + live_prefixes=["person"], run_entities={}, dry_run=True) + assert [i["nes_id"] for i in items] == [YADAV] + assert skips == [] + + +def test_accused_binds_skips_an_unrecognised_code_not_on_any_documented_skip_list(): + # A deny-list rewrite (skip only OA/RE/WC/WF/WH/WO) would still bind this: + # "ZZ" is on neither list. It must skip anyway -- an unrecognised code + # risks naming an office, and skipping one only costs a bind a later run + # recovers, so the allow-list, not a deny-list, is what must gate this. + record = _court_record("079-zz-0001", "कुनै व्यक्ति", nes_id=YADAV) + items, rows, skips = _accused_binds( + _SearchApi(), _case(), [record], + live_prefixes=["person"], run_entities={}, dry_run=True) + assert items == [] + assert len(skips) == 1 and "ZZ" in skips[0] + + +def test_accused_binds_binds_a_person_named_through_their_firm(): + # FJ's one reference in the corpus names a proprietor through their firm: + # "अनिल गुप्ता एण्ड एशोसियटस का प्रोपराइटर अनिल कुमार गुप्ता". A keyword + # filter on "एशोसियटस" or "कार्यालय" would drop this real defendant -- + # only the code, never the name text, may gate the bind. + firm_name = "अनिल गुप्ता एण्ड एशोसियटस का प्रोपराइटर अनिल कुमार गुप्ता" + record = _court_record("079-fj-0001", firm_name, nes_id=YADAV) + items, rows, skips = _accused_binds( + _SearchApi(), _case(), [record], + live_prefixes=["person"], run_entities={}, dry_run=True) + assert [i["nes_id"] for i in items] == [YADAV] + assert skips == [] + + +def test_a_case_with_only_a_non_prosecution_reference_still_gets_both_dates(): + # Guards "dates are not filtered": `plan_case` reads `start_date`/`end_date` + # off `records` directly, so the accused-bind filter must live inside + # `_accused_binds` and never upstream in `court_record_for_case` -- if it + # did, this case's only reference would vanish from `records` entirely and + # both date fields would stay empty rather than just the bind. + api = _PlanApi(detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कुनै व्यक्ति"}]) + case = _case(court_cases=["https://jawafdehi.org/courtcase/special/079-oa-0014"]) + plan = _plan(api, case) + assert dict(plan.fields) == {"case_start_date": "2023-06-22", + "case_end_date": "2024-06-04"} + assert plan.entities is None + assert any("079-oa-0014" in s and "OA" in s for s in plan.skips) + + def test_an_existing_bind_survives_untouched(): # The REAL read shape: the relationship type comes back under `type`, and # `relationship_type` never appears on a read at all. A fixture written From f5c1bf12472e40e022ea60577a2f838dfd4ec942 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Sat, 8 Aug 2026 04:17:58 -0700 Subject: [PATCH 15/29] fix(casework): route non-prosecution skips to bind_plan, and trim the review debt _log_plan had no branch for _accused_binds' new record-level skip, so it fell into the dates/no_source bucket and the bind_plan summary claimed 0 defendants where the record had named one and been declined. Add _NON_PROSECUTION_SKIP_PREFIX alongside _COURT_READ_FAILURE_PREFIX and route on it; the bind_plan line now counts resolved defendants and says how many references were skipped as non-prosecution. Also: drop the self-referential BINDABLE_CODES test, collapse the duplicate _court_record test builder into _record, and cut the same rationale from being written three times across two docstrings and a comment block. --- casework/court_record.py | 6 +--- casework/enrich_court_record.py | 36 +++++++++++++++------- tests/casework/test_court_record.py | 8 ----- tests/casework/test_enrich_court_record.py | 29 +++++++++-------- 4 files changed, 40 insertions(+), 39 deletions(-) diff --git a/casework/court_record.py b/casework/court_record.py index d418f6bb..277356ff 100644 --- a/casework/court_record.py +++ b/casework/court_record.py @@ -64,11 +64,7 @@ def case_number_code(number): - """The court's case-type letters from `079-CR-0151`, upper-cased, or "". - - Matches the first `--` segment; a number with none (the old - pre-FY073 format, or anything malformed) yields "". - """ + """The court's case-type letters from `079-CR-0151`, upper-cased, or "" if none.""" match = _CODE_SEGMENT.search(str(number or "")) return match.group(1).upper() if match else "" diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index bfdc22c0..4772f70f 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -453,8 +453,7 @@ def bind_outcome(records): def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run): - """`(items, rows, skips)` -- one bind per named defendant, a report row each, - and a line per record whose case type is not a prosecution. + """`(items, rows, skips)` -- binds, a report row each, and non-prosecution skips. De-duplicated by name across every court reference on the case, order preserved, exactly like `defendant_names` does -- through the SAME @@ -463,11 +462,6 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run): here (its `nes_id` for ladder rung 1, its `address` for the run-entity key), which is why this reads the parties itself rather than calling `defendant_names`. - - A record whose `case_number_code` is not in `BINDABLE_CODES` (`OA`, `RE`, - the `W*` writ codes, ...) is skipped whole: on those the defendant column - names a government office, not a person. This is the only filter on this - path -- `start_date`/`end_date` still read every record. """ outcome = bind_outcome(records) citation = (records[0].get("detail") or {}).get("material_id", "") if records else "" @@ -476,7 +470,7 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run): code = case_number_code(record["number"]) if code not in BINDABLE_CODES: skips.append( - f"skipping accused bind for court reference " + "skipping accused bind for court reference " f"{record['court']}/{record['number']}: case type {code!r} " "is not a prosecution") continue @@ -640,6 +634,13 @@ def apply_plan(api, plan): _COURT_READ_FAILURE_PREFIX = "court reference " +#: Prefix `_accused_binds` puts on every record it skips for a non-prosecution +#: case type. Mirrors `_COURT_READ_FAILURE_PREFIX`'s role: the reference read +#: fine and was refused by policy, which is neither an unreadable reference +#: nor a fact about date derivation, so `_log_plan` routes it to `bind_plan`. +_NON_PROSECUTION_SKIP_PREFIX = "skipping accused bind for " + + #: The ladder rung each `plan.rows` entry settled on, spelled for the events #: file. It rides in the event's DETAIL, not its status: every event this #: function emits is an INTERMEDIATE step, and `casework.ledger.build_ledger` @@ -679,6 +680,7 @@ def _log_plan(logger, events, run_id, plan): log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, step="dates", status="ok", detail="proposed " + ", ".join(f"{k}={v}" for k, v in plan.fields)) + code_skips = 0 for skip in plan.skips: if skip.startswith(_COURT_READ_FAILURE_PREFIX): # A per-reference read failure, not a date fact: `plan.status` is @@ -692,13 +694,25 @@ def _log_plan(logger, events, run_id, plan): log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, step="court_read", status="ok", detail=f"unreadable: {skip}") continue + if skip.startswith(_NON_PROSECUTION_SKIP_PREFIX): + # Read fine, refused by policy -- not a date fact either, so this + # rides under `bind_plan` (see `_NON_PROSECUTION_SKIP_PREFIX`). + code_skips += 1 + log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, + step="bind_plan", status="ok", detail=skip) + continue kind = "skip_open_case" if "not every court reference" in skip else "no_source" log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, step="dates", status="ok", detail=f"{kind}: {skip}") + # "resolved", not "on the court record": when `code_skips` is non-zero the + # court record named at least one defendant this run declined to look at, + # and the earlier count must not be read as "the record named none". + summary = (f"{'merged' if plan.entities is not None else 'no_additions'}: " + f"{len(plan.rows)} defendant(s) resolved") + if code_skips: + summary += f"; {code_skips} court reference(s) skipped as non-prosecution" log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, - step="bind_plan", status="ok", - detail=f"{'merged' if plan.entities is not None else 'no_additions'}: " - f"{len(plan.rows)} defendant(s) on the court record") + step="bind_plan", status="ok", detail=summary) def main(argv=None): diff --git a/tests/casework/test_court_record.py b/tests/casework/test_court_record.py index 736aada6..2726cbb7 100644 --- a/tests/casework/test_court_record.py +++ b/tests/casework/test_court_record.py @@ -13,7 +13,6 @@ import pytest from casework.court_record import ( - BINDABLE_CODES, case_number_code, court_record_for_case, court_ref, @@ -225,10 +224,3 @@ def test_no_court_reference_reports_why(): ]) def test_case_number_code_classifies_the_court_case_type(number, expected): assert case_number_code(number) == expected - - -def test_bindable_codes_names_only_the_prosecution_codes(): - # Pins the allow-list itself: a deny-list rewrite (skip only OA/RE/W*) - # would still pass every `case_number_code` test above but silently bind - # an unrecognised code instead of skipping it. - assert BINDABLE_CODES == frozenset({"CR", "CB", "FJ", ""}) diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index fc906761..7529b8fa 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -9,8 +9,8 @@ from casework.enrich_court_record import deciding_hearing, end_date, start_date -def _record(reg=None, hearings=(), status=None, parties=()): - return {"court": "special", "number": "079-cr-0151", +def _record(reg=None, hearings=(), status=None, parties=(), number="079-cr-0151"): + return {"court": "special", "number": number, "detail": {"registration_date_ad": reg, "case_status": status}, "hearings": list(hearings), "parties": list(parties)} @@ -436,14 +436,6 @@ def _plan(api, case, **kw): return plan_case(api, case, 'W/"7"', **kw) -def _court_record(number, name, *, nes_id=None, court="special"): - """One court reference, one defendant party, for `_accused_binds` tests.""" - party = {"side": "defendant", "name": name} - if nes_id: - party["nes_id"] = nes_id - return {"court": court, "number": number, "detail": {}, "hearings": [], "parties": [party]} - - def test_a_whole_case_acquittal_labels_every_defendant_acquitted(): assert bind_outcome([_record(hearings=[DECIDED])]) == ACQUITTED @@ -552,8 +544,11 @@ def test_accused_binds_skips_a_non_prosecution_record_but_binds_a_prosecution_on # on THAT literal name string would still pass a fixture using it, for the # wrong reason. Naming it "कुनै व्यक्ति" (an ordinary person's placeholder) # means only a code-based filter can make this record skip. - cr_record = _court_record("079-cr-0151", "कृष्ण प्रसाद यादव", nes_id=YADAV) - oa_record = _court_record("079-oa-0014", "कुनै व्यक्ति") + cr_record = _record(number="079-cr-0151", + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", + "nes_id": YADAV}]) + oa_record = _record(number="079-oa-0014", + parties=[{"side": "defendant", "name": "कुनै व्यक्ति"}]) items, rows, skips = _accused_binds( _SearchApi(), _case(), [cr_record, oa_record], live_prefixes=["person"], run_entities={}, dry_run=True) @@ -568,7 +563,9 @@ def test_accused_binds_binds_the_pre_fy073_no_code_format(): # references in the corpus. A rule spelled "the number must contain # `-CR-`" would misclassify this as an unrecognised code and silently # drop these prosecutions. - record = _court_record("93-068-0194", "सिताराम यादव", nes_id=YADAV) + record = _record(number="93-068-0194", + parties=[{"side": "defendant", "name": "सिताराम यादव", + "nes_id": YADAV}]) items, rows, skips = _accused_binds( _SearchApi(), _case(), [record], live_prefixes=["person"], run_entities={}, dry_run=True) @@ -581,7 +578,8 @@ def test_accused_binds_skips_an_unrecognised_code_not_on_any_documented_skip_lis # "ZZ" is on neither list. It must skip anyway -- an unrecognised code # risks naming an office, and skipping one only costs a bind a later run # recovers, so the allow-list, not a deny-list, is what must gate this. - record = _court_record("079-zz-0001", "कुनै व्यक्ति", nes_id=YADAV) + record = _record(number="079-zz-0001", + parties=[{"side": "defendant", "name": "कुनै व्यक्ति", "nes_id": YADAV}]) items, rows, skips = _accused_binds( _SearchApi(), _case(), [record], live_prefixes=["person"], run_entities={}, dry_run=True) @@ -595,7 +593,8 @@ def test_accused_binds_binds_a_person_named_through_their_firm(): # filter on "एशोसियटस" or "कार्यालय" would drop this real defendant -- # only the code, never the name text, may gate the bind. firm_name = "अनिल गुप्ता एण्ड एशोसियटस का प्रोपराइटर अनिल कुमार गुप्ता" - record = _court_record("079-fj-0001", firm_name, nes_id=YADAV) + record = _record(number="079-fj-0001", + parties=[{"side": "defendant", "name": firm_name, "nes_id": YADAV}]) items, rows, skips = _accused_binds( _SearchApi(), _case(), [record], live_prefixes=["person"], run_entities={}, dry_run=True) From a94c7d9219a3ac7a22868df6dd2b46325cc237dc Mon Sep 17 00:00:00 2001 From: GAURAV Date: Sat, 8 Aug 2026 04:37:25 -0700 Subject: [PATCH 16/29] feat(casework): hold a defendant name that appears on more than one case Two same-name defendants on separate court cases collapsed into one NES entity through the exact-match rung, putting one person's accusation on another's record. defendant_name_index/held_names build a cross-case name index from every selected case's court record; a name in the held set gets a report row and no bind item, while the case's other defendants and dates are unaffected. Also adds the automated _log_plan routing test the non- prosecution skip was still missing. --- casework/enrich_court_record.py | 77 ++++++-- tests/casework/test_enrich_court_record.py | 197 +++++++++++++++++++++ 2 files changed, 258 insertions(+), 16 deletions(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 4772f70f..bcfdd12a 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -452,7 +452,35 @@ def bind_outcome(records): return CHARGED -def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run): +def defendant_name_index(records_by_slug): + """`{normalised name: {slug, ...}}` over every case selected for the run. + + Only records passing `BINDABLE_CODES` feed this index, matching + `_accused_binds`'s own filter -- a ministry named on two `OA` cases must + not consume a review slot for a name that was never a bind candidate. + """ + index = {} + for slug, records in records_by_slug.items(): + for record in records: + if case_number_code(record["number"]) not in BINDABLE_CODES: + continue + for party in record.get("parties") or (): + if not is_defendant(party): + continue + name = party_name(party) + if not name: + continue + index.setdefault(normalise_name(name), set()).add(slug) + return {name: frozenset(slugs) for name, slugs in index.items()} + + +def held_names(index): + """Normalised names appearing on more than one case -- never auto-bound.""" + return {name: slugs for name, slugs in index.items() if len(slugs) > 1} + + +def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, + held=None): """`(items, rows, skips)` -- binds, a report row each, and non-prosecution skips. De-duplicated by name across every court reference on the case, order @@ -462,7 +490,13 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run): here (its `nes_id` for ladder rung 1, its `address` for the run-entity key), which is why this reads the parties itself rather than calling `defendant_names`. + + A name in `held` (see `held_names`) never reaches `resolve_defendant` at + all -- it gets a `how="held"` row and no bind item, because the same-name + collapse `held_names` exists to catch cannot be told apart from a genuine + match by anything this function can see on its own. """ + held = held or {} outcome = bind_outcome(records) citation = (records[0].get("detail") or {}).get("material_id", "") if records else "" items, rows, skips, seen = [], [], [], set() @@ -481,6 +515,17 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run): if not name or name in seen: continue seen.add(name) + other_slugs = held.get(normalise_name(name)) + if other_slugs: + others = sorted(s for s in other_slugs if s != case.get("slug")) + rows.append({ + "slug": case.get("slug"), "name": name, "how": "held", + "nes_id": "", "outcome": outcome, + "reason": ("also names a defendant on " + + (", ".join(others) or "another case") + + " -- held for a human to rule on"), + "court_case": f"{record['court']}/{record['number']}"}) + continue got = resolve_defendant( api, name, party.get("nes_id"), citation=citation, live_prefixes=live_prefixes, run_entities=run_entities, @@ -501,7 +546,7 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run): return items, rows, skips -def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run): +def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run, held=None): """Build the write for one case. Reads the court record; writes nothing.""" slug = case.get("slug") or "" if (case.get("state") or "").upper() != REQUIRED_WRITE_STATE: @@ -540,7 +585,7 @@ def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run): items, rows, accused_skips = _accused_binds( api, case, records, live_prefixes=live_prefixes, - run_entities=run_entities, dry_run=dry_run) + run_entities=run_entities, dry_run=dry_run, held=held) skips.extend(accused_skips) # `current_entity_binds`, NOT the raw `case["entities"]` list: the read @@ -641,20 +686,20 @@ def apply_plan(api, plan): _NON_PROSECUTION_SKIP_PREFIX = "skipping accused bind for " -#: The ladder rung each `plan.rows` entry settled on, spelled for the events -#: file. It rides in the event's DETAIL, not its status: every event this -#: function emits is an INTERMEDIATE step, and `casework.ledger.build_ledger` -#: treats any status outside `NON_OUTCOME_STATUSES` as the case's outcome for -#: the stage. A per-defendant `failed` or a per-case `merged` would therefore be -#: recorded as what this stage DID to the case, which on a dry run is nothing at -#: all -- and `failed` is a real terminal status for `casework.convert`, so it -#: cannot simply be added to that shared frozenset. Every sibling enricher -#: resolves this the same way: intermediate steps report `ok` and put the -#: specifics in `detail` (`step="source", status="ok"`, -#: `step="resolve", status="ok"`), leaving distinctive statuses to the one -#: terminal event per case. +#: The ladder rung -- or hold decision -- each `plan.rows` entry settled on, +#: spelled for the events file. It rides in the event's DETAIL, not its +#: status: every event this function emits is an INTERMEDIATE step, and +#: `casework.ledger.build_ledger` treats any status outside +#: `NON_OUTCOME_STATUSES` as the case's outcome for the stage. A per-defendant +#: `failed` or a per-case `merged` would therefore be recorded as what this +#: stage DID to the case, which on a dry run is nothing at all -- and `failed` +#: is a real terminal status for `casework.convert`, so it cannot simply be +#: added to that shared frozenset. Every sibling enricher resolves this the +#: same way: intermediate steps report `ok` and put the specifics in `detail` +#: (`step="source", status="ok"`, `step="resolve", status="ok"`), leaving +#: distinctive statuses to the one terminal event per case. _RUNG_WORDS = {"nes_id": "nes_id_copied", "exact": "exact_match", - "created": "created", "failed": "failed"} + "created": "created", "failed": "failed", "held": "held"} def _log_plan(logger, events, run_id, plan): diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 7529b8fa..5500f5c4 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -95,11 +95,14 @@ def test_end_date_takes_the_latest_when_every_reference_decided(): from casework.common.api import EntityAlreadyExists # noqa: E402 from casework.entity_identity import entity_slug # noqa: E402 +from casework.entity_resolver import normalise_name # noqa: E402 from casework.enrich_court_record import ( # noqa: E402 PERSON_PREFIX, _accused_binds, _is_person, + defendant_name_index, exact_person_match, + held_names, resolve_defendant, ) from jawafdehi_shared.entities.ids import build_entity_iri # noqa: E402 @@ -602,6 +605,106 @@ def test_accused_binds_binds_a_person_named_through_their_firm(): assert skips == [] +def test_defendant_name_index_groups_by_normalised_name_across_cases(): + # Extra spacing and a trailing danda on case-b's spelling: a wrong + # implementation keyed on raw string equality would put these in two + # separate buckets instead of one, and never hold either. + records_by_slug = { + "case-a": [_record(parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}])], + "case-b": [_record(number="080-cr-0002", + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव।"}])], + } + index = defendant_name_index(records_by_slug) + assert index[normalise_name("कृष्ण प्रसाद यादव")] == frozenset({"case-a", "case-b"}) + + +def test_defendant_name_index_excludes_non_prosecution_records(): + # A ministry named "defendant" on two OA references must not consume a + # review slot: it was never a bind candidate, so it must never surface as + # held either. A wrong implementation that indexes every party regardless + # of case-type code would put this name in the index with two slugs, and + # `held_names` would then flag it. + records_by_slug = { + "case-a": [_record(number="079-oa-0014", + parties=[{"side": "defendant", "name": "कुनै व्यक्ति"}])], + "case-b": [_record(number="080-oa-0002", + parties=[{"side": "defendant", "name": "कुनै व्यक्ति"}])], + } + assert defendant_name_index(records_by_slug) == {} + + +def test_held_names_is_empty_when_every_name_is_on_one_case_only(): + records_by_slug = { + "case-a": [_record(parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}])], + "case-b": [_record(number="080-cr-0002", + parties=[{"side": "defendant", "name": "सिताराम यादव"}])], + } + assert held_names(defendant_name_index(records_by_slug)) == {} + + +def test_held_names_names_the_cases_a_shared_defendant_appears_on(): + records_by_slug = { + "case-a": [_record(parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}])], + "case-b": [_record(number="080-cr-0002", + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}])], + } + held = held_names(defendant_name_index(records_by_slug)) + assert held == {normalise_name("कृष्ण प्रसाद यादव"): frozenset({"case-a", "case-b"})} + + +def test_a_shared_defendant_is_held_on_both_cases_naming_the_other(): + # Both cases must be held, and each one's reason must name the OTHER case, + # not itself -- a wrong implementation that reports the full `held[key]` + # set unfiltered would pass "both held" but also claim case-a appears on + # case-a, which the second half of each assertion below catches. + key = normalise_name("कृष्ण प्रसाद यादव") + held = {key: frozenset({"case-a", "case-b"})} + record = _record(parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}]) + items_a, rows_a, _ = _accused_binds( + _SearchApi(), _case(slug="case-a"), [record], + live_prefixes=["person"], run_entities={}, dry_run=True, held=held) + items_b, rows_b, _ = _accused_binds( + _SearchApi(), _case(slug="case-b"), [record], + live_prefixes=["person"], run_entities={}, dry_run=True, held=held) + assert items_a == [] and items_b == [] + assert rows_a[0]["how"] == "held" and rows_b[0]["how"] == "held" + assert "case-b" in rows_a[0]["reason"] and "case-a" not in rows_a[0]["reason"] + assert "case-a" in rows_b[0]["reason"] and "case-b" not in rows_b[0]["reason"] + + +def test_a_name_held_for_another_name_does_not_hold_this_one(): + # `held` is non-empty, but carries no entry for THIS defendant's name -- a + # wrong implementation that treats "held is non-empty" as "hold everyone + # on this case" would still fail this, since the bound defendant carries a + # real `nes_id` and a bind item only appears when the ladder actually ran. + held = {normalise_name("अर्को व्यक्ति"): frozenset({"case-x", "case-y"})} + record = _record(parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", + "nes_id": YADAV}]) + items, rows, _ = _accused_binds( + _SearchApi(), _case(slug="case-a"), [record], + live_prefixes=["person"], run_entities={}, dry_run=True, held=held) + assert [i["nes_id"] for i in items] == [YADAV] + assert rows[0]["how"] == "nes_id" + + +def test_a_name_spelled_with_different_punctuation_across_cases_still_holds(): + # End to end from raw records through `defendant_name_index`/`held_names` + # into `_accused_binds`: keying on `normalise_name` (the same function + # `exact_person_match` uses) means a spacing/punctuation variant cannot + # slip past the held check the way raw string equality would. + records_by_slug = { + "case-a": [_record(parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}])], + "case-b": [_record(number="080-cr-0002", + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव।"}])], + } + held = held_names(defendant_name_index(records_by_slug)) + items, rows, _ = _accused_binds( + _SearchApi(), _case(slug="case-a"), records_by_slug["case-a"], + live_prefixes=["person"], run_entities={}, dry_run=True, held=held) + assert items == [] + assert rows[0]["how"] == "held" + + def test_a_case_with_only_a_non_prosecution_reference_still_gets_both_dates(): # Guards "dates are not filtered": `plan_case` reads `start_date`/`end_date` # off `records` directly, so the accused-bind filter must live inside @@ -634,6 +737,31 @@ def test_an_existing_bind_survives_untouched(): assert plan.entities is None +def test_a_held_defendant_does_not_block_the_case_s_other_defendants_or_dates(): + # `held` must cost only the name it names: the case's other defendant + # still binds through the ordinary ladder, and the date fields -- which + # `plan_case` derives from `records`, never from `rows` -- still fill. A + # wrong implementation that let a hold short-circuit the whole case (or + # that dropped the held name from `plan.rows` instead of reporting it) + # would fail one of the three assertions below. + key = normalise_name("कृष्ण प्रसाद यादव") + held = {key: frozenset({"case-a", "case-b"})} + api = _PlanApi( + detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}, + {"side": "defendant", "name": "सिताराम यादव", "nes_id": YADAV}], + ) + plan = _plan(api, _case(slug="case-a"), held=held) + assert dict(plan.fields) == {"case_start_date": "2023-06-22", + "case_end_date": "2024-06-04"} + assert plan.entities == [{"nes_id": YADAV, "relationship_type": "accused", + "outcome": ACQUITTED, + "notes": "प्रतिवादी — विशेष अदालत मुद्दा 079-cr-0151"}] + hows = {r["name"]: r["how"] for r in plan.rows} + assert hows["कृष्ण प्रसाद यादव"] == "held" + assert hows["सिताराम यादव"] == "nes_id" + + def test_the_party_row_address_reaches_the_run_entity_key(): # Wiring: `_accused_binds` must pass the court party row's `address` # through to `resolve_defendant`, or the name-plus-address key is dead code @@ -891,6 +1019,75 @@ def get_courtcase(self, court, number, timeout=60): assert {e["status"] for e in court_read + dates} == {"ok"} +def test_a_non_prosecution_court_reference_is_logged_as_bind_plan_not_dates( + tmp_path, monkeypatch, +): + # `_accused_binds` skips a whole non-prosecution record without reading a + # single party, and `_log_plan` routes that skip line under + # `step="bind_plan"` (see `_NON_PROSECUTION_SKIP_PREFIX`), never into the + # `dates`/`no_source` catch-all a genuine date-derivation skip uses. Task 1 + # shipped that routing branch with only a hand-run repro in its report -- + # this is the automated pin the follow-up review asked for, in the same + # style as the court-read-failure test above. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + oa_ref = "https://jawafdehi.org/courtcase/special/079-oa-0014" + case = _case(court_cases=[oa_ref]) + api = _CliApi(case, detail={"registration_date_ad": "2023-06-22"}, + parties=[{"side": "defendant", "name": "कुनै व्यक्ति"}]) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + events = _events(tmp_path) + + bind_plan = [e for e in events if e["step"] == "bind_plan"] + assert any("079-oa-0014" in e["detail"] and "not a prosecution" in e["detail"] + for e in bind_plan) + assert any("skipped as non-prosecution" in e["detail"] for e in bind_plan) + # The skip must not ALSO (or instead) land under `dates`. + dates = [e for e in events if e["step"] == "dates"] + assert not any("079-oa-0014" in e.get("detail", "") for e in dates) + assert {e["status"] for e in bind_plan} == {"ok"} + + +def test_a_held_defendant_is_logged_under_defendant_resolve_not_silently_dropped( + tmp_path, monkeypatch, +): + # `main()` does not build a held set yet -- a later task wires the + # two-pass index (`defendant_name_index`/`held_names`) into it. Until + # then this pins the `_log_plan` routing for a `how="held"` row end to + # end by monkeypatching `plan_case` to inject a held set the same way + # that later task will, rather than only unit-testing `_log_plan` + # directly. Guards `_RUNG_WORDS` carrying a `"held"` entry (its absence + # would raise `KeyError` here, not silently drop the row) and that the + # held defendant never reaches a bind. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + case = _case() + api = _CliApi(case, detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}]) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + held = {normalise_name("कृष्ण प्रसाद यादव"): frozenset({"case-079-cr-0151", "case-b"})} + real_plan_case = ecr.plan_case + monkeypatch.setattr( + ecr, "plan_case", + lambda *a, **kw: real_plan_case(*a, **{**kw, "held": held})) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + events = _events(tmp_path) + resolve_events = [e for e in events if e["step"] == "defendant_resolve"] + assert len(resolve_events) == 1 + assert resolve_events[0]["detail"].startswith("held: कृष्ण प्रसाद यादव -> ") + assert "case-b" in resolve_events[0]["detail"] + assert resolve_events[0]["status"] == "ok" + assert api.posted == [] + assert api.patch_calls == [] + + def test_a_dry_run_leaves_the_case_out_of_the_ledger_entirely(tmp_path, monkeypatch): # Fix 3, proved against a REAL run rather than a hand-written fixture: the # events this CLI actually emits, folded by the real From fc83d00516ca8a4d2e1566e7b2455a5076a5d647 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Sat, 8 Aug 2026 05:00:39 -0700 Subject: [PATCH 17/29] fix(casework): stop counting a held defendant as resolved or bound A held row inflated len(plan.rows) into three consumers that all meant "defendants this run resolved" -- the bind_plan summary, the review file's accused+N, and the nothing-to-do idempotency detail all overcounted by the held row and never mentioned the hold. A nothing-to-do case whose only outstanding item is a held name also logged ledger status "already", which casework.ledger.build_ledger records as a completed outcome; it now logs the distinct held_for_review instead. Also makes held a required keyword (no more held=None silently reopening the same-name collapse), fixes the held-check to membership (is not None) rather than truthiness, keys the per-case defendant dedup on the normalised name, and trims a docstring paragraph and a dead fallback string flagged in review. --- casework/enrich_court_record.py | 83 ++++++++----- tests/casework/test_enrich_court_record.py | 133 ++++++++++++++++++++- 2 files changed, 181 insertions(+), 35 deletions(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index bcfdd12a..0dfd22b4 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -475,12 +475,11 @@ def defendant_name_index(records_by_slug): def held_names(index): - """Normalised names appearing on more than one case -- never auto-bound.""" + """`{normalised name: frozenset(slugs)}` for names on more than one case -- never auto-bound.""" return {name: slugs for name, slugs in index.items() if len(slugs) > 1} -def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, - held=None): +def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, held): """`(items, rows, skips)` -- binds, a report row each, and non-prosecution skips. De-duplicated by name across every court reference on the case, order @@ -491,12 +490,9 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, which is why this reads the parties itself rather than calling `defendant_names`. - A name in `held` (see `held_names`) never reaches `resolve_defendant` at - all -- it gets a `how="held"` row and no bind item, because the same-name - collapse `held_names` exists to catch cannot be told apart from a genuine - match by anything this function can see on its own. + A held name (see `held_names`) is never told apart from a genuine match + by anything this function alone can see. """ - held = held or {} outcome = bind_outcome(records) citation = (records[0].get("detail") or {}).get("material_id", "") if records else "" items, rows, skips, seen = [], [], [], set() @@ -512,17 +508,17 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, if not is_defendant(party): continue name = party_name(party) - if not name or name in seen: + key = normalise_name(name) + if not name or key in seen: continue - seen.add(name) - other_slugs = held.get(normalise_name(name)) - if other_slugs: + seen.add(key) + other_slugs = held.get(key) + if other_slugs is not None: others = sorted(s for s in other_slugs if s != case.get("slug")) rows.append({ "slug": case.get("slug"), "name": name, "how": "held", "nes_id": "", "outcome": outcome, - "reason": ("also names a defendant on " - + (", ".join(others) or "another case") + "reason": ("also names a defendant on " + ", ".join(others) + " -- held for a human to rule on"), "court_case": f"{record['court']}/{record['number']}"}) continue @@ -546,7 +542,7 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, return items, rows, skips -def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run, held=None): +def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run, held): """Build the write for one case. Reads the court record; writes nothing.""" slug = case.get("slug") or "" if (case.get("state") or "").upper() != REQUIRED_WRITE_STATE: @@ -703,10 +699,12 @@ def apply_plan(api, plan): def _log_plan(logger, events, run_id, plan): - """Emit the per-step events for one planned case. + """Emit the per-step events for one planned case. Returns the held count. Every event here is intermediate and therefore `ok`-statused; see `_RUNG_WORDS` for why, and `main` for the terminal events that follow. + The held count is returned so `main` can subtract it from its own + "accused+N" and already-bound counts without recomputing `plan.rows`. `run_id`/`stage`/`slug` are passed as explicit keywords on every call rather than once via a `**common` dict: `ty` cannot verify that a plain @@ -716,7 +714,9 @@ def _log_plan(logger, events, run_id, plan): `enrich_related_entities.py`'s own `log_event` calls use the same explicit-keyword style for the identical reason. """ + held_count = 0 for row in plan.rows: + held_count += row["how"] == "held" log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, step="defendant_resolve", status="ok", detail=f"{_RUNG_WORDS[row['how']]}: {row['name']} -> " @@ -751,13 +751,18 @@ def _log_plan(logger, events, run_id, plan): step="dates", status="ok", detail=f"{kind}: {skip}") # "resolved", not "on the court record": when `code_skips` is non-zero the # court record named at least one defendant this run declined to look at, - # and the earlier count must not be read as "the record named none". + # and the earlier count must not be read as "the record named none". A + # held row sits in `plan.rows` too but was never resolved, so it is + # subtracted out here as well. summary = (f"{'merged' if plan.entities is not None else 'no_additions'}: " - f"{len(plan.rows)} defendant(s) resolved") + f"{len(plan.rows) - held_count} defendant(s) resolved") if code_skips: summary += f"; {code_skips} court reference(s) skipped as non-prosecution" + if held_count: + summary += f"; {held_count} name(s) held for review" log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, step="bind_plan", status="ok", detail=summary) + return held_count def main(argv=None): @@ -792,7 +797,11 @@ def main(argv=None): continue plan = plan_case(api, detail, etag, live_prefixes=live_prefixes, - run_entities=run_entities, dry_run=args.dry_run) + run_entities=run_entities, dry_run=args.dry_run, + # Single-pass `main()`: no cross-case index yet, so + # nothing is held. Task 3 builds the real index over + # every selected case and passes it here instead. + held={}) # `detail=` carries `plan.skips` even on a clean "selected": a case # can reach "would-patch"/"nothing-to-do" with a partially-unreadable # court record (some references 404, at least one did not), and the @@ -814,11 +823,15 @@ def main(argv=None): continue log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, step="court_read", status="ok") - _log_plan(logger, events, run_id, plan) - - generated = "; ".join( - [f"{k}={v}" for k, v in plan.fields] - + [f"accused+{len(plan.rows)}" if plan.entities is not None else ""]).strip("; ") + held_count = _log_plan(logger, events, run_id, plan) + resolved_count = len(plan.rows) - held_count + + generated_parts = [f"{k}={v}" for k, v in plan.fields] + if plan.entities is not None: + generated_parts.append(f"accused+{resolved_count}") + if held_count: + generated_parts.append(f"{held_count} name(s) held for review") + generated = "; ".join(generated_parts) review.add(ReviewRow( slug=slug, status=plan.status, before=(f"case_start_date={detail.get('case_start_date')}, " @@ -833,14 +846,22 @@ def main(argv=None): # entirely, which cannot be told apart from a run that crashed # before reaching it. `already` is the vocabulary every sibling uses # for "the fields were populated before we got here" - # (`step="idempotency", status="already"`), and it is what this is: - # no date field was empty and needed filling, and every bind the - # court record names is already on the case. + # (`step="idempotency", status="already"`), and it is what this is + # -- UNLESS a held name is the reason nothing else happened: that + # case still has a human decision outstanding, so it cannot read + # `already` (a status `casework.ledger.NON_OUTCOME_STATUSES` + # would record as this stage's completed outcome) without the + # audit trail claiming the stage is finished when it isn't. + # `held_for_review` is the honest, distinct status for that case. + detail = ("nothing to add: no empty date this run could fill, " + f"and {resolved_count} court-record defendant(s) are " + "already bound") + if held_count: + detail += f"; {held_count} name(s) held for review" log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, - step="idempotency", status="already", - detail="nothing to add: no empty date this run could " - f"fill, and all {len(plan.rows)} court-record " - "defendant(s) are already bound") + step="idempotency", + status="held_for_review" if held_count else "already", + detail=detail) stats["nothing-to-do"] = stats.get("nothing-to-do", 0) + 1 continue if args.dry_run: diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 5500f5c4..34afb6f3 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -436,6 +436,7 @@ def _plan(api, case, **kw): kw.setdefault("live_prefixes", ["person"]) kw.setdefault("run_entities", {}) kw.setdefault("dry_run", True) + kw.setdefault("held", {}) return plan_case(api, case, 'W/"7"', **kw) @@ -554,7 +555,7 @@ def test_accused_binds_skips_a_non_prosecution_record_but_binds_a_prosecution_on parties=[{"side": "defendant", "name": "कुनै व्यक्ति"}]) items, rows, skips = _accused_binds( _SearchApi(), _case(), [cr_record, oa_record], - live_prefixes=["person"], run_entities={}, dry_run=True) + live_prefixes=["person"], run_entities={}, dry_run=True, held={}) assert [i["nes_id"] for i in items] == [YADAV] assert [r["name"] for r in rows] == ["कृष्ण प्रसाद यादव"] assert len(skips) == 1 @@ -571,7 +572,7 @@ def test_accused_binds_binds_the_pre_fy073_no_code_format(): "nes_id": YADAV}]) items, rows, skips = _accused_binds( _SearchApi(), _case(), [record], - live_prefixes=["person"], run_entities={}, dry_run=True) + live_prefixes=["person"], run_entities={}, dry_run=True, held={}) assert [i["nes_id"] for i in items] == [YADAV] assert skips == [] @@ -585,7 +586,7 @@ def test_accused_binds_skips_an_unrecognised_code_not_on_any_documented_skip_lis parties=[{"side": "defendant", "name": "कुनै व्यक्ति", "nes_id": YADAV}]) items, rows, skips = _accused_binds( _SearchApi(), _case(), [record], - live_prefixes=["person"], run_entities={}, dry_run=True) + live_prefixes=["person"], run_entities={}, dry_run=True, held={}) assert items == [] assert len(skips) == 1 and "ZZ" in skips[0] @@ -600,7 +601,7 @@ def test_accused_binds_binds_a_person_named_through_their_firm(): parties=[{"side": "defendant", "name": firm_name, "nes_id": YADAV}]) items, rows, skips = _accused_binds( _SearchApi(), _case(), [record], - live_prefixes=["person"], run_entities={}, dry_run=True) + live_prefixes=["person"], run_entities={}, dry_run=True, held={}) assert [i["nes_id"] for i in items] == [YADAV] assert skips == [] @@ -705,6 +706,40 @@ def test_a_name_spelled_with_different_punctuation_across_cases_still_holds(): assert rows[0]["how"] == "held" +def test_two_punctuation_variants_of_one_name_on_the_same_case_collapse_to_one_row(): + # `seen` used to key on the raw name, so two spellings of the SAME + # defendant on one case's parties produced two `defendant_resolve` rows + # (and could double-bind the same person under two different IRIs) for + # one person. `defendant_name_index` already collapses spelling variants + # via `normalise_name`; the per-case dedup inside `_accused_binds` must + # agree, or a case can hold one spelling while binding the other. + record = _record(parties=[ + {"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}, + {"side": "defendant", "name": "कृष्ण प्रसाद यादव।"}, + ]) + items, rows, _ = _accused_binds( + _SearchApi(), _case(), [record], + live_prefixes=["person"], run_entities={}, dry_run=True, held={}) + assert len(rows) == 1 + assert [i["nes_id"] for i in items] == [YADAV] + + +def test_a_held_entry_mapping_to_an_empty_set_still_holds(): + # `held_names` only ever returns entries with 2+ slugs, so an empty set + # should not occur in practice -- but the membership check must be + # `is not None`, not truthiness. A truthy check on an empty frozenset + # falls through and binds, which is the fail-OPEN direction on exactly + # the defamation path this task exists to close. + held = {normalise_name("कृष्ण प्रसाद यादव"): frozenset()} + record = _record(parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", + "nes_id": YADAV}]) + items, rows, _ = _accused_binds( + _SearchApi(), _case(), [record], + live_prefixes=["person"], run_entities={}, dry_run=True, held=held) + assert items == [] + assert rows[0]["how"] == "held" + + def test_a_case_with_only_a_non_prosecution_reference_still_gets_both_dates(): # Guards "dates are not filtered": `plan_case` reads `start_date`/`end_date` # off `records` directly, so the accused-bind filter must live inside @@ -834,6 +869,25 @@ def test_apply_plan_refuses_to_write_without_an_etag(): apply_plan(_PlanApi(), plan) +def test_accused_binds_requires_held_explicitly(): + # No default: a caller that forgets `held` must get a loud `TypeError`, + # not a silent "nothing is held" that reintroduces the same-name collapse + # this task exists to stop -- the failure mode on this path is naming the + # wrong person as accused, so a forgotten argument must fail LOUD, not open. + with pytest.raises(TypeError): + _accused_binds( # ty: ignore[missing-argument] -- the point of this test + _SearchApi(), _case(), [], + live_prefixes=["person"], run_entities={}, dry_run=True) + + +def test_plan_case_requires_held_explicitly(): + with pytest.raises(TypeError): + plan_case( # ty: ignore[missing-argument] -- the point of this test + _PlanApi(detail={"registration_date_ad": "2023-06-22"}), + _case(), 'W/"7"', + live_prefixes=["person"], run_entities={}, dry_run=True) + + def test_apply_plan_sends_one_conditional_request(): seen = {} @@ -1088,6 +1142,77 @@ def test_a_held_defendant_is_logged_under_defendant_resolve_not_silently_dropped assert api.patch_calls == [] +def test_a_held_defendant_is_excluded_from_resolved_and_accused_counts(tmp_path, monkeypatch): + # Reviewer repro: one held name plus one `nes_id`-bound defendant, dates + # already populated. Before this fix `len(plan.rows)` counted the held + # row as "resolved" and as part of "accused+N" too -- the bind_plan + # summary read "2 defendant(s) resolved" and the review file's Generated + # field read "accused+2" for a plan that only ever bound one person. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + case = _case(case_start_date="2020-01-01", case_end_date="2021-01-01") + api = _CliApi( + case, detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}, + {"side": "defendant", "name": "सिताराम यादव", "nes_id": YADAV}]) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + held = {normalise_name("कृष्ण प्रसाद यादव"): frozenset({"case-079-cr-0151", "case-b"})} + real_plan_case = ecr.plan_case + monkeypatch.setattr( + ecr, "plan_case", + lambda *a, **kw: real_plan_case(*a, **{**kw, "held": held})) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + + bind_plan = [e for e in _events(tmp_path) if e["step"] == "bind_plan"] + assert any("1 defendant(s) resolved" in e["detail"] for e in bind_plan) + assert not any("2 defendant(s) resolved" in e["detail"] for e in bind_plan) + assert any("1 name(s) held for review" in e["detail"] for e in bind_plan) + + review_text = (tmp_path / "review.md").read_text(encoding="utf-8") + assert "accused+1" in review_text + assert "accused+2" not in review_text + assert "1 name(s) held for review" in review_text + + +def test_a_held_only_nothing_to_do_case_is_not_recorded_as_already(tmp_path, monkeypatch): + # The companion to `test_a_case_with_nothing_to_change_records_already_not_nothing`: + # when the ONLY reason a case reaches "nothing-to-do" is a held name, the + # stage's own work is not finished -- a human still has to rule on it. + # `already` is excluded from nothing here: `casework.ledger.NON_OUTCOME_STATUSES` + # does not contain it, so `build_ledger` would otherwise record this + # stage as a COMPLETED outcome for a case whose whole point is that it + # isn't. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + case = _case(case_start_date="2020-01-01", case_end_date="2021-01-01") + api = _CliApi(case, detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}]) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + held = {normalise_name("कृष्ण प्रसाद यादव"): frozenset({"case-079-cr-0151", "case-b"})} + real_plan_case = ecr.plan_case + monkeypatch.setattr( + ecr, "plan_case", + lambda *a, **kw: real_plan_case(*a, **{**kw, "held": held})) + + assert main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) == 0 + assert api.patch_calls == [] + + idempotency = [e for e in _events(tmp_path) if e["step"] == "idempotency"] + assert len(idempotency) == 1 + assert idempotency[0]["status"] == "held_for_review" + assert "1 name(s) held for review" in idempotency[0]["detail"] + assert "0 court-record defendant(s) are already bound" in idempotency[0]["detail"] + + from casework.ledger import build_ledger + status = build_ledger(tmp_path)[("case-079-cr-0151", "court_record")]["status"] + assert status == "held_for_review" + assert status != "already" + + def test_a_dry_run_leaves_the_case_out_of_the_ledger_entirely(tmp_path, monkeypatch): # Fix 3, proved against a REAL run rather than a hand-written fixture: the # events this CLI actually emits, folded by the real From b645f25b6ae9092d3a12fc0b9fa56bad1cf151e5 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Sat, 8 Aug 2026 05:20:20 -0700 Subject: [PATCH 18/29] feat(casework): wire the held-name index into main, and fix the review status main() split into two passes: pass 1 reads every selected case's court record and builds the cross-case held-name index once, pass 2 re-reads each case for a fresh ETag and plans it against that same index -- closing the gap where held={} left the Task 2 hold inert in a real run. Ships a held-names JSON file beside the review file for a human to rule on. Also fixes review rows always reading would-patch regardless of the case's actual outcome, and renames a shadowed `detail` variable flagged in the Task 2 review --- casework/enrich_court_record.py | 136 ++++++++++--- tests/casework/test_enrich_court_record.py | 217 +++++++++++++++++++++ 2 files changed, 331 insertions(+), 22 deletions(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 0dfd22b4..b1060e5d 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -54,6 +54,7 @@ """ import argparse +import json import logging import sys import time @@ -542,8 +543,14 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, return items, rows, skips -def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run, held): - """Build the write for one case. Reads the court record; writes nothing.""" +def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run, held, + court_record=None): + """Build the write for one case; writes nothing. + + Reads the court record itself unless `court_record` -- a pass-1 + `(records, skips)` pair -- is supplied, so a run planning many cases can + read every court reference once and reuse it here. + """ slug = case.get("slug") or "" if (case.get("state") or "").upper() != REQUIRED_WRITE_STATE: return CasePlan(slug, "skip-state", @@ -564,7 +571,10 @@ def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run, held): "is not empty; refusing to plan a write from " "an incomplete read"]) - records, skips = court_record_for_case(api, case) + if court_record is not None: + records, skips = court_record + else: + records, skips = court_record_for_case(api, case) if not records: return CasePlan(slug, "no-court-reference", skips=skips) @@ -765,6 +775,47 @@ def _log_plan(logger, events, run_id, plan): return held_count +def _held_report(held, court_records): + """One entry per held name: the cases it appears on and the defendant + rows behind it, so a human can rule on all of them without re-reading + the court record themselves. + + Recomputed from `court_records` (the pass-1 cache) rather than collected + from `plan.rows` during pass 2, so a case that never reaches + `_accused_binds` -- wrong state, no `entities` key -- still shows up + here. The same `BINDABLE_CODES`/`is_defendant`/`party_name` filter as + `defendant_name_index` applies, so this cannot list a row that index + itself would have excluded. + """ + report = [] + for name, slugs in sorted(held.items()): + rows = [] + for slug in sorted(slugs): + records, _ = court_records.get(slug, ([], [])) + for record in records: + if case_number_code(record["number"]) not in BINDABLE_CODES: + continue + for party in record.get("parties") or (): + if not is_defendant(party): + continue + if normalise_name(party_name(party)) != name: + continue + rows.append({"slug": slug, + "court_case": f"{record['court']}/{record['number']}", + "name": party_name(party)}) + report.append({"name": name, "cases": sorted(slugs), "rows": rows}) + return report + + +def write_held_file(path, held, court_records, *, run_id): + """Write the held-names file: every cross-case name a human must rule + on before it can be bound. Devanagari unescaped, matching every other + casework output file.""" + payload = {"run_id": run_id, "held": _held_report(held, court_records)} + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + def main(argv=None): parser = add_common_args(argparse.ArgumentParser( description="Bind court-record defendants and fill the case date fields.")) @@ -785,10 +836,39 @@ def main(argv=None): live_prefixes = read_live_prefixes(api) run_entities, stats = {}, {} + # Pass 1: read every selected case's court record before planning any of + # them, so the held index sees every defendant in the run. A per-case + # index would hold a name on case A and bind it on case B -- the exact + # collapse this two-pass split exists to close (see the module + # docstring). A read failure here costs only that case: it is dropped + # from `readable_cases` and never reaches pass 2, the same way a pass-2 + # read failure below drops a case from the write loop. + court_records, readable_cases = {}, [] for case in cases: slug = case.get("slug") or "" try: - detail, etag = api.get_case_with_etag(slug) + case_detail, _ = api.get_case_with_etag(slug) + except Exception as exc: # noqa: BLE001 - one case's read failure is not the run's + log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, + step="court_read", status="unreadable", + detail=f"pass 1: {type(exc).__name__}") + stats["error"] = stats.get("error", 0) + 1 + continue + readable_cases.append(case) + court_records[slug] = court_record_for_case(api, case_detail) + + held = held_names(defendant_name_index( + {slug: records for slug, (records, _) in court_records.items()})) + + # Pass 2: plan (and maybe write) every case against that SAME `held` + # mapping. `get_case_with_etag` is re-read here for a FRESH ETag -- + # pass 1's is stale by the time this write would land, and a stale + # If-Match raises 412. The court record itself does not need a second + # read: it is cached from pass 1 and passed straight into `plan_case`. + for case in readable_cases: + slug = case.get("slug") or "" + try: + case_detail, etag = api.get_case_with_etag(slug) except Exception as exc: # noqa: BLE001 - one case's read failure is not the run's log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, step="court_read", status="unreadable", @@ -796,12 +876,9 @@ def main(argv=None): stats["error"] = stats.get("error", 0) + 1 continue - plan = plan_case(api, detail, etag, live_prefixes=live_prefixes, + plan = plan_case(api, case_detail, etag, live_prefixes=live_prefixes, run_entities=run_entities, dry_run=args.dry_run, - # Single-pass `main()`: no cross-case index yet, so - # nothing is held. Task 3 builds the real index over - # every selected case and passes it here instead. - held={}) + held=held, court_record=court_records.get(slug)) # `detail=` carries `plan.skips` even on a clean "selected": a case # can reach "would-patch"/"nothing-to-do" with a partially-unreadable # court record (some references 404, at least one did not), and the @@ -832,14 +909,16 @@ def main(argv=None): if held_count: generated_parts.append(f"{held_count} name(s) held for review") generated = "; ".join(generated_parts) - review.add(ReviewRow( - slug=slug, status=plan.status, - before=(f"case_start_date={detail.get('case_start_date')}, " - f"case_end_date={detail.get('case_end_date')}, " - f"{len(detail.get('entities') or [])} bind(s)"), - generated=generated, - note="; ".join(plan.skips))) - + before = (f"case_start_date={case_detail.get('case_start_date')}, " + f"case_end_date={case_detail.get('case_end_date')}, " + f"{len(case_detail.get('entities') or [])} bind(s)") + note = "; ".join(plan.skips) + + # The review row is added in EACH terminal branch below, carrying the + # status the case actually reached -- never `plan.status` (always + # "would-patch" here) written before the write was attempted. That + # was the bug: every row read `would-patch` even under `Mode: + # APPLIED`, because `review.add` used to run before `apply_plan`. if plan.status == "nothing-to-do": # The one TERMINAL event this path gets. Without it the case ends on # `ok`-statused intermediates only and vanishes from the ledger @@ -853,20 +932,25 @@ def main(argv=None): # would record as this stage's completed outcome) without the # audit trail claiming the stage is finished when it isn't. # `held_for_review` is the honest, distinct status for that case. - detail = ("nothing to add: no empty date this run could fill, " - f"and {resolved_count} court-record defendant(s) are " - "already bound") + nothing_to_do_note = ( + "nothing to add: no empty date this run could fill, " + f"and {resolved_count} court-record defendant(s) are " + "already bound") if held_count: - detail += f"; {held_count} name(s) held for review" + nothing_to_do_note += f"; {held_count} name(s) held for review" log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, step="idempotency", status="held_for_review" if held_count else "already", - detail=detail) + detail=nothing_to_do_note) + review.add(ReviewRow(slug=slug, status="nothing-to-do", before=before, + generated=generated, note=note)) stats["nothing-to-do"] = stats.get("nothing-to-do", 0) + 1 continue if args.dry_run: log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, step="patch", status="dry_run", detail=generated) + review.add(ReviewRow(slug=slug, status="would-patch", before=before, + generated=generated, note=note)) stats["would-patch"] = stats.get("would-patch", 0) + 1 continue try: @@ -884,16 +968,24 @@ def main(argv=None): log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, step="patch", status=status, detail=f"{type(exc).__name__}: {exc}") + review.add(ReviewRow(slug=slug, status=status, before=before, + generated=generated, note=note)) stats["error"] = stats.get("error", 0) + 1 continue log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, step="patch", status="applied", detail=generated) + review.add(ReviewRow(slug=slug, status="patched", before=before, + generated=generated, note=note)) stats["patched"] = stats.get("patched", 0) + 1 + held_path = review.path.parent / (review.path.stem + ".held.json") + write_held_file(held_path, held, court_records, run_id=run_id) + review.write() log_run_footer(logger, stage=STAGE, stats=stats, duration_s=time.time() - started) print_summary(stats, args.dry_run, "court-record binder") print(f"review file: {review.path}") + print(f"held-names file: {held_path}") return 0 diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 34afb6f3..fa2f44c6 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -1364,6 +1364,223 @@ def test_a_slug_containing_412_does_not_mislabel_a_missing_etag_as_a_conflict( assert patch_events[0]["status"] == "rejected" +class _MultiCaseApi(_CliApi): + """`_CliApi` serving several cases in one run, looked up by slug for the + case detail and by court case NUMBER for the court record -- the + two-pass / held-file tests below need more than the one canned case + `_CliApi` alone can serve. + """ + + def __init__(self, cases, courtcase_data, *, etag='W/"7"', patch_error=None, + fail_slugs=(), **kw): + super().__init__(cases[0], etag=etag, patch_error=patch_error, **kw) + self._cases = {c["slug"]: c for c in cases} + self._courtcase_data = courtcase_data + self._fail_slugs = set(fail_slugs) + + def iter_cases(self, params=None, timeout=60, progress=None): + yield from self._cases.values() + + def get_case_with_etag(self, slug, timeout=60): + if slug in self._fail_slugs: + raise urllib.error.HTTPError( + "https://jawafdehi.org", 500, "Internal Server Error", {}, None) + return self._cases[slug], self._etag + + def get_courtcase(self, court, number, timeout=60): + return self._courtcase_data[number].get("detail", {}) + + def list_hearings(self, court, number, timeout=60): + return self._courtcase_data[number].get("hearings", []) + + def get_court_case_entities(self, court, number, timeout=60): + return self._courtcase_data[number].get("parties", []) + + +def test_a_pass_1_read_failure_on_one_case_does_not_stop_the_run(tmp_path, monkeypatch): + # `case-bad`'s pass-1 `get_case_with_etag` raises. A wrong implementation + # that let this propagate would crash `main()` before any case is + # planned; one that caught it but stopped the pass-1 loop entirely (a + # `return` where a `continue` belongs) would leave `case-good` never + # planned either -- checked here by requiring `case-good` to actually + # reach a `patch` event, not just that `main()` returns 0. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + good = _case(slug="case-good", + court_cases=["https://jawafdehi.org/courtcase/special/079-cr-0200"]) + bad = _case(slug="case-bad", + court_cases=["https://jawafdehi.org/courtcase/special/079-cr-0201"]) + api = _MultiCaseApi( + [bad, good], + {"079-cr-0200": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": "कृष्ण प्रसाद यादव", + "nes_id": YADAV}]}}, + fail_slugs=["case-bad"]) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + + events = _events(tmp_path) + bad_events = [e for e in events if e["slug"] == "case-bad"] + assert len(bad_events) == 1 + assert bad_events[0]["step"] == "court_read" + assert bad_events[0]["status"] == "unreadable" + + good_events = [e for e in events if e["slug"] == "case-good"] + assert any(e["step"] == "patch" for e in good_events) + + +def test_main_holds_the_same_defendant_on_every_case_it_appears_on(tmp_path, monkeypatch): + # The one property a previous reviewer flagged as unverifiable: every + # case in a run must be planned against the SAME `held` mapping, built + # from every selected case before any of them is planned. A wrong + # implementation that built the index incrementally case-by-case (or + # otherwise let case-a plan against a partial index) would see nothing + # to hold when case-a is planned first, since case-b's occurrence of the + # name has not been read yet -- so case-a would bind it, and only case-b + # would come back "held". Both must come back "held", naming each other. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + ref_a = "https://jawafdehi.org/courtcase/special/079-cr-0151" + ref_b = "https://jawafdehi.org/courtcase/special/080-cr-0002" + case_a = _case(slug="case-a", court_cases=[ref_a]) + case_b = _case(slug="case-b", court_cases=[ref_b]) + shared_party = [{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}] + api = _MultiCaseApi( + [case_a, case_b], + {"079-cr-0151": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], "parties": shared_party}, + "080-cr-0002": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], "parties": shared_party}}) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + + resolve = {e["slug"]: e for e in _events(tmp_path) if e["step"] == "defendant_resolve"} + assert resolve["case-a"]["detail"].startswith("held: कृष्ण प्रसाद यादव -> ") + assert resolve["case-b"]["detail"].startswith("held: कृष्ण प्रसाद यादव -> ") + assert "case-b" in resolve["case-a"]["detail"] + assert "case-a" in resolve["case-b"]["detail"] + + +def test_the_held_file_lists_a_two_case_name_and_omits_a_one_case_name( + tmp_path, monkeypatch, +): + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + ref_a = "https://jawafdehi.org/courtcase/special/079-cr-0151" + ref_b = "https://jawafdehi.org/courtcase/special/080-cr-0002" + case_a = _case(slug="case-a", court_cases=[ref_a]) + case_b = _case(slug="case-b", court_cases=[ref_b]) + shared_name = "कृष्ण प्रसाद यादव" + solo_name = "सिताराम यादव" + api = _MultiCaseApi( + [case_a, case_b], + {"079-cr-0151": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": shared_name}, + {"side": "defendant", "name": solo_name, + "nes_id": YADAV}]}, + "080-cr-0002": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": shared_name}]}}) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + review_path = tmp_path / "review.md" + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(review_path)]) + assert rc == 0 + + held_path = tmp_path / "review.held.json" + assert held_path.exists() + payload = json.loads(held_path.read_text(encoding="utf-8")) + by_name = {entry["name"]: entry for entry in payload["held"]} + + shared_key = normalise_name(shared_name) + assert shared_key in by_name + assert set(by_name[shared_key]["cases"]) == {"case-a", "case-b"} + assert {r["slug"] for r in by_name[shared_key]["rows"]} == {"case-a", "case-b"} + + # A name on only ONE case must not appear at all -- a wrong + # implementation that wrote every held-index candidate regardless of + # multiplicity would still pass the assertions above but fail this one. + assert normalise_name(solo_name) not in by_name + + +def test_an_applied_runs_review_row_reads_patched(tmp_path, monkeypatch): + # Reviewer-and-smoke-test-found bug: `review.add` used to run before the + # write was attempted, so this row read `would-patch` even under `Mode: + # APPLIED`. A fix that keeps reading `plan.status` (always "would-patch" + # on this path) instead of the terminal branch's own outcome would still + # fail this. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + api = _CliApi( + _case(), + detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}], + ) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + review_path = tmp_path / "review.md" + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--apply", + "--review-file", str(review_path)]) + assert rc == 0 + text = review_path.read_text(encoding="utf-8") + assert "| 1 | `case-079-cr-0151` | patched |" in text + assert "would-patch" not in text + + +def test_a_dry_runs_review_row_still_reads_would_patch(tmp_path, monkeypatch): + # The companion: a dry run must NOT be relabelled `patched` by whatever + # fixes the test above -- a wrong fix that hardcodes "patched" for every + # would-patch plan would fail this one instead. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + api = _CliApi( + _case(), + detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}], + ) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + review_path = tmp_path / "review.md" + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(review_path)]) + assert rc == 0 + text = review_path.read_text(encoding="utf-8") + assert "| 1 | `case-079-cr-0151` | would-patch |" in text + + +def test_a_failed_patchs_review_row_reads_the_failure_status(tmp_path, monkeypatch): + # A 412 must read `etag_conflict` in the review file, not `would-patch` + # -- an operator skimming the review file needs to see the write never + # landed. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + conflict = urllib.error.HTTPError( + "https://jawafdehi.org/api/cases/case-079-cr-0151/", 412, + "Precondition Failed", {}, None) + api = _CliApi( + _case(), patch_error=conflict, + detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}], + ) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + review_path = tmp_path / "review.md" + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--apply", + "--review-file", str(review_path)]) + assert rc == 0 + text = review_path.read_text(encoding="utf-8") + assert "| 1 | `case-079-cr-0151` | etag_conflict |" in text + + def test_the_module_imports_without_django(tmp_path): """The standalone constraint, pinned deterministically. From 8b2936bd1e3c32b7a3517b1633b702de68b392aa Mon Sep 17 00:00:00 2001 From: GAURAV Date: Sat, 8 Aug 2026 05:42:48 -0700 Subject: [PATCH 19/29] fix(casework): create the held file's directory, and surface a shrunk index write_held_file crashed after the PATCH landed whenever the review directory didn't exist yet, since main() called it before review.write() -- the only thing that ever created it -- losing the review file, the held file, and the run footer. Also logs a run-level held_index event (cases selected vs readable vs names indexed vs held) so a pass-1 read failure or a narrowed selection (--limit/--slug/--court-case/--batch-csv) is visible instead of silently weakening the hold, pins get_case_with_etag/get_courtcase call counts, and rewires the three held-behavior tests onto the real two-pass index instead of a monkeypatched one --- casework/enrich_court_record.py | 93 ++++--- tests/casework/test_enrich_court_record.py | 279 +++++++++++++++++---- 2 files changed, 300 insertions(+), 72 deletions(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index b1060e5d..6ee2c2ce 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -49,6 +49,14 @@ plain "Accused" label. `charged` is true by construction everywhere else: every case in this corpus is a Special Court `-CR-` case, so CIAA filed a charge sheet. +THE HOLD IS SCOPED TO ONE RUN. `held_names` only sees a name on 2+ cases +INSIDE the current selection, so `--limit`, `--slug`, `--court-case`, or a +`--batch-csv` split can each shrink that selection below the cases sharing a +name -- silently disabling the hold for exactly the pair it exists to catch. +The run log states the index's scope (`step="held_index"`) and warns when the +selection looks narrowed; an unrestricted or fiscal-year-scoped sweep is what +actually makes the hold protective. + Usage: uv run python -m casework.enrich_court_record --dry-run --verbose """ @@ -776,22 +784,22 @@ def _log_plan(logger, events, run_id, plan): def _held_report(held, court_records): - """One entry per held name: the cases it appears on and the defendant - rows behind it, so a human can rule on all of them without re-reading - the court record themselves. + """One entry per held name: its cases and the defendant rows behind it. Recomputed from `court_records` (the pass-1 cache) rather than collected - from `plan.rows` during pass 2, so a case that never reaches - `_accused_binds` -- wrong state, no `entities` key -- still shows up - here. The same `BINDABLE_CODES`/`is_defendant`/`party_name` filter as - `defendant_name_index` applies, so this cannot list a row that index - itself would have excluded. + from `plan.rows` -- a case that never reaches `_accused_binds` (wrong + state, no `entities` key) still needs to show up here, and re-applies + `defendant_name_index`'s own `BINDABLE_CODES` filter since `court_records` + holds every reference read, not just the bindable ones. """ report = [] for name, slugs in sorted(held.items()): rows = [] for slug in sorted(slugs): - records, _ = court_records.get(slug, ([], [])) + # `slug` came from `held`, which was built from `court_records` + # itself (see `main`) -- a miss here is a real bug, not a case + # this function should quietly render as having no rows. + records, _ = court_records[slug] for record in records: if case_number_code(record["number"]) not in BINDABLE_CODES: continue @@ -808,9 +816,12 @@ def _held_report(held, court_records): def write_held_file(path, held, court_records, *, run_id): - """Write the held-names file: every cross-case name a human must rule - on before it can be bound. Devanagari unescaped, matching every other - casework output file.""" + """Write the held-names file beside the review file. + + Devanagari unescaped (`ensure_ascii=False`), matching every other + casework output file -- an escaped `\\u0915` cannot be reviewed. + """ + path.parent.mkdir(parents=True, exist_ok=True) payload = {"run_id": run_id, "held": _held_report(held, court_records)} path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") return path @@ -837,14 +848,12 @@ def main(argv=None): run_entities, stats = {}, {} # Pass 1: read every selected case's court record before planning any of - # them, so the held index sees every defendant in the run. A per-case - # index would hold a name on case A and bind it on case B -- the exact - # collapse this two-pass split exists to close (see the module - # docstring). A read failure here costs only that case: it is dropped - # from `readable_cases` and never reaches pass 2, the same way a pass-2 - # read failure below drops a case from the write loop. + # them, so the held index sees every defendant in the run -- a per-case + # index would hold a name on case A and bind it on case B, the exact + # collapse this split exists to close. A read failure here costs only + # that case; it is dropped from `readable_cases` before pass 2 runs. court_records, readable_cases = {}, [] - for case in cases: + for i, case in enumerate(cases, 1): slug = case.get("slug") or "" try: case_detail, _ = api.get_case_with_etag(slug) @@ -856,9 +865,33 @@ def main(argv=None): continue readable_cases.append(case) court_records[slug] = court_record_for_case(api, case_detail) - - held = held_names(defendant_name_index( - {slug: records for slug, (records, _) in court_records.items()})) + # Pass 1 pays one HTTP round trip per case before any write happens, + # so a full-corpus run is silent for hours without this -- the same + # reason `CaseworkApi.iter_cases` narrates its own page fetches. + logger.info("pass 1: read %d/%d selected cases (%s)", i, len(cases), slug) + + name_index = defendant_name_index( + {slug: records for slug, (records, _) in court_records.items()}) + held = held_names(name_index) + + # A pass-1 failure shrinks the index by removing that case's defendants + # from it entirely -- not just that case's own hold coverage, but any + # OTHER case's protection for a name the two would have shared. There is + # no in-run fix for that (the case's own data is simply unread), so this + # is the visibility half: an operator can see readable < selected instead + # of inferring it from `error=N` in the footer. `--limit`/`--slug`/ + # `--court-case`/`--batch-csv` narrow the SAME index deliberately, so the + # warning below applies regardless of why the count is small. + narrowed = bool(args.limit or args.slug or args.court_case or args.batch_csv) + index_detail = (f"selected={len(cases)}, readable={len(readable_cases)}, " + f"names_in_index={len(name_index)}, held={len(held)}") + if narrowed: + index_detail += ("; WARNING: selection narrowed by --limit/--slug/" + "--court-case/--batch-csv -- the held index only " + "covers this run's cases, so a name shared with a " + "case OUTSIDE this selection will not be held") + log_event(logger, events, run_id=run_id, stage=STAGE, slug="", + step="held_index", status="ok", detail=index_detail) # Pass 2: plan (and maybe write) every case against that SAME `held` # mapping. `get_case_with_etag` is re-read here for a FRESH ETag -- @@ -876,9 +909,14 @@ def main(argv=None): stats["error"] = stats.get("error", 0) + 1 continue + # `court_records[slug]`, never `.get`: every slug in `readable_cases` + # was inserted into `court_records` in the same pass-1 iteration, so + # a miss here is a real bug -- falling back to `.get(slug)` would + # silently re-read a fresh, un-indexed court record and plan off it + # with no hold protection at all. plan = plan_case(api, case_detail, etag, live_prefixes=live_prefixes, run_entities=run_entities, dry_run=args.dry_run, - held=held, court_record=court_records.get(slug)) + held=held, court_record=court_records[slug]) # `detail=` carries `plan.skips` even on a clean "selected": a case # can reach "would-patch"/"nothing-to-do" with a partially-unreadable # court record (some references 404, at least one did not), and the @@ -914,11 +952,10 @@ def main(argv=None): f"{len(case_detail.get('entities') or [])} bind(s)") note = "; ".join(plan.skips) - # The review row is added in EACH terminal branch below, carrying the - # status the case actually reached -- never `plan.status` (always - # "would-patch" here) written before the write was attempted. That - # was the bug: every row read `would-patch` even under `Mode: - # APPLIED`, because `review.add` used to run before `apply_plan`. + # Recorded in EACH terminal branch below with that branch's own + # outcome, not `plan.status` -- which stays "would-patch" here + # regardless of whether the case is applied, held back by + # --dry-run, or rejected. if plan.status == "nothing-to-do": # The one TERMINAL event this path gets. Without it the case ends on # `ok`-statused intermediates only and vanishes from the ledger diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index fa2f44c6..0edef73d 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -919,13 +919,25 @@ def __init__(self, case, *, etag='W/"7"', patch_error=None, **kw): self._etag = etag self._patch_error = patch_error self.patch_calls = [] + # Pins the two load-bearing call counts the two-pass split promises: + # `get_case_with_etag` runs once per pass (a fresh ETag each time), + # `get_courtcase` only in pass 1 (pass 2 reuses the cache). + self.call_counts = {} + + def _count(self, name): + self.call_counts[name] = self.call_counts.get(name, 0) + 1 def iter_cases(self, params=None, timeout=60, progress=None): yield self._case def get_case_with_etag(self, slug, timeout=60): + self._count("get_case_with_etag") return self._case, self._etag + def get_courtcase(self, court, number, timeout=60): + self._count("get_courtcase") + return super().get_courtcase(court, number, timeout=timeout) + def entity_prefixes(self, timeout=60): return ["person"] @@ -996,7 +1008,10 @@ def test_a_case_missing_the_entities_key_is_skipped_and_logged(tmp_path, monkeyp rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", "--review-file", str(tmp_path / "review.md")]) assert rc == 0 - events = _events(tmp_path) + # Filtered to this case's own slug: the run also emits one run-level + # `held_index` event (slug="") for every run, which is not this case's + # concern. + events = [e for e in _events(tmp_path) if e["slug"] == "case-079-cr-0151"] assert [e["step"] for e in events] == ["select"] assert events[0]["status"] == "skip_no_entities_key" # The ONE line this case leaves must say WHY, not just THAT -- an operator @@ -1024,7 +1039,7 @@ def test_a_non_draft_case_is_skipped_with_the_state_in_the_detail(tmp_path, monk "--slug", "case-079-cr-0151", "--review-file", str(tmp_path / "review.md")]) assert rc == 0 - events = _events(tmp_path) + events = [e for e in _events(tmp_path) if e["slug"] == "case-079-cr-0151"] assert [e["step"] for e in events] == ["select"] assert events[0]["status"] == "skip_state" assert "PUBLISHED" in events[0]["detail"] @@ -1109,31 +1124,32 @@ def test_a_non_prosecution_court_reference_is_logged_as_bind_plan_not_dates( def test_a_held_defendant_is_logged_under_defendant_resolve_not_silently_dropped( tmp_path, monkeypatch, ): - # `main()` does not build a held set yet -- a later task wires the - # two-pass index (`defendant_name_index`/`held_names`) into it. Until - # then this pins the `_log_plan` routing for a `how="held"` row end to - # end by monkeypatching `plan_case` to inject a held set the same way - # that later task will, rather than only unit-testing `_log_plan` - # directly. Guards `_RUNG_WORDS` carrying a `"held"` entry (its absence + # Real two-pass wiring: `case-b` names the same defendant, so `held` is + # the genuine cross-case index `main()` builds, not an injected + # stand-in. Guards `_RUNG_WORDS` carrying a `"held"` entry (its absence # would raise `KeyError` here, not silently drop the row) and that the # held defendant never reaches a bind. monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) - case = _case() - api = _CliApi(case, detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], - parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}]) + shared = "कृष्ण प्रसाद यादव" + case_a = _case() + case_b = _case(slug="case-b", + court_cases=["https://jawafdehi.org/courtcase/special/080-cr-0002"]) + api = _MultiCaseApi( + [case_a, case_b], + {"079-cr-0151": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": shared}]}, + "080-cr-0002": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": shared}]}}) import casework.enrich_court_record as ecr monkeypatch.setattr(ecr, "build_api", lambda args: api) - held = {normalise_name("कृष्ण प्रसाद यादव"): frozenset({"case-079-cr-0151", "case-b"})} - real_plan_case = ecr.plan_case - monkeypatch.setattr( - ecr, "plan_case", - lambda *a, **kw: real_plan_case(*a, **{**kw, "held": held})) rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", "--review-file", str(tmp_path / "review.md")]) assert rc == 0 - events = _events(tmp_path) - resolve_events = [e for e in events if e["step"] == "defendant_resolve"] + resolve_events = [e for e in _events(tmp_path) + if e["step"] == "defendant_resolve" and e["slug"] == "case-079-cr-0151"] assert len(resolve_events) == 1 assert resolve_events[0]["detail"].startswith("held: कृष्ण प्रसाद यादव -> ") assert "case-b" in resolve_events[0]["detail"] @@ -1148,25 +1164,31 @@ def test_a_held_defendant_is_excluded_from_resolved_and_accused_counts(tmp_path, # row as "resolved" and as part of "accused+N" too -- the bind_plan # summary read "2 defendant(s) resolved" and the review file's Generated # field read "accused+2" for a plan that only ever bound one person. + # `case-b` supplies the real second occurrence of the held name. monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) - case = _case(case_start_date="2020-01-01", case_end_date="2021-01-01") - api = _CliApi( - case, detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], - parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}, - {"side": "defendant", "name": "सिताराम यादव", "nes_id": YADAV}]) + shared = "कृष्ण प्रसाद यादव" + case_a = _case(case_start_date="2020-01-01", case_end_date="2021-01-01") + case_b = _case(slug="case-b", case_start_date="2020-01-01", case_end_date="2021-01-01", + court_cases=["https://jawafdehi.org/courtcase/special/080-cr-0002"]) + api = _MultiCaseApi( + [case_a, case_b], + {"079-cr-0151": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": shared}, + {"side": "defendant", "name": "सिताराम यादव", + "nes_id": YADAV}]}, + "080-cr-0002": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": shared}]}}) import casework.enrich_court_record as ecr monkeypatch.setattr(ecr, "build_api", lambda args: api) - held = {normalise_name("कृष्ण प्रसाद यादव"): frozenset({"case-079-cr-0151", "case-b"})} - real_plan_case = ecr.plan_case - monkeypatch.setattr( - ecr, "plan_case", - lambda *a, **kw: real_plan_case(*a, **{**kw, "held": held})) rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", "--review-file", str(tmp_path / "review.md")]) assert rc == 0 - bind_plan = [e for e in _events(tmp_path) if e["step"] == "bind_plan"] + bind_plan = [e for e in _events(tmp_path) + if e["step"] == "bind_plan" and e["slug"] == "case-079-cr-0151"] assert any("1 defendant(s) resolved" in e["detail"] for e in bind_plan) assert not any("2 defendant(s) resolved" in e["detail"] for e in bind_plan) assert any("1 name(s) held for review" in e["detail"] for e in bind_plan) @@ -1184,24 +1206,29 @@ def test_a_held_only_nothing_to_do_case_is_not_recorded_as_already(tmp_path, mon # `already` is excluded from nothing here: `casework.ledger.NON_OUTCOME_STATUSES` # does not contain it, so `build_ledger` would otherwise record this # stage as a COMPLETED outcome for a case whose whole point is that it - # isn't. + # isn't. `case-b` supplies the real second occurrence of the held name. monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) - case = _case(case_start_date="2020-01-01", case_end_date="2021-01-01") - api = _CliApi(case, detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], - parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}]) + shared = "कृष्ण प्रसाद यादव" + case_a = _case(case_start_date="2020-01-01", case_end_date="2021-01-01") + case_b = _case(slug="case-b", case_start_date="2020-01-01", case_end_date="2021-01-01", + court_cases=["https://jawafdehi.org/courtcase/special/080-cr-0002"]) + api = _MultiCaseApi( + [case_a, case_b], + {"079-cr-0151": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": shared}]}, + "080-cr-0002": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": shared}]}}) import casework.enrich_court_record as ecr monkeypatch.setattr(ecr, "build_api", lambda args: api) - held = {normalise_name("कृष्ण प्रसाद यादव"): frozenset({"case-079-cr-0151", "case-b"})} - real_plan_case = ecr.plan_case - monkeypatch.setattr( - ecr, "plan_case", - lambda *a, **kw: real_plan_case(*a, **{**kw, "held": held})) assert main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", "--review-file", str(tmp_path / "review.md")]) == 0 assert api.patch_calls == [] - idempotency = [e for e in _events(tmp_path) if e["step"] == "idempotency"] + idempotency = [e for e in _events(tmp_path) + if e["step"] == "idempotency" and e["slug"] == "case-079-cr-0151"] assert len(idempotency) == 1 assert idempotency[0]["status"] == "held_for_review" assert "1 name(s) held for review" in idempotency[0]["detail"] @@ -1365,11 +1392,7 @@ def test_a_slug_containing_412_does_not_mislabel_a_missing_etag_as_a_conflict( class _MultiCaseApi(_CliApi): - """`_CliApi` serving several cases in one run, looked up by slug for the - case detail and by court case NUMBER for the court record -- the - two-pass / held-file tests below need more than the one canned case - `_CliApi` alone can serve. - """ + """`_CliApi` serving several cases at once, keyed by slug and by court case number.""" def __init__(self, cases, courtcase_data, *, etag='W/"7"', patch_error=None, fail_slugs=(), **kw): @@ -1581,6 +1604,174 @@ def test_a_failed_patchs_review_row_reads_the_failure_status(tmp_path, monkeypat assert "| 1 | `case-079-cr-0151` | etag_conflict |" in text +def test_a_non_412_patch_failure_review_row_reads_rejected(tmp_path, monkeypatch): + # The other half of the failure-status fix: a non-412 PATCH failure (a + # 400, say) must read `rejected`, not `would-patch` and not `etag_conflict` + # -- only 412 gets the retry-worthy label. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + bad_request = urllib.error.HTTPError( + "https://jawafdehi.org/api/cases/case-079-cr-0151/", 400, + "Bad Request", {}, None) + api = _CliApi( + _case(), patch_error=bad_request, + detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}], + ) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + review_path = tmp_path / "review.md" + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--apply", + "--review-file", str(review_path)]) + assert rc == 0 + text = review_path.read_text(encoding="utf-8") + assert "| 1 | `case-079-cr-0151` | rejected |" in text + assert "etag_conflict" not in text + assert "would-patch" not in text + + +def test_write_held_file_creates_a_review_directory_that_does_not_exist_yet( + tmp_path, monkeypatch, +): + # Reviewer repro: an APPLIED run whose review directory has never been + # created (the default `work/reviews/` on a fresh worktree, or a fresh + # `--review-file`/`CASEWORK_REVIEW_DIR` target) used to crash inside + # `write_held_file` -- called before `review.write()`, the only thing + # that ever `mkdir`s -- AFTER the PATCH had already landed. That would + # lose the review file, the held file, the run footer, and the summary, + # and exit by exception instead of returning 0. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + fresh_dir = tmp_path / "fresh-reviews" + assert not fresh_dir.exists() + api = _CliApi( + _case(), + detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}], + ) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--apply", + "--review-file", str(fresh_dir / "review.md")]) + assert rc == 0 + assert api.patch_calls, "the PATCH must have actually landed before the crash point" + assert (fresh_dir / "review.md").exists() + assert (fresh_dir / "review.held.json").exists() + + +def test_the_held_index_event_reports_a_shrunk_index_after_a_pass_1_failure( + tmp_path, monkeypatch, +): + # Known, accepted limitation: a pass-1 failure on one case removes its + # defendants from the index entirely, so a name it shares with a + # SURVIVING case is no longer protected there either -- case-a binds the + # shared name instead of holding it, because case-b's occurrence of it + # was never read. Not fixed here (no in-run retry is in scope); this + # pins that the run log at least SURFACES the shrink -- `selected` vs + # `readable` in the `held_index` event -- rather than leaving `error=1` + # in the footer as the only signal something is off. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + shared = "कृष्ण प्रसाद यादव" + case_a = _case(slug="case-a", + court_cases=["https://jawafdehi.org/courtcase/special/079-cr-0151"]) + case_b = _case(slug="case-b", + court_cases=["https://jawafdehi.org/courtcase/special/080-cr-0002"]) + api = _MultiCaseApi( + [case_a, case_b], + {"079-cr-0151": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": shared}]}}, + fail_slugs=["case-b"]) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + + events = _events(tmp_path) + # Documents the gap: case-a binds instead of holding, because the index + # never saw case-b's occurrence of the shared name. + case_a_resolve = [e for e in events + if e["step"] == "defendant_resolve" and e["slug"] == "case-a"] + assert case_a_resolve and case_a_resolve[0]["detail"].startswith("created: ") + + index_events = [e for e in events if e["step"] == "held_index"] + assert len(index_events) == 1 + assert "selected=2" in index_events[0]["detail"] + assert "readable=1" in index_events[0]["detail"] + + +def test_the_held_index_event_warns_when_the_selection_is_narrowed(tmp_path, monkeypatch): + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + case_a = _case(slug="case-a", + court_cases=["https://jawafdehi.org/courtcase/special/079-cr-0151"]) + case_b = _case(slug="case-b", + court_cases=["https://jawafdehi.org/courtcase/special/080-cr-0002"]) + api = _MultiCaseApi( + [case_a, case_b], + {"079-cr-0151": {"detail": {"registration_date_ad": "2023-06-22"}}, + "080-cr-0002": {"detail": {"registration_date_ad": "2023-06-22"}}}) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--limit", "1", "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + index_events = [e for e in _events(tmp_path) if e["step"] == "held_index"] + assert len(index_events) == 1 + assert "WARNING" in index_events[0]["detail"] + assert "selected=1" in index_events[0]["detail"] + + +def test_the_held_index_event_has_no_warning_for_an_unrestricted_run(tmp_path, monkeypatch): + # The companion: a wrong implementation that always prints the warning + # (regardless of selection) would still pass the test above but fail + # this one -- a plain bulk sweep over both cases, no --limit/--slug/ + # --court-case/--batch-csv, must not carry it. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + case_a = _case(slug="case-a", + court_cases=["https://jawafdehi.org/courtcase/special/079-cr-0151"]) + case_b = _case(slug="case-b", + court_cases=["https://jawafdehi.org/courtcase/special/080-cr-0002"]) + api = _MultiCaseApi( + [case_a, case_b], + {"079-cr-0151": {"detail": {"registration_date_ad": "2023-06-22"}}, + "080-cr-0002": {"detail": {"registration_date_ad": "2023-06-22"}}}) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + index_events = [e for e in _events(tmp_path) if e["step"] == "held_index"] + assert len(index_events) == 1 + assert "WARNING" not in index_events[0]["detail"] + assert "selected=2" in index_events[0]["detail"] + + +def test_pass_2_reuses_the_cached_court_record_but_gets_a_fresh_etag(tmp_path, monkeypatch): + # The two load-bearing properties the brief warns against silently + # regressing: `get_case_with_etag` must fire ONCE PER PASS (a fresh ETag + # each time -- "optimising away the second read" would drop this to 1), + # and `get_courtcase` must fire only in pass 1 (dropping `court_record=` + # and re-reading in pass 2 would push this to 2). + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + api = _CliApi( + _case(), + detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}], + ) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--apply", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + assert api.call_counts["get_case_with_etag"] == 2 + assert api.call_counts["get_courtcase"] == 1 + + def test_the_module_imports_without_django(tmp_path): """The standalone constraint, pinned deterministically. From 00f585baefc7c458cd87bea495c771692e0857c0 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Sat, 8 Aug 2026 05:58:44 -0700 Subject: [PATCH 20/29] fix(casework): warn on a shrunk held index, not just a narrowed selection The held_index WARNING only fired for --limit/--slug/--court-case/ --batch-csv, never when a pass-1 read failure shrank readable_cases below the selection -- the exact scenario a transient 5xx on one case creates, leaving no signal beyond error=1 in the footer. Now fires on either cause, names which one applied, and logs at logging.WARNING so it's visible to level-based filtering, not just to grepping detail text --- casework/enrich_court_record.py | 26 ++++++++++++------ tests/casework/test_enrich_court_record.py | 32 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 6ee2c2ce..5d3d86d4 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -879,19 +879,29 @@ def main(argv=None): # OTHER case's protection for a name the two would have shared. There is # no in-run fix for that (the case's own data is simply unread), so this # is the visibility half: an operator can see readable < selected instead - # of inferring it from `error=N` in the footer. `--limit`/`--slug`/ - # `--court-case`/`--batch-csv` narrow the SAME index deliberately, so the - # warning below applies regardless of why the count is small. + # of inferring it from `error=N` in the footer. The WARNING below fires + # on EITHER cause of a small index -- a narrowed selection + # (--limit/--slug/--court-case/--batch-csv) or a pass-1 read failure -- + # and names which one applied: an operator needs to tell "I asked for a + # subset" apart from "a read failed" to know whether a re-run would help. narrowed = bool(args.limit or args.slug or args.court_case or args.batch_csv) + shrunk = len(readable_cases) != len(cases) index_detail = (f"selected={len(cases)}, readable={len(readable_cases)}, " f"names_in_index={len(name_index)}, held={len(held)}") + reasons = [] if narrowed: - index_detail += ("; WARNING: selection narrowed by --limit/--slug/" - "--court-case/--batch-csv -- the held index only " - "covers this run's cases, so a name shared with a " - "case OUTSIDE this selection will not be held") + reasons.append("selection narrowed by --limit/--slug/--court-case/--batch-csv") + if shrunk: + reasons.append(f"{len(cases) - len(readable_cases)} case(s) failed their " + "pass-1 read") + if reasons: + index_detail += ("; WARNING: " + " AND ".join(reasons) + + " -- the held index only covers readable, selected " + "cases, so a name shared with a case OUTSIDE it " + "will not be held") log_event(logger, events, run_id=run_id, stage=STAGE, slug="", - step="held_index", status="ok", detail=index_detail) + step="held_index", status="ok", detail=index_detail, + level=logging.WARNING if reasons else logging.INFO) # Pass 2: plan (and maybe write) every case against that SAME `held` # mapping. `get_case_with_etag` is re-read here for a FRESH ETag -- diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 0edef73d..7bd85e18 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -956,6 +956,19 @@ def _events(tmp_path): return [json.loads(line) for line in paths[0].read_text().splitlines() if line] +def _log_lines(tmp_path): + """Every line from the one `*.log` a run leaves in `tmp_path`. + + Reads the rendered log file rather than `caplog`: `configure_run_logging` + sets `propagate = False` on its logger precisely so this logger's output + isn't doubled through root's handlers, which also means `caplog` (which + only ever attaches to root) never sees these records. + """ + paths = list(tmp_path.glob("*.log")) + assert paths, "the run must leave a log file" + return paths[0].read_text(encoding="utf-8").splitlines() + + def test_a_dry_run_writes_the_events_file_and_no_patch(tmp_path, monkeypatch): monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) monkeypatch.setenv("CASEWORK_API_USER", "dev") @@ -1670,6 +1683,12 @@ def test_the_held_index_event_reports_a_shrunk_index_after_a_pass_1_failure( # pins that the run log at least SURFACES the shrink -- `selected` vs # `readable` in the `held_index` event -- rather than leaving `error=1` # in the footer as the only signal something is off. + # + # Review round 2 found the WARNING wired to the wrong condition: it only + # fired for a narrowed CLI selection (`--limit`/`--slug`/etc.), never for + # THIS scenario -- no selection flag at all, the shrink comes entirely + # from the pass-1 read failure. A wrong fix that keeps checking only the + # CLI flags would leave this test's WARNING assertions red. monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) shared = "कृष्ण प्रसाद यादव" case_a = _case(slug="case-a", @@ -1700,6 +1719,19 @@ def test_the_held_index_event_reports_a_shrunk_index_after_a_pass_1_failure( assert len(index_events) == 1 assert "selected=2" in index_events[0]["detail"] assert "readable=1" in index_events[0]["detail"] + # No selection flag narrowed this run -- the WARNING must still fire, + # and must name the read failure, not the (absent) CLI-flag reason. + assert "WARNING" in index_events[0]["detail"] + assert "pass-1 read" in index_events[0]["detail"] + assert "--limit" not in index_events[0]["detail"] + + # The marker must be visible to level-based filtering too, not only to + # someone grepping `detail` -- `log_event` takes a `level` kwarg for + # exactly this, and the rendered log line must actually carry it. + held_index_lines = [ln for ln in _log_lines(tmp_path) if "step=held_index" in ln] + assert held_index_lines + assert " WARNING [" in held_index_lines[0] + assert " INFO " not in held_index_lines[0] def test_the_held_index_event_warns_when_the_selection_is_narrowed(tmp_path, monkeypatch): From b9158903de4e5ab86f56f951b876ea9a4ccf2695 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Sat, 8 Aug 2026 06:43:58 -0700 Subject: [PATCH 21/29] fix(casework): make an unparseable case number skip, not bind case_number_code returned "" -- the pre-FY073 prosecution bucket -- for anything it couldn't parse, not just the genuine legacy all-digit shape. `W-081-0037`, `RE-081-1730`, `079_CR_0151`, and a Devanagari-transliterated number all fell into BINDABLE_CODES by accident, inverting the allow-list rule Task 1 was written around. Only `^[0-9]+-[0-9]+-[0-9]+$` (matching `93-068-0194`) now maps to "", everything else this can't read returns UNPARSEABLE, which is not in BINDABLE_CODES so it skips and logs like any other unrecognised code. --- casework/court_record.py | 25 +++++++++++++++++++++--- tests/casework/test_court_record.py | 30 +++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/casework/court_record.py b/casework/court_record.py index 277356ff..51a25384 100644 --- a/casework/court_record.py +++ b/casework/court_record.py @@ -62,11 +62,30 @@ _CODE_SEGMENT = re.compile(r"-([A-Za-z]+)-") +#: The pre-FY073 shape: three all-ASCII-digit groups, nothing else (`93-068-0194`). +_LEGACY_NUMBER = re.compile(r"^[0-9]+-[0-9]+-[0-9]+$") + +#: Returned for a number that is neither `--` coded nor the legacy +#: all-digit shape -- NOT in `BINDABLE_CODES`, so it skips and logs rather +#: than joining the `""` (pre-FY073 prosecution) bucket by default. Allow-list, +#: not deny-list: a number this parser cannot read is treated the same as an +#: unrecognised code, never as a silent prosecution. +UNPARSEABLE = "UNPARSEABLE" + def case_number_code(number): - """The court's case-type letters from `079-CR-0151`, upper-cased, or "" if none.""" - match = _CODE_SEGMENT.search(str(number or "")) - return match.group(1).upper() if match else "" + """The court's case-type letters from `079-CR-0151`, upper-cased. + + `""` only for the genuine pre-FY073 shape (`93-068-0194`); anything else + this cannot read -- wrong separator, letters in the wrong place, a + non-ASCII transliteration -- returns `UNPARSEABLE`, never `""`, so it + cannot be mistaken for that legacy prosecution format. + """ + text = str(number or "") + match = _CODE_SEGMENT.search(text) + if match: + return match.group(1).upper() + return "" if _LEGACY_NUMBER.match(text) else UNPARSEABLE def is_defendant(party): diff --git a/tests/casework/test_court_record.py b/tests/casework/test_court_record.py index 2726cbb7..bfaac42c 100644 --- a/tests/casework/test_court_record.py +++ b/tests/casework/test_court_record.py @@ -13,6 +13,8 @@ import pytest from casework.court_record import ( + BINDABLE_CODES, + UNPARSEABLE, case_number_code, court_record_for_case, court_ref, @@ -219,8 +221,32 @@ def test_no_court_reference_reports_why(): ("93-068-0194", ""), ("081-RE-1730", "RE"), # No hyphens at all: a `.split("-")[1]` implementation would raise - # IndexError here instead of falling back to "". - ("0791234", ""), + # IndexError here instead of falling back to a safe value. Not the + # legacy shape either (no hyphens at all), so this is UNPARSEABLE, not + # the pre-FY073 "" bucket -- an allow-list miss, not a silent prosecution. + ("0791234", UNPARSEABLE), ]) def test_case_number_code_classifies_the_court_case_type(number, expected): assert case_number_code(number) == expected + + +@pytest.mark.parametrize("number", [ + "W-081-0037", # a writ code, but not between two hyphens -- must not + # fall into the pre-FY073 "" bucket and bind as a + # prosecution. + "RE-081-1730", # code-first ordering: no `--` segment exists. + "079_CR_0151", # underscores, not hyphens: not the legacy shape either. + "०८१-आरई-१७३०", # Devanagari digits and letters: `_CODE_SEGMENT` only + # matches ASCII letters, and the middle segment is not + # `[0-9]+`, so this cannot be the legacy shape. +]) +def test_case_number_code_refuses_to_guess_an_unparseable_number(number): + # Reviewer repro: all four used to return "" (BINDABLE), inverting the + # allow-list rule for a number the parser genuinely cannot read. + code = case_number_code(number) + assert code == UNPARSEABLE + assert code not in BINDABLE_CODES + + +def test_the_legacy_all_digit_shape_still_classifies_as_a_prosecution(): + assert case_number_code("93-068-0194") == "" From 704c15a9c73ad35dea5525ad4ade725486d5241a Mon Sep 17 00:00:00 2001 From: GAURAV Date: Sat, 8 Aug 2026 06:44:18 -0700 Subject: [PATCH 22/29] fix(casework): warn on --fiscal-year narrowing, and admit what the held index can't see narrowed checked --limit/--slug/--court-case/--batch-csv but not --fiscal-year -- the selector an operator scoping a whole campaign is most likely to use, so a fiscal-year-scoped run got no WARNING though the same shrink under --limit did. Added to the predicate and the reason text. The held_index detail also said nothing about select_cases's own ENRICHABLE_STATES gate, so a clean, unrestricted, unshrunk run read as "the index is complete" when a PUBLISHED case -- exactly where a confirmed accused bind already lives -- is structurally invisible to it regardless of any CLI flag. States the limit on every run now, and corrects the module docstring's claim that an unrestricted sweep "is what actually makes the hold protective"; it does not, on its own, see past the state gate. Also: - resolved_count = len(plan.rows) - held_count counted a how="failed" row (an unslugabble name, a search error, a slug collision) as resolved, so "accused+N" and the bind_plan summary overcounted by one phantom bind per failure. Factored into _rung_counts, shared by _log_plan and main. - Pass 1 keyed court_records on `case.get("slug") or ""` with no guard, so two slug-less cases would both land on "" and pass 2 would plan both against whichever record set was written last. Refuse to index a slug-less case instead. - _accused_binds read its new entity's citation off records[0] before the per-record prosecution filter ran, so a person created for a CR defendant could be cited to an OA/RE/writ material that never names them. Now taken from the first record that actually passes the filter. - A dry run's "would create" resolution implied the printed IRI is the one --apply would bind, but dry_run never reaches the EntityAlreadyExists handler -- on the COMMON path for a common name (this slug already belongs to someone the ladder declined to identify), --apply refuses the bind instead. The reason now says that plainly. --- casework/enrich_court_record.py | 117 +++++++++---- tests/casework/test_enrich_court_record.py | 192 +++++++++++++++++++++ 2 files changed, 280 insertions(+), 29 deletions(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 5d3d86d4..5dd52a4d 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -49,13 +49,19 @@ plain "Accused" label. `charged` is true by construction everywhere else: every case in this corpus is a Special Court `-CR-` case, so CIAA filed a charge sheet. -THE HOLD IS SCOPED TO ONE RUN. `held_names` only sees a name on 2+ cases -INSIDE the current selection, so `--limit`, `--slug`, `--court-case`, or a -`--batch-csv` split can each shrink that selection below the cases sharing a -name -- silently disabling the hold for exactly the pair it exists to catch. -The run log states the index's scope (`step="held_index"`) and warns when the -selection looks narrowed; an unrestricted or fiscal-year-scoped sweep is what -actually makes the hold protective. +THE HOLD IS SCOPED TO ONE RUN, AND TO THE ENRICHABLE STATES. `held_names` only +sees a name on 2+ cases INSIDE the current selection, so `--limit`, `--slug`, +`--court-case`, `--batch-csv`, or a narrow `--fiscal-year` can each shrink +that selection below the cases sharing a name -- silently disabling the hold +for exactly the pair it exists to catch. An unrestricted sweep does not fix +this the way it might seem to: `casework.common.select.select_cases` filters +bulk selection to `ENRICHABLE_STATES` (DRAFT, IN_REVIEW), so a name already +bound as accused on a PUBLISHED case is invisible to the index by +construction, not by narrowing -- and PUBLISHED is exactly where a confirmed +bind already lives. Widening the index past that state gate is a +cost/coverage call for a human, not something this module defaults to. The +run log states both limits every run (`step="held_index"`) and warns +separately when the selection looks narrowed or a pass-1 read shrank it. Usage: uv run python -m casework.enrich_court_record --dry-run --verbose @@ -81,7 +87,7 @@ setup_logging, ) from casework.common.review import ReviewRow, build_review_file -from casework.common.select import select_for_run +from casework.common.select import ENRICHABLE_STATES, select_for_run from casework.court_record import ( BINDABLE_CODES, case_number_code, @@ -344,10 +350,20 @@ def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, iri = build_entity_iri(PERSON_PREFIX, slug) if dry_run: - # POST nothing, but report the IRI an --apply run would use, so the - # printed patch is the one that would be sent. + # POST nothing, and report the IRI an --apply run would use IF + # nothing already owns this slug. That "if" is real: a dry run never + # reaches the `EntityAlreadyExists` handler below, so on the COMMON + # path for a common name -- this slug already belongs to a person + # the ladder declined to identify -- --apply refuses the bind this + # row reports as made. The review file is approved BEFORE --apply, so + # the reason says that plainly rather than implying the printed + # patch is guaranteed to be the one sent. run_entities[key] = iri - return Resolution(iri, "created", "would create") + return Resolution(iri, "created", + "would create -- if this slug already belongs to " + "another entity, --apply refuses the bind instead " + "of using this IRI (a dry run has no network read " + "to check that here)") # `slug` is sent explicitly, not left for the server to derive: # `normalize_authoring_payload` raises "slug is required" on a payload @@ -503,7 +519,11 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, by anything this function alone can see. """ outcome = bind_outcome(records) - citation = (records[0].get("detail") or {}).get("material_id", "") if records else "" + # The first BINDABLE record, never `records[0]`: a rejected reference + # (an `OA`/`RE`/writ) never names these defendants, so citing it would + # put a false provenance claim on the NES entity this creates. + bindable = [r for r in records if case_number_code(r["number"]) in BINDABLE_CODES] + citation = (bindable[0].get("detail") or {}).get("material_id", "") if bindable else "" items, rows, skips, seen = [], [], [], set() for record in records: code = case_number_code(record["number"]) @@ -716,13 +736,30 @@ def apply_plan(api, plan): "created": "created", "failed": "failed", "held": "held"} +def _rung_counts(rows): + """`(resolved_count, held_count)` over a plan's per-defendant rows. + + Resolved means the ladder actually named an entity -- `how` in + `{"nes_id", "exact", "created"}` -- so a `"failed"` row (an unslugabble + name, a search error, a slug collision) counts as neither resolved nor + held. `len(rows) - held_count` alone still counts a failed row as + resolved, which is where "accused+N"/"N defendant(s) resolved" picked up + a phantom bind: a name a run never turned into an entity read the same + as one it did. + """ + held = sum(1 for row in rows if row["how"] == "held") + resolved = sum(1 for row in rows if row["how"] not in ("held", "failed")) + return resolved, held + + def _log_plan(logger, events, run_id, plan): - """Emit the per-step events for one planned case. Returns the held count. + """Emit the per-step events for one planned case. Returns `(held, resolved)`. Every event here is intermediate and therefore `ok`-statused; see `_RUNG_WORDS` for why, and `main` for the terminal events that follow. - The held count is returned so `main` can subtract it from its own - "accused+N" and already-bound counts without recomputing `plan.rows`. + Both counts are returned so `main` can build its own "accused+N" and + already-bound text from the SAME numbers this summary line reports, + rather than recomputing them from `plan.rows` a second time. `run_id`/`stage`/`slug` are passed as explicit keywords on every call rather than once via a `**common` dict: `ty` cannot verify that a plain @@ -732,9 +769,8 @@ def _log_plan(logger, events, run_id, plan): `enrich_related_entities.py`'s own `log_event` calls use the same explicit-keyword style for the identical reason. """ - held_count = 0 + resolved_count, held_count = _rung_counts(plan.rows) for row in plan.rows: - held_count += row["how"] == "held" log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, step="defendant_resolve", status="ok", detail=f"{_RUNG_WORDS[row['how']]}: {row['name']} -> " @@ -770,17 +806,17 @@ def _log_plan(logger, events, run_id, plan): # "resolved", not "on the court record": when `code_skips` is non-zero the # court record named at least one defendant this run declined to look at, # and the earlier count must not be read as "the record named none". A - # held row sits in `plan.rows` too but was never resolved, so it is - # subtracted out here as well. + # held row and a failed row both sit in `plan.rows` too but neither was + # resolved (see `_rung_counts`), so both are excluded here. summary = (f"{'merged' if plan.entities is not None else 'no_additions'}: " - f"{len(plan.rows) - held_count} defendant(s) resolved") + f"{resolved_count} defendant(s) resolved") if code_skips: summary += f"; {code_skips} court reference(s) skipped as non-prosecution" if held_count: summary += f"; {held_count} name(s) held for review" log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, step="bind_plan", status="ok", detail=summary) - return held_count + return held_count, resolved_count def _held_report(held, court_records): @@ -855,6 +891,18 @@ def main(argv=None): court_records, readable_cases = {}, [] for i, case in enumerate(cases, 1): slug = case.get("slug") or "" + if not slug: + # Two slug-less cases would both key `court_records` on the SAME + # `""` -- pass 2 would then plan both against whichever record + # set landed there last, one case's court record reaching the + # other's `_accused_binds`. Refusing to index either closes that + # collision rather than picking a loser between them. + log_event(logger, events, run_id=run_id, stage=STAGE, slug="", + step="court_read", status="unreadable", + detail=f"pass 1: case {i} of {len(cases)} has no slug " + "-- cannot be read or indexed") + stats["error"] = stats.get("error", 0) + 1 + continue try: case_detail, _ = api.get_case_with_etag(slug) except Exception as exc: # noqa: BLE001 - one case's read failure is not the run's @@ -881,16 +929,28 @@ def main(argv=None): # is the visibility half: an operator can see readable < selected instead # of inferring it from `error=N` in the footer. The WARNING below fires # on EITHER cause of a small index -- a narrowed selection - # (--limit/--slug/--court-case/--batch-csv) or a pass-1 read failure -- - # and names which one applied: an operator needs to tell "I asked for a - # subset" apart from "a read failed" to know whether a re-run would help. - narrowed = bool(args.limit or args.slug or args.court_case or args.batch_csv) + # (--limit/--slug/--court-case/--batch-csv/--fiscal-year) or a pass-1 + # read failure -- and names which one applied: an operator needs to tell + # "I asked for a subset" apart from "a read failed" to know whether a + # re-run would help. + narrowed = bool(args.limit or args.slug or args.court_case or args.batch_csv + or args.fiscal_year) shrunk = len(readable_cases) != len(cases) + # The index is ALSO narrower than "every case", unconditionally: bulk + # selection (`select_cases`) only ever returns `ENRICHABLE_STATES`, so a + # PUBLISHED case -- exactly the ones already carrying a confirmed accused + # bind -- is absent here regardless of any CLI flag. Stated every run, + # not folded into the WARNING branch below: an unrestricted, unshrunk + # sweep must not read as "the index sees everything". index_detail = (f"selected={len(cases)}, readable={len(readable_cases)}, " - f"names_in_index={len(name_index)}, held={len(held)}") + f"names_in_index={len(name_index)}, held={len(held)} " + f"(covers only {'/'.join(ENRICHABLE_STATES)} cases -- a " + "name already bound on a PUBLISHED case is invisible to " + "this index)") reasons = [] if narrowed: - reasons.append("selection narrowed by --limit/--slug/--court-case/--batch-csv") + reasons.append("selection narrowed by --limit/--slug/--court-case/" + "--batch-csv/--fiscal-year") if shrunk: reasons.append(f"{len(cases) - len(readable_cases)} case(s) failed their " "pass-1 read") @@ -948,8 +1008,7 @@ def main(argv=None): continue log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, step="court_read", status="ok") - held_count = _log_plan(logger, events, run_id, plan) - resolved_count = len(plan.rows) - held_count + held_count, resolved_count = _log_plan(logger, events, run_id, plan) generated_parts = [f"{k}={v}" for k, v in plan.fields] if plan.entities is not None: diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 7bd85e18..80728535 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -216,6 +216,22 @@ def test_a_dry_run_posts_nothing_but_reports_the_iri_it_would_use(): assert api.posted == [] +def test_a_dry_run_admits_apply_could_refuse_this_bind(): + # `dry_run` returns the pre-POST IRI without ever attempting the create, + # so it never reaches the `EntityAlreadyExists` handler an --apply run + # would hit on the COMMON path for a common name: this exact slug + # already belongs to a person the ladder declined to identify. The + # review file this row feeds is approved BEFORE --apply runs, so the + # reason must say a collision is possible, not imply this IRI is the + # one that will be bound. + api = _SearchApi(results=[]) + got = resolve_defendant(api, "कृष्ण प्रसाद यादव", None, citation="", + live_prefixes=["person"], run_entities={}, dry_run=True) + assert got.how == "created" + assert "refuse" in got.reason + assert "--apply" in got.reason + + def test_the_same_person_across_two_cases_creates_one_entity(): api = _SearchApi(results=[], created={"@id": YADAV}) run_entities = {} @@ -562,6 +578,27 @@ def test_accused_binds_skips_a_non_prosecution_record_but_binds_a_prosecution_on assert "079-oa-0014" in skips[0] and "OA" in skips[0] +def test_the_new_entity_citation_comes_from_the_first_bindable_record(): + # Reviewer repro: `citation` used to read `records[0]` before the + # per-record filter ran, so a CR defendant's new entity could be cited + # to a writ (`OA`) material that never names them -- a false provenance + # claim on a public NES record. The OA reference is listed FIRST here on + # purpose, so only a fix that skips it when picking the citation (not + # just when binding) can pass. + oa_record = _record(number="079-oa-0014", + parties=[{"side": "defendant", "name": "कुनै व्यक्ति"}]) + oa_record["detail"]["material_id"] = "https://jawafdehi.org/material/court/special.079-oa-0014" + cr_record = _record(number="079-cr-0151", + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव"}]) + cr_record["detail"]["material_id"] = "https://jawafdehi.org/material/court/special.079-cr-0151" + api = _SearchApi(results=[], created={"@id": YADAV}) + items, rows, skips = _accused_binds( + api, _case(), [oa_record, cr_record], + live_prefixes=["person"], run_entities={}, dry_run=False, held={}) + assert [i["nes_id"] for i in items] == [YADAV] + assert api.posted[0]["citation"] == "https://jawafdehi.org/material/court/special.079-cr-0151" + + def test_accused_binds_binds_the_pre_fy073_no_code_format(): # `93-068-0194`-style numbers carry no `--` segment at all -- 139 # references in the corpus. A rule spelled "the number must contain @@ -1212,6 +1249,40 @@ def test_a_held_defendant_is_excluded_from_resolved_and_accused_counts(tmp_path, assert "1 name(s) held for review" in review_text +def test_a_failed_resolution_is_not_counted_as_resolved_or_bound(tmp_path, monkeypatch): + # Reviewer repro, verbatim: parties `["कृष्ण प्रसाद यादव", "!!!", "???"]` + # produce exactly ONE bind item (the `nes_id` copy), but the old + # `resolved_count = len(plan.rows) - held_count` reported "2 + # defendant(s) resolved" and `accused+2` -- it counted the `how="failed"` + # row (an unslugabble punctuation-only name) as resolved. `"!!!"` and + # `"???"` both normalise to "" (`normalise_name` strips all punctuation), + # so the per-case dedup in `_accused_binds` collapses them to ONE row -- + # which is exactly why the real repro used two different symbols and + # still only produced a single extra row to miscount. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + api = _CliApi( + _case(), + detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}, + {"side": "defendant", "name": "!!!"}, + {"side": "defendant", "name": "???"}], + ) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + + bind_plan = [e for e in _events(tmp_path) if e["step"] == "bind_plan"] + assert any("1 defendant(s) resolved" in e["detail"] for e in bind_plan) + assert not any("2 defendant(s) resolved" in e["detail"] for e in bind_plan) + + review_text = (tmp_path / "review.md").read_text(encoding="utf-8") + assert "accused+1" in review_text + assert "accused+2" not in review_text + + def test_a_held_only_nothing_to_do_case_is_not_recorded_as_already(tmp_path, monkeypatch): # The companion to `test_a_case_with_nothing_to_change_records_already_not_nothing`: # when the ONLY reason a case reaches "nothing-to-do" is a held name, the @@ -1433,6 +1504,68 @@ def get_court_case_entities(self, court, number, timeout=60): return self._courtcase_data[number].get("parties", []) +class _EmptySlugApi: + """Two cases, deliberately both slug-less. `_MultiCaseApi` cannot express + this fixture at all -- it keys its own case map by slug, so two cases + sharing `""` would collide there first.""" + + def __init__(self, cases): + self._cases = cases + + def iter_cases(self, params=None, timeout=60, progress=None): + yield from self._cases + + def get_case_with_etag(self, slug, timeout=60): + raise AssertionError("a slug-less case must never reach a case read") + + def get_courtcase(self, court, number, timeout=60): + raise AssertionError("a slug-less case must never reach a court read") + + def list_hearings(self, court, number, timeout=60): + raise AssertionError("a slug-less case must never reach a court read") + + def get_court_case_entities(self, court, number, timeout=60): + raise AssertionError("a slug-less case must never reach a court read") + + def entity_prefixes(self, timeout=60): + return ["person"] + + def patch_case(self, slug, *, fields=(), lists=(), timeout=60, if_match=None): + raise AssertionError("a slug-less case must never reach a patch") + + +def test_two_slug_less_cases_do_not_collide_on_an_empty_key(tmp_path, monkeypatch): + # Reviewer repro: pass 1 did `slug = case.get("slug") or ""` with no + # guard, so two slug-less cases both keyed `court_records[""]`, both + # entered `readable_cases`, and pass 2 planned BOTH against whichever + # record set pass 1 wrote there last -- case B's court record reaching + # case A's `_accused_binds`. Each stub method below raises if pass 1 + # ever gets far enough to call it, so this fails loudly rather than + # quietly proving nothing if the guard regresses. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + case_a = {"state": "DRAFT", + "court_cases": ["https://jawafdehi.org/courtcase/special/079-cr-0151"], + "entities": []} + case_b = {"state": "DRAFT", + "court_cases": ["https://jawafdehi.org/courtcase/special/080-cr-0002"], + "entities": []} + api = _EmptySlugApi([case_a, case_b]) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + + events = _events(tmp_path) + unreadable = [e for e in events if e["step"] == "court_read" and e["status"] == "unreadable"] + assert len(unreadable) == 2 + assert all("no slug" in e["detail"] for e in unreadable) + # Neither case may reach planning: no `select`, `patch`, or a resolved + # `defendant_resolve` event of any kind should exist. + assert not any(e["step"] in ("select", "patch", "defendant_resolve") for e in events) + + def test_a_pass_1_read_failure_on_one_case_does_not_stop_the_run(tmp_path, monkeypatch): # `case-bad`'s pass-1 `get_case_with_etag` raises. A wrong implementation # that let this propagate would crash `main()` before any case is @@ -1782,6 +1915,65 @@ def test_the_held_index_event_has_no_warning_for_an_unrestricted_run(tmp_path, m assert "selected=2" in index_events[0]["detail"] +def test_the_held_index_event_states_it_cannot_see_published_binds( + tmp_path, monkeypatch, +): + # Reviewer repro: bulk selection filters to `ENRICHABLE_STATES`, so a + # PUBLISHED case's confirmed accused binds are absent from the index + # unconditionally -- not because of `--limit`/a pass-1 failure, so this + # must appear even on a clean, unrestricted, un-shrunk run where neither + # WARNING branch fires. Before this fix the line said nothing about it, + # which reads as "the index is complete" when it structurally cannot be. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + case_a = _case(slug="case-a") + api = _MultiCaseApi( + [case_a], + {"079-cr-0151": {"detail": {"registration_date_ad": "2023-06-22"}}}) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + index_events = [e for e in _events(tmp_path) if e["step"] == "held_index"] + assert len(index_events) == 1 + assert "WARNING" not in index_events[0]["detail"] + assert "DRAFT" in index_events[0]["detail"] and "IN_REVIEW" in index_events[0]["detail"] + assert "PUBLISHED" in index_events[0]["detail"] + + +def test_the_held_index_event_warns_when_fiscal_year_narrows_the_selection( + tmp_path, monkeypatch, +): + # Reviewer repro: `narrowed` checked `--limit`/`--slug`/`--court-case`/ + # `--batch-csv` but not `--fiscal-year` -- the selector an operator + # scoping a whole campaign is most likely to use. Two DRAFT cases in + # different fiscal years; `--fiscal-year 79` selects only one, so + # `selected=1` with nothing failing its pass-1 read (`shrunk` stays + # False) -- only the `narrowed` branch can produce this WARNING. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + case_a = _case(slug="case-a", + court_cases=["https://jawafdehi.org/courtcase/special/079-cr-0151"]) + case_b = _case(slug="case-b", + court_cases=["https://jawafdehi.org/courtcase/special/080-cr-0002"]) + api = _MultiCaseApi( + [case_a, case_b], + {"079-cr-0151": {"detail": {"registration_date_ad": "2023-06-22"}}, + "080-cr-0002": {"detail": {"registration_date_ad": "2023-06-22"}}}) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--fiscal-year", "79", "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + index_events = [e for e in _events(tmp_path) if e["step"] == "held_index"] + assert len(index_events) == 1 + assert "selected=1" in index_events[0]["detail"] + assert "readable=1" in index_events[0]["detail"] + assert "WARNING" in index_events[0]["detail"] + assert "--fiscal-year" in index_events[0]["detail"] + + def test_pass_2_reuses_the_cached_court_record_but_gets_a_fresh_etag(tmp_path, monkeypatch): # The two load-bearing properties the brief warns against silently # regressing: `get_case_with_etag` must fire ONCE PER PASS (a fresh ETag From 92c2486a9af4b07cb0b70ef0ec1e2038d81a322c Mon Sep 17 00:00:00 2001 From: GAURAV Date: Sat, 8 Aug 2026 06:56:05 -0700 Subject: [PATCH 23/29] fix(casework): keep a resolution's caveat instead of letting its IRI swallow it `nes_id or reason` discarded the reason on every row carrying both, which is exactly the two whose reason is a warning: a dry run's "would create" (--apply refuses it when the slug is taken) and "reused from this run". Both read as a settled bind in the events file a caseworker approves from. Co-Authored-By: Claude Opus 5 (1M context) --- casework/enrich_court_record.py | 15 ++++++++++- tests/casework/test_enrich_court_record.py | 30 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 5dd52a4d..16f071ed 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -752,6 +752,19 @@ def _rung_counts(rows): return resolved, held +def _resolve_detail(row): + """What one defendant resolved to -- the IRI, its caveat, or why it failed. + + `nes_id or reason` alone discards the reason on every row that has both, + which is exactly the two rows whose reason carries a warning: a dry run's + "would create" (`--apply` refuses it if the slug is taken) and a + "reused from this run". Both then read as a plain settled bind. + """ + if row["nes_id"] and row["reason"]: + return f"{row['nes_id']} ({row['reason']})" + return row["nes_id"] or row["reason"] + + def _log_plan(logger, events, run_id, plan): """Emit the per-step events for one planned case. Returns `(held, resolved)`. @@ -774,7 +787,7 @@ def _log_plan(logger, events, run_id, plan): log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, step="defendant_resolve", status="ok", detail=f"{_RUNG_WORDS[row['how']]}: {row['name']} -> " - f"{row['nes_id'] or row['reason']}") + f"{_resolve_detail(row)}") if plan.fields: log_event(logger, events, run_id=run_id, stage=STAGE, slug=plan.slug, step="dates", status="ok", diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 80728535..9202006f 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -1208,6 +1208,36 @@ def test_a_held_defendant_is_logged_under_defendant_resolve_not_silently_dropped assert api.patch_calls == [] +def test_a_dry_run_created_row_keeps_the_caveat_its_iri_would_have_hidden( + tmp_path, monkeypatch, +): + # `nes_id or reason` dropped the reason on every row carrying both, and + # a dry-run "created" row is exactly that: the IRI is truthy, so the + # warning that `--apply` refuses this bind when the slug is already taken + # was discarded. The review file is approved BEFORE the apply, so a row + # reading `created: -> ` promised a bind the run might refuse. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + api = _MultiCaseApi( + [_case()], + {"079-cr-0151": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", + "name": "कृष्ण प्रसाद यादव"}]}}) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + created = [e for e in _events(tmp_path) + if e["step"] == "defendant_resolve" and e["detail"].startswith("created: ")] + assert len(created) == 1 + # Both halves: the IRI an --apply would use, AND the caveat on it. + assert "/entity/person/" in created[0]["detail"] + assert "--apply refuses the bind" in created[0]["detail"] + assert api.posted == [] + + def test_a_held_defendant_is_excluded_from_resolved_and_accused_counts(tmp_path, monkeypatch): # Reviewer repro: one held name plus one `nes_id`-bound defendant, dates # already populated. Before this fix `len(plan.rows)` counted the held From eca85a68d35fa0fc160d2f9581046152e4544934 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Sat, 8 Aug 2026 09:58:08 -0700 Subject: [PATCH 24/29] test(casework): pin that IN_REVIEW feeds the held index but is never written The selection gate (ENRICHABLE_STATES, DRAFT+IN_REVIEW) and the write gate (REQUIRED_WRITE_STATE, DRAFT alone) are deliberately different widths, and conflating them would start writing to cases already under human review. Co-Authored-By: Claude Opus 5 (1M context) --- tests/casework/test_enrich_court_record.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 9202006f..30c9d38f 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -415,9 +415,11 @@ def test_is_person_never_raises_on_a_malformed_iri(): assert _is_person(None) is False +from casework.common.select import ENRICHABLE_STATES # noqa: E402 from casework.enrich_court_record import ( # noqa: E402 ACQUITTED, CHARGED, + REQUIRED_WRITE_STATE, CasePlan, bind_outcome, plan_case, @@ -875,6 +877,21 @@ def test_a_non_draft_case_is_refused(): assert plan.status == "skip-state" +def test_an_in_review_case_is_selected_for_the_index_but_never_written(): + # `select_cases`'s ENRICHABLE_STATES admits IN_REVIEW, which is what the + # held index wants -- an IN_REVIEW case's defendants are real occurrences + # and must count toward a cross-case collision. The WRITE gate is separate + # and narrower: `REQUIRED_WRITE_STATE` is DRAFT alone. Pinned because the + # two are easy to conflate, and widening this one to match the selection + # gate would start writing to cases already under human review. + assert "IN_REVIEW" in ENRICHABLE_STATES + assert REQUIRED_WRITE_STATE == "DRAFT" + plan = _plan(_PlanApi(detail={"registration_date_ad": "2023-06-22"}), + _case(state="IN_REVIEW")) + assert plan.status == "skip-state" + assert "IN_REVIEW" in plan.skips[0] + + def test_a_case_payload_missing_the_entities_key_is_refused(): # `case.get("entities") or []` cannot tell "no binds" from "this payload # does not carry binds at all" -- a trimmed dict from a list endpoint, say. From 7df92abde973e500336e430437d1d1f9cdd7e510 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Sat, 8 Aug 2026 10:52:52 -0700 Subject: [PATCH 25/29] revert(cases): drop the date help-text change and its migration The help text was genuinely wrong -- `case_start_date` read "when the alleged incident began" while 46 of 48 published cases use the court registration date, which is also what the binder writes. But the fix is independent of the binder, and it dragged the only cases/ change and a migration number into an otherwise self-contained casework/ branch, where 0056 is a merge hazard for a cosmetic edit. Goes back as its own PR. Co-Authored-By: Claude Opus 5 (1M context) --- .../0056_correct_case_date_help_text.py | 23 ------------------- cases/models.py | 9 ++------ 2 files changed, 2 insertions(+), 30 deletions(-) delete mode 100644 cases/migrations/0056_correct_case_date_help_text.py diff --git a/cases/migrations/0056_correct_case_date_help_text.py b/cases/migrations/0056_correct_case_date_help_text.py deleted file mode 100644 index 6109a660..00000000 --- a/cases/migrations/0056_correct_case_date_help_text.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.15 on 2026-08-07 19:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('cases', '0055_alter_case_banner_url_alter_case_thumbnail_url'), - ] - - operations = [ - migrations.AlterField( - model_name='case', - name='case_end_date', - field=models.DateField(blank=True, help_text='When the case concluded — the deciding hearing date. Leave empty for a case still being heard: a value here renders the case as concluded on the public site', null=True), - ), - migrations.AlterField( - model_name='case', - name='case_start_date', - field=models.DateField(blank=True, help_text='When the case began — the court registration date for a case with a court record', null=True), - ), - ] diff --git a/cases/models.py b/cases/models.py index 470bafdc..42951c23 100644 --- a/cases/models.py +++ b/cases/models.py @@ -642,15 +642,10 @@ class Case(models.Model): ) # Date fields case_start_date = models.DateField( - null=True, blank=True, - help_text="When the case began — the court registration date for a " - "case with a court record", + null=True, blank=True, help_text="When the alleged incident began" ) case_end_date = models.DateField( - null=True, blank=True, - help_text="When the case concluded — the deciding hearing date. Leave " - "empty for a case still being heard: a value here renders the " - "case as concluded on the public site", + null=True, blank=True, help_text="When the alleged incident ended" ) # Entity relationships live on the CaseEntityRelationship bind (the From 45a24cd4f21620572f80a77b45fe61e215031ff5 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Sat, 8 Aug 2026 13:30:49 -0700 Subject: [PATCH 26/29] fix(casework): page hearings on next, and stop rung 1 binding a non-person MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_hearings exited on `len(batch) < 100`, but config.settings uses plain PageNumberPagination with no page_size_query_param -- page_size is ignored and every page is 20 rows, so the loop always stopped after page 1. Measured: 3 of 77 sampled FY078/079 cases carry more than 20 hearings (max 27), and a deciding फैसला row in the dropped tail silently changes case_end_date and bind_outcome. Also from review: rung 1 copied the court row's nes_id on IRI validity alone, the one path by which an office could be bound as the accused individual; a dry run re-read every case for an If-Match it can never send; and party_name's docstring still claimed both de-dup paths key on the same string. Co-Authored-By: Claude Opus 5 (1M context) --- casework/common/api.py | 12 +++++-- casework/court_record.py | 10 ++++-- casework/enrich_court_record.py | 39 +++++++++++++++++----- tests/casework/test_api.py | 37 ++++++++++++++++++-- tests/casework/test_enrich_court_record.py | 39 ++++++++++++++++++++++ 5 files changed, 122 insertions(+), 15 deletions(-) diff --git a/casework/common/api.py b/casework/common/api.py index 61cea34f..8aa11b3b 100644 --- a/casework/common/api.py +++ b/casework/common/api.py @@ -407,7 +407,15 @@ def list_hearings(self, court, number, timeout=60): Pages by page NUMBER and ignores the response's `next` URL, for the same reason `get_court_case_entities` does: `get()` concatenates path onto - base_url, so an absolute `next` would produce a doubled prefix. + base_url, so an absolute `next` would produce a doubled prefix. Its + PRESENCE is still the termination signal, though -- a short page is not + the end. `config.settings` configures plain `PageNumberPagination`, + which defines no `page_size_query_param`, so `page_size` is IGNORED and + every page is `PAGE_SIZE` (20) rows. Exiting on `len(batch) < 100` + therefore returned page 1 and stopped on EVERY case: 3 of 77 sampled + FY078/079 cases carry more than 20 hearings (max 27), and a deciding + `फैसला` row sitting in the dropped tail silently changes `end_date` and + `bind_outcome`. """ path = (f"/courtcases/{urllib.parse.quote(str(court), safe='')}" f"/{urllib.parse.quote(str(number), safe='')}/hearings") @@ -416,7 +424,7 @@ def list_hearings(self, court, number, timeout=60): data = self.get(path, {"page": page, "page_size": 100}, timeout=timeout) batch = data.get("results") or [] rows.extend(batch) - if len(batch) < 100: + if not batch or not data.get("next"): return rows page += 1 diff --git a/casework/court_record.py b/casework/court_record.py index 51a25384..2a63748c 100644 --- a/casework/court_record.py +++ b/casework/court_record.py @@ -103,9 +103,13 @@ def is_defendant(party): def party_name(party): """The party's name, stripped, or "" when the row carries none. - Shared with `is_defendant` for the same reason: the de-dup on both paths - keys on this exact string, so one path stripping and the other not would - make the same person two entities. + Shared with `is_defendant` so the two paths cannot disagree on WHICH string + a party's name is. They no longer de-dup on the same key, though: + `defendant_names` keys on this exact string, while + `enrich_court_record._accused_binds` keys on `normalise_name` of it, so that + two punctuation variants of one name on one case collapse to a single row + and match the held-name index. Strictly coarser, and benign only because + `defendant_names` is off the enricher path. """ return (party.get("name") or "").strip() diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 16f071ed..724a6a78 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -316,6 +316,15 @@ def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, """ row_nes_id = (row_nes_id or "").strip() if row_nes_id and is_valid_entity_iri(row_nes_id): + # `_is_person` too, not just a well-formed IRI: rungs 2 and 3 can only + # ever produce a `person`, so without this rung 1 is the one way a + # non-person IRI reaches an `accused` bind -- an office or a company + # named as the accused individual. Not dead: this cohort carries no + # `nes_id` at all, but `special/080-cr-0111` was backfilled with 185. + if not _is_person(row_nes_id): + return Resolution("", "failed", + f"the court row's nes_id {row_nes_id} is not a " + f"{PERSON_PREFIX} entity") return Resolution(row_nes_id, "nes_id") try: @@ -902,6 +911,11 @@ def main(argv=None): # collapse this split exists to close. A read failure here costs only # that case; it is dropped from `readable_cases` before pass 2 runs. court_records, readable_cases = {}, [] + # Dry runs only. Pass 2 re-reads each case for a FRESH ETag, but a dry run + # never PATCHes, so that ETag is read and discarded -- one wasted request + # per case, ~2,900 on a full-corpus dry run against a measured 4,470/hour + # budget. Kept empty under --apply so the re-read there is unconditional. + dry_run_details = {} for i, case in enumerate(cases, 1): slug = case.get("slug") or "" if not slug: @@ -925,6 +939,8 @@ def main(argv=None): stats["error"] = stats.get("error", 0) + 1 continue readable_cases.append(case) + if args.dry_run: + dry_run_details[slug] = case_detail court_records[slug] = court_record_for_case(api, case_detail) # Pass 1 pays one HTTP round trip per case before any write happens, # so a full-corpus run is silent for hours without this -- the same @@ -983,14 +999,21 @@ def main(argv=None): # read: it is cached from pass 1 and passed straight into `plan_case`. for case in readable_cases: slug = case.get("slug") or "" - try: - case_detail, etag = api.get_case_with_etag(slug) - except Exception as exc: # noqa: BLE001 - one case's read failure is not the run's - log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, - step="court_read", status="unreadable", - detail=f"{type(exc).__name__}") - stats["error"] = stats.get("error", 0) + 1 - continue + if slug in dry_run_details: + # Dry run: reuse pass 1's read. The ETag is deliberately "" because + # nothing here will send an If-Match -- `apply_plan` is unreachable + # under --dry-run, and handing back a real ETag would imply this + # path could write. + case_detail, etag = dry_run_details[slug], "" + else: + try: + case_detail, etag = api.get_case_with_etag(slug) + except Exception as exc: # noqa: BLE001 - one case's read failure is not the run's + log_event(logger, events, run_id=run_id, stage=STAGE, slug=slug, + step="court_read", status="unreadable", + detail=f"{type(exc).__name__}") + stats["error"] = stats.get("error", 0) + 1 + continue # `court_records[slug]`, never `.get`: every slug in `readable_cases` # was inserted into `court_records` in the same pass-1 iteration, so diff --git a/tests/casework/test_api.py b/tests/casework/test_api.py index 2cf69642..89ae995a 100644 --- a/tests/casework/test_api.py +++ b/tests/casework/test_api.py @@ -1069,14 +1069,47 @@ def fake_get(path, params=None, timeout=60): def test_list_hearings_follows_pages_by_number(monkeypatch): api = CaseworkApi("http://127.0.0.1:48010", token="t") pages = { - 1: {"results": [{"hearing_date_ad": "2024-06-04"}] * 100}, - 2: {"results": [{"hearing_date_ad": "2024-06-03"}]}, + 1: {"results": [{"hearing_date_ad": "2024-06-04"}] * 100, "next": "?page=2"}, + 2: {"results": [{"hearing_date_ad": "2024-06-03"}], "next": None}, } monkeypatch.setattr(api, "get", lambda path, params=None, timeout=60: pages[params["page"]]) rows = api.list_hearings("special", "079-CR-0151") assert len(rows) == 101 +def test_list_hearings_keeps_paging_when_a_short_page_still_carries_next(monkeypatch): + # The production shape, and the bug this pins. `config.settings` configures + # plain `PageNumberPagination`, which defines no `page_size_query_param`, so + # the `page_size=100` this method sends is IGNORED and every page comes back + # at PAGE_SIZE (20). Exiting on `len(batch) < 100` therefore stopped after + # page 1 on every case: 3 of 77 sampled FY078/079 cases carry more than 20 + # hearings (special/078-CR-0100 has 27), and a deciding `फैसला` row in the + # dropped tail silently changes `end_date` and `bind_outcome`. + api = CaseworkApi("http://127.0.0.1:48010", token="t") + pages = { + 1: {"results": [{"hearing_date_ad": "2024-01-01", "case_status": "स्थगित"}] * 20, + "next": "https://api.jawafdehi.org/api/courtcases/special/078-CR-0100/hearings?page=2"}, + 2: {"results": [{"hearing_date_ad": "2024-06-04", "case_status": "फैसला"}] * 7, + "next": None}, + } + monkeypatch.setattr(api, "get", lambda path, params=None, timeout=60: pages[params["page"]]) + rows = api.list_hearings("special", "078-CR-0100") + assert len(rows) == 27 + # The deciding rows live only on page 2 -- a length-based exit drops them all. + assert any(r["case_status"] == "फैसला" for r in rows) + + +def test_list_hearings_stops_on_an_empty_page_even_if_next_lies(monkeypatch): + # A `next` that points past the end must not spin forever. + api = CaseworkApi("http://127.0.0.1:48010", token="t") + pages = { + 1: {"results": [{"hearing_date_ad": "2024-06-04"}] * 20, "next": "?page=2"}, + 2: {"results": [], "next": "?page=3"}, + } + monkeypatch.setattr(api, "get", lambda path, params=None, timeout=60: pages[params["page"]]) + assert len(api.list_hearings("special", "079-CR-0151")) == 20 + + def test_patch_case_sends_scalars_and_a_whole_list_in_one_request(monkeypatch): api = CaseworkApi("http://127.0.0.1:48010", token="t") seen = {} diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 30c9d38f..4260863f 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -148,6 +148,21 @@ def test_a_row_carrying_an_nes_id_is_a_pure_copy(): assert api.posted == [] +def test_a_row_nes_id_that_is_not_a_person_is_refused(): + # Rungs 2 and 3 can only ever produce a `person`, so rung 1 is the single + # way a non-person IRI could reach an `accused` bind -- an office named as + # the accused individual. Not a dead path: the FY078/079 cohort carries no + # `nes_id` at all, but `special/080-cr-0111` was backfilled with 185 of them. + api = _SearchApi() + office = "https://jawafdehi.org/entity/organization/malpot-karyalaya-jhapa" + got = resolve_defendant(api, "मालपोत कार्यालय झापा", office, citation="", + live_prefixes=["person"], run_entities={}, dry_run=True) + assert got.nes_id == "" + assert got.how == "failed" + assert "not a person entity" in got.reason + assert api.posted == [] + + def test_one_exact_person_match_binds(): # A COMPLETE window with one hit is the clean case: nothing else can be # hiding, so the match is safe to bind. @@ -2043,6 +2058,30 @@ def test_pass_2_reuses_the_cached_court_record_but_gets_a_fresh_etag(tmp_path, m assert api.call_counts["get_courtcase"] == 1 +def test_a_dry_run_does_not_re_read_a_case_for_an_etag_it_cannot_use( + tmp_path, monkeypatch, +): + # Pass 2's second read exists ONLY for a fresh If-Match ETag, and a dry run + # never PATCHes -- so on --dry-run it is a wasted request per case, ~2,900 + # on a full-corpus sweep against a measured 4,470/hour budget. The --apply + # test above pins that the re-read is still unconditional when it matters. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + api = _CliApi( + _case(), + detail={"registration_date_ad": "2023-06-22"}, hearings=[DECIDED], + parties=[{"side": "defendant", "name": "कृष्ण प्रसाद यादव", "nes_id": YADAV}], + ) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + assert api.call_counts["get_case_with_etag"] == 1 + assert api.call_counts["get_courtcase"] == 1 + assert api.patch_calls == [] + + def test_the_module_imports_without_django(tmp_path): """The standalone constraint, pinned deterministically. From b6836ab89d0aa1612a6bd3ebd1e8eaf300dadd77 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Mon, 10 Aug 2026 01:17:47 -0700 Subject: [PATCH 27/29] feat(casework): settle held defendant names from their press releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A court record names a defendant and says nothing else about them -- no address on any of the 1,414 rows in the FY078/079 census, no nes_id. So `held_names` holds any name landing on two or more cases in a run, because binding one entity to both would state "this man did both" and binding two would duplicate one man. Until now that hold ended the matter: the name went to a file and a human went reading. The press release settles most of them. CIAA writes the district, the local unit and the office into its own title, and that is enough to tell an elected वडा अध्यक्ष in Rautahat from a contracted वातावरण अधिकृत in Jhapa -- the real collision the FY078/079 dry run surfaced, where both cases name `ज्ञानेन्द्र चौधरी`. `casework/held_identity.py` builds one compact card per case and asks the model one question per HELD name. Measured shape: 80 of ~1,414 defendant rows carry a name on two cases, so a full-corpus run makes 80 calls, not 1,414 and not one per case. Every card field comes from the case payload pass 1 has already read, so this adds no HTTP request to a stage measured at 8.8 per case against a 5,000/hour ceiling. What the verdict may do is deliberately narrow: different each case creates its own entity, separated by a discriminator DERIVED from the case (its one district, else its court case number) -- never text the model wrote, since the fragment lands in a permanent public IRI. Ladder rung 2 is skipped: the verdict has just said these are two people, so the single existing entity carrying the name cannot be assumed to be either of them. same one entity, bound to both. The `run_entities` address component is overridden so an address on one case's row only cannot split the person the verdict just merged. anything the name stays held, exactly as before. else `HeldVerdict.is_actionable` is the gate: high confidence, evidence past a length floor (`salvage_json` closes a truncated reply, so a cut-off verdict arrives well-formed and worthless), and a per-case description covering every case asked about. A provider outage is reported as `unavailable` and holds every name rather than failing the run -- verified against production, where a missing SECRET_KEY did exactly that and the dates still planned. Rung 1 still outranks a verdict. The court row's own `nes_id` is what the portal asserts about its own record, and a model's inference does not override a stated identity. `--no-held-compare` restores the previous behaviour and spends no tokens. A run holding no name never imports the LLM stack at all, which stays the ordinary case for this stage. The stage tier moves to premium for the same reason `news`'s bind decision is premium: it decides whether to publicly attach a named person to a corruption case. Verified against production, dry run, both colliding cases: the model returned `different/high` with the districts and posts stated, and each case planned its own entity (`.../jnyanendra-caudhari-rautahat` and `.../jnyanendra-caudhari-079-cr-0071`, the second falling back to the docket number because that case binds two districts). Nothing was written. Co-Authored-By: Claude Opus 5 (1M context) --- casework/common/llm.py | 14 +- casework/enrich_court_record.py | 254 +++++++++-- casework/held_identity.py | 360 ++++++++++++++++ tests/casework/test_enrich_court_record.py | 473 ++++++++++++++++++++- tests/casework/test_held_identity.py | 326 ++++++++++++++ 5 files changed, 1384 insertions(+), 43 deletions(-) create mode 100644 casework/held_identity.py create mode 100644 tests/casework/test_held_identity.py diff --git a/casework/common/llm.py b/casework/common/llm.py index 895fe04e..e48192ce 100644 --- a/casework/common/llm.py +++ b/casework/common/llm.py @@ -69,10 +69,16 @@ # the decision and leaves the gate cheap. Re-measure with the live test # before changing it again -- do not re-argue it from first principles. "news": "premium", - # Registered because `test_stage_names_match_llm_tier_names` pins the pair, - # not because a model runs. `court_record` makes ZERO LLM calls -- a run - # that spends no tokens is its success case, not a shortfall. - "court_record": "cheap", + # `court_record` reads its dates and defendants straight off the court + # record and needs no model for either. Its ONE call is + # `held_identity.compare_held`, fired only for a name the run found on two + # or more cases -- 80 of ~1,414 measured defendant rows -- and it decides + # whether two same-named defendants are one person. Getting that wrong + # either attaches someone to a case they were never in or splits one + # person's record, so it takes the same tier as `news`'s bind decision for + # the same reason. A run that holds no name spends nothing and never + # reaches the provider at all. + "court_record": "premium", } DEFAULT_TIER = "cheap" diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index 724a6a78..af485dc5 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -1,10 +1,18 @@ #!/usr/bin/env python """Accused binds and case dates, read from the case's own NGM court record. -Zero LLM calls, zero Django, zero source documents. The court record states -these facts rather than inferring them: a defendant is a defendant because a -charge sheet says so, and a verdict date is a verdict date because the Special -Court's docket says so. +Zero Django, zero source documents. The court record states these facts rather +than inferring them: a defendant is a defendant because a charge sheet says so, +and a verdict date is a verdict date because the Special Court's docket says so. + +ONE THING IS NOT IN THE COURT RECORD: whether two cases naming the same person +mean the same human being. Those rows carry a name and nothing else -- no +address on any of the 1,414 in the census -- so `held_names` holds such a name +and `casework.held_identity` asks the model to compare the cases' press +releases, which do state district and office. That is this stage's only model +call, it fires once per HELD name rather than per case, and an answer that is +not high-confidence leaves the name held exactly as before. `--no-held-compare` +turns it off, restoring a run that spends no tokens at all. WHAT IT WRITES, in one conditional PATCH per case (`CaseworkApi.patch_case`): @@ -86,6 +94,7 @@ print_summary, setup_logging, ) +from casework.common.llm import bootstrap, tier_for from casework.common.review import ReviewRow, build_review_file from casework.common.select import ENRICHABLE_STATES, select_for_run from casework.court_record import ( @@ -97,6 +106,8 @@ ) from casework.entity_identity import entity_slug, prefix_is_creatable from casework.entity_resolver import normalise_name +from casework.held_identity import case_identity, compare_held +from casework.held_identity import discriminator as held_discriminator from casework.enrich_related_entities import ( bind_key, current_entity_binds, @@ -271,9 +282,16 @@ def exact_person_match(api, name): return next(iter(hits)), "" -def run_entity_key(name, address): +def run_entity_key(name, address, discriminator=""): """The `run_entities` key for one court-record party row. + `discriminator` is set only for a name a `different` held verdict split + (see `held_identity.discriminator`). It is part of the key because that + verdict's whole content is "these two are not the same person", and a + shared key would hand the second case the first case's entity -- the exact + reuse this map exists to perform, applied to the one pair where it is + wrong. + NAME PLUS ADDRESS, never the bare name. `run_entities` is shared across every case in the run so that one person named on two cases becomes ONE entity rather than two. Keyed on the name alone that reuse is @@ -295,11 +313,12 @@ def run_entity_key(name, address): halves go through `normalise_name` so a spacing or punctuation difference in the portal's transcription does not split one person into two entities. """ - return normalise_name(name), normalise_name(address or "") + return normalise_name(name), normalise_name(address or ""), discriminator def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, - run_entities, dry_run, address=""): + run_entities, dry_run, address="", discriminator="", + distinct=False): """Turn one court-record defendant name into an NES entity id. The ladder, top to bottom: @@ -307,6 +326,13 @@ def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, 2. exactly one person entity with that identical name 3. create the entity from the court record + `distinct` SKIPS rung 2, and is set only for a name a `different` held + verdict split. Rung 2 binds the single existing person carrying this name, + and the verdict has just said the cases name two people -- so at most one + of them is that entity and nothing here can say which. Matching would give + both cases the same IRI, which is the merge the split exists to prevent. + `discriminator` then separates their created slugs. + `run_entities` maps a `run_entity_key` (name AND address) to an IRI already created THIS RUN, and is shared across cases on purpose: without it, two cases naming the same defendant create two entities. Nothing here raises -- @@ -327,14 +353,20 @@ def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, f"{PERSON_PREFIX} entity") return Resolution(row_nes_id, "nes_id") - try: - matched, why = exact_person_match(api, name) - except Exception as exc: # noqa: BLE001 - one bad search costs this name, not the case - return Resolution("", "failed", f"could not search for a match ({type(exc).__name__})") - if matched: - return Resolution(matched, "exact") + if distinct: + why = ("a held verdict split this name across cases, so the one " + "existing entity carrying it cannot be assumed to be this " + "defendant") + else: + try: + matched, why = exact_person_match(api, name) + except Exception as exc: # noqa: BLE001 - one bad search costs this name, not the case + return Resolution("", "failed", + f"could not search for a match ({type(exc).__name__})") + if matched: + return Resolution(matched, "exact") - key = run_entity_key(name, address) + key = run_entity_key(name, address, discriminator) if key in run_entities: return Resolution(run_entities[key], "created", "reused from this run") @@ -356,6 +388,17 @@ def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, slug = entity_slug(name) if not slug: return Resolution("", "failed", f"{why}; the name cannot be slugged") + if distinct: + # Without a discriminator both split cases derive the SAME slug, so the + # second create 409s and binds nothing -- safe, but it reports a + # collision where the real problem is that the case carries no fact to + # name its own person by. Said plainly instead. + if not discriminator: + return Resolution("", "failed", + f"{why}; and the case carries neither a single " + "district nor a court case number to separate " + "this defendant's entity from the namesake's") + slug = f"{slug}-{discriminator}" iri = build_entity_iri(PERSON_PREFIX, slug) if dry_run: @@ -513,7 +556,60 @@ def held_names(index): return {name: slugs for name, slugs in index.items() if len(slugs) > 1} -def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, held): +def bindable_defendants(records): + """Defendant names on this case's bindable references, order preserved. + + The same `BINDABLE_CODES`/`is_defendant`/`party_name` filter + `defendant_name_index` applies, over records already in hand. Feeds the + identity cards, so a name that could never be held never costs a + `description` scan either. + """ + names, seen = [], set() + for record in records: + if case_number_code(record["number"]) not in BINDABLE_CODES: + continue + for party in record.get("parties") or (): + if not is_defendant(party): + continue + name = party_name(party) + key = normalise_name(name) + if not name or key in seen: + continue + seen.add(key) + names.append(name) + return names + + +def _held_outcome(slugs, case_slug, verdict, identity): + """`(row_reason, distinct, discriminator, address_override)` for a held name. + + Returns `row_reason` non-empty when the name stays HELD -- the caller emits + the report row and binds nothing. Otherwise the three write parameters that + an actionable verdict earns. + + A `same` verdict returns `address_override=""` so both cases key the same + `run_entities` entry and share one created entity. The address is what + `run_entity_key` normally uses to keep namesakes apart, and this verdict has + replaced it as the thing establishing identity -- without the override, one + case carrying an address and the other not would key differently and mint + two entities for the person a `same` verdict just merged. + """ + others = sorted(s for s in slugs if s != case_slug) + shared = "also names a defendant on " + ", ".join(others) + if verdict is None: + return f"{shared} -- held for a human to rule on", False, "", None + stated = f"{verdict.verdict}/{verdict.confidence or 'no confidence'}" + if not verdict.is_actionable: + why = "the model did not answer" if verdict.failed else stated + return (f"{shared} -- held for a human to rule on ({why}: " + f"{verdict.evidence})"), False, "", None + if verdict.verdict == "different": + return "", True, held_discriminator(identity) if identity else "", None + return "", False, "", "" + + +def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, + held, decisions=None, identity=None): """`(items, rows, skips)` -- binds, a report row each, and non-prosecution skips. De-duplicated by name across every court reference on the case, order @@ -524,8 +620,10 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, which is why this reads the parties itself rather than calling `defendant_names`. - A held name (see `held_names`) is never told apart from a genuine match - by anything this function alone can see. + A held name (see `held_names`) is never told apart from a genuine match by + anything this function alone can see. `decisions` -- `{normalised name: + HeldVerdict}` from `held_identity.compare_held` -- is what can, and an + absent or non-actionable verdict leaves the name held exactly as before. """ outcome = bind_outcome(records) # The first BINDABLE record, never `records[0]`: a rejected reference @@ -551,21 +649,30 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, continue seen.add(key) other_slugs = held.get(key) + distinct, disc, address = False, "", party.get("address") + settled = "" if other_slugs is not None: - others = sorted(s for s in other_slugs if s != case.get("slug")) - rows.append({ - "slug": case.get("slug"), "name": name, "how": "held", - "nes_id": "", "outcome": outcome, - "reason": ("also names a defendant on " + ", ".join(others) - + " -- held for a human to rule on"), - "court_case": f"{record['court']}/{record['number']}"}) - continue + verdict = (decisions or {}).get(key) + reason, distinct, disc, override = _held_outcome( + other_slugs, case.get("slug"), verdict, identity) + if reason: + rows.append({ + "slug": case.get("slug"), "name": name, "how": "held", + "nes_id": "", "outcome": outcome, "reason": reason, + "court_case": f"{record['court']}/{record['number']}"}) + continue + if override is not None: + address = override + settled = (f"held verdict {verdict.verdict}/{verdict.confidence}" + f": {verdict.evidence}") got = resolve_defendant( api, name, party.get("nes_id"), citation=citation, live_prefixes=live_prefixes, run_entities=run_entities, - dry_run=dry_run, address=party.get("address")) + dry_run=dry_run, address=address, discriminator=disc, + distinct=distinct) row = {"slug": case.get("slug"), "name": name, "how": got.how, - "nes_id": got.nes_id, "outcome": outcome, "reason": got.reason, + "nes_id": got.nes_id, "outcome": outcome, + "reason": "; ".join(p for p in (settled, got.reason) if p), "court_case": f"{record['court']}/{record['number']}"} rows.append(row) if not got.nes_id: @@ -581,7 +688,7 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run, held, - court_record=None): + court_record=None, decisions=None, identity=None): """Build the write for one case; writes nothing. Reads the court record itself unless `court_record` -- a pass-1 @@ -628,7 +735,8 @@ def plan_case(api, case, etag, *, live_prefixes, run_entities, dry_run, held, items, rows, accused_skips = _accused_binds( api, case, records, live_prefixes=live_prefixes, - run_entities=run_entities, dry_run=dry_run, held=held) + run_entities=run_entities, dry_run=dry_run, held=held, + decisions=decisions, identity=identity) skips.extend(accused_skips) # `current_entity_binds`, NOT the raw `case["entities"]` list: the read @@ -841,14 +949,29 @@ def _log_plan(logger, events, run_id, plan): return held_count, resolved_count -def _held_report(held, court_records): - """One entry per held name: its cases and the defendant rows behind it. +def _verdict_report(verdict): + """One held verdict as JSON, or None when the name was never compared.""" + if verdict is None: + return None + return {"verdict": verdict.verdict, "confidence": verdict.confidence, + "evidence": verdict.evidence, "per_case": verdict.per_case, + "acted_on": verdict.is_actionable, + "model_answered": not verdict.failed} + + +def _held_report(held, court_records, verdicts=None): + """One entry per held name: its cases, the rows behind it, and any verdict. Recomputed from `court_records` (the pass-1 cache) rather than collected from `plan.rows` -- a case that never reaches `_accused_binds` (wrong state, no `entities` key) still needs to show up here, and re-applies `defendant_name_index`'s own `BINDABLE_CODES` filter since `court_records` holds every reference read, not just the bindable ones. + + A name with an ACTED-ON verdict stays in this file. It is no longer waiting + on a human, but it is the record of a merge or a split this run performed on + the model's word, which is exactly what a reviewer needs to be able to find + afterwards -- `acted_on` tells the two apart. """ report = [] for name, slugs in sorted(held.items()): @@ -869,18 +992,20 @@ def _held_report(held, court_records): rows.append({"slug": slug, "court_case": f"{record['court']}/{record['number']}", "name": party_name(party)}) - report.append({"name": name, "cases": sorted(slugs), "rows": rows}) + report.append({"name": name, "cases": sorted(slugs), "rows": rows, + "comparison": _verdict_report((verdicts or {}).get(name))}) return report -def write_held_file(path, held, court_records, *, run_id): +def write_held_file(path, held, court_records, *, run_id, verdicts=None): """Write the held-names file beside the review file. Devanagari unescaped (`ensure_ascii=False`), matching every other casework output file -- an escaped `\\u0915` cannot be reviewed. """ path.parent.mkdir(parents=True, exist_ok=True) - payload = {"run_id": run_id, "held": _held_report(held, court_records)} + payload = {"run_id": run_id, + "held": _held_report(held, court_records, verdicts)} path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") return path @@ -888,6 +1013,11 @@ def write_held_file(path, held, court_records, *, run_id): def main(argv=None): parser = add_common_args(argparse.ArgumentParser( description="Bind court-record defendants and fill the case date fields.")) + parser.add_argument( + "--no-held-compare", action="store_true", + help="Do not ask the model whether two same-named defendants are one " + "person; leave every held name for a human. Restores the " + "pre-comparison behaviour, and makes the run spend no tokens.") args = parser.parse_args(argv) setup_logging(args.verbose) logger, run_id, paths = configure_run_logging(STAGE, verbose=args.verbose) @@ -910,7 +1040,7 @@ def main(argv=None): # index would hold a name on case A and bind it on case B, the exact # collapse this split exists to close. A read failure here costs only # that case; it is dropped from `readable_cases` before pass 2 runs. - court_records, readable_cases = {}, [] + court_records, readable_cases, identities = {}, [], {} # Dry runs only. Pass 2 re-reads each case for a FRESH ETag, but a dry run # never PATCHes, so that ETag is read and discarded -- one wasted request # per case, ~2,900 on a full-corpus dry run against a measured 4,470/hour @@ -942,6 +1072,15 @@ def main(argv=None): if args.dry_run: dry_run_details[slug] = case_detail court_records[slug] = court_record_for_case(api, case_detail) + # Built HERE, not after `held` is known, because this is the only + # moment the run holds both the case payload and that case's defendant + # names without a second read. The card keeps only short fields plus a + # bounded excerpt per name, so retaining one per case costs far less + # than retaining `case_detail` itself would. + records_for_card, _ = court_records[slug] + identities[slug] = case_identity( + case_detail, bindable_defendants(records_for_card), + court_cases=[r["number"] for r in records_for_card]) # Pass 1 pays one HTTP round trip per case before any write happens, # so a full-corpus run is silent for hours without this -- the same # reason `CaseworkApi.iter_cases` narrates its own page fetches. @@ -992,6 +1131,45 @@ def main(argv=None): step="held_index", status="ok", detail=index_detail, level=logging.WARNING if reasons else logging.INFO) + # The stage's ONLY model calls: one per HELD name, between the two passes. + # A run holding nothing spends no tokens and never imports the LLM stack -- + # which is still this stage's ordinary case, since only 80 of ~1,414 + # measured defendant rows carry a name that lands on two cases. + verdicts = {} + if held and not args.no_held_compare: + try: + bootstrap(args.provider, args.model) + from llm.invoke import invoke_json + except Exception as exc: # noqa: BLE001 - no model means every name stays held + log_event(logger, events, run_id=run_id, stage=STAGE, slug="", + step="held_compare", status="unavailable", + detail=f"{type(exc).__name__}: {exc} -- every held name " + "stays held for a human", + level=logging.WARNING) + else: + def _log_verdict(name, slugs, verdict): + log_event(logger, events, run_id=run_id, stage=STAGE, slug="", + step="held_compare", + status="ok" if not verdict.failed else "failed", + detail=(f"{name} on {', '.join(slugs)} -> " + f"{verdict.verdict}/" + f"{verdict.confidence or 'no confidence'}" + f"{'' if verdict.is_actionable else ' (still held)'}" + f": {verdict.evidence}")) + + logger.info("comparing %d held name(s) against their press releases", + len(held)) + verdicts = compare_held(held, identities, invoke_json, + tier=tier_for(STAGE), on_verdict=_log_verdict) + acted = sum(1 for v in verdicts.values() if v.is_actionable) + stats["held_compared"] = len(verdicts) + stats["held_settled"] = acted + log_event(logger, events, run_id=run_id, stage=STAGE, slug="", + step="held_compare", status="ok", + detail=f"compared {len(verdicts)} held name(s); {acted} " + f"settled by the model, {len(verdicts) - acted} " + "still held for a human") + # Pass 2: plan (and maybe write) every case against that SAME `held` # mapping. `get_case_with_etag` is re-read here for a FRESH ETag -- # pass 1's is stale by the time this write would land, and a stale @@ -1022,7 +1200,8 @@ def main(argv=None): # with no hold protection at all. plan = plan_case(api, case_detail, etag, live_prefixes=live_prefixes, run_entities=run_entities, dry_run=args.dry_run, - held=held, court_record=court_records[slug]) + held=held, court_record=court_records[slug], + decisions=verdicts, identity=identities.get(slug)) # `detail=` carries `plan.skips` even on a clean "selected": a case # can reach "would-patch"/"nothing-to-do" with a partially-unreadable # court record (some references 404, at least one did not), and the @@ -1121,7 +1300,8 @@ def main(argv=None): stats["patched"] = stats.get("patched", 0) + 1 held_path = review.path.parent / (review.path.stem + ".held.json") - write_held_file(held_path, held, court_records, run_id=run_id) + write_held_file(held_path, held, court_records, run_id=run_id, + verdicts=verdicts) review.write() log_run_footer(logger, stage=STAGE, stats=stats, duration_s=time.time() - started) diff --git a/casework/held_identity.py b/casework/held_identity.py new file mode 100644 index 00000000..ff83e14f --- /dev/null +++ b/casework/held_identity.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python +"""Tell two same-named court defendants apart, from their press releases. + +A court record carries a defendant's NAME and nothing else usable: 0 of the +1,414 defendant rows in the FY078/079 census carry an address, and none carry +an `nes_id`. So `enrich_court_record.held_names` holds any name appearing on +two or more cases in a run instead of guessing whether that is one person or +two -- binding one entity to both would state "this man did both", and binding +two would duplicate one man. + +The press release is where the identifying detail lives. CIAA writes the +district, the local unit and the office into its own title: + + जिल्ला झापा, दमक नगरपालिकाका वातावरण अधिकृत ज्ञानेन्द्र चौधरी, ... + जिल्ला रौतहट, फतुवा विजयपुर नगरपालिका ... बडा अध्यक्षहरु ... समेत २१ जनाउपर + +Those two lines settle that particular collision on their own -- an elected +ward chair in Rautahat is not a contracted environment officer in Jhapa. + +ONE MODEL CALL PER HELD NAME. Not per case, and not per pair: a name on three +cases is one call comparing all three. On the measured corpus that is 80 calls +for ~1,414 defendant rows, because only 80 names land on more than one case. + +NO EXTRA HTTP. Every field of a `CaseIdentity` comes out of the case payload +`enrich_court_record`'s pass 1 has already read. The binder runs at a measured +8.8 requests per case against a 5,000/hour ceiling; a comparison that fetched +its own sources would spend that headroom. + +WHAT THE VERDICT MAY DO is decided by `HeldVerdict.is_actionable`, not here. +`unclear` is a first-class answer, and the honest one whenever the cards do not +carry a distinguishing fact -- a case with no bound press release cannot be +compared at all, and is refused before the model is ever called. +""" + +import re +from dataclasses import dataclass, field + +from casework.entity_resolver import normalise_name + +#: Characters of `description` kept either side of a name mention. +MENTION_WINDOW = 220 + +#: Mentions kept per name per case. The first few carry the role; a long +#: judgment repeats the name in every procedural paragraph after that. +MAX_MENTIONS = 3 + +#: Minimum `evidence` length for an actionable verdict. A SHAPE floor, not a +#: measured one: `llm.invoke.salvage_json` repairs a reply truncated at +#: `max_tokens` by closing the open string, so an overflowing call yields a +#: verdict whose evidence stops mid-sentence. Non-blank, and worthless as the +#: audit record of why two people were merged or split. +EVIDENCE_FLOOR = 40 + +#: Trailing NES disambiguation code on a location slug (`jhapa-np0104`). +_LOCATION_CODE = re.compile(r"-(?:np|pa)[0-9a-z]*[0-9][0-9a-z]*$") + + +@dataclass(frozen=True) +class CaseIdentity: + """What one case says about the people it accuses. Built without HTTP. + + `mentions` is keyed by `normalise_name` of the defendant, matching the + `held` mapping's own keys, and is populated in pass 1 while that case's + court record is in hand -- which is the only moment the binder knows a + case's defendant names without a second read. + """ + slug: str + court_cases: tuple = () + title: str = "" + press_titles: tuple = () + districts: tuple = () + mentions: dict = field(default_factory=dict) + + def carries_identity(self, key): + """Whether this card says anything that could tell `key` from a namesake. + + The case title is deliberately NOT counted. Every title in this corpus + is generated to the same template (` समेत `), so it + separates nothing -- a card carrying only a title would send the model + two strings that differ merely in the number of co-defendants and + invite a verdict from the shared name alone. + """ + return bool(self.press_titles or self.districts or self.mentions.get(key)) + + +def _windows(text, name, *, window=MENTION_WINDOW, limit=MAX_MENTIONS): + """Up to `limit` excerpts of `text` around `name`, non-overlapping. + + Matched on the raw name rather than a normalised form: `description` is + prose, so there is no normalised copy of it to search, and the portal's + defendant spelling is what the prose uses. A name that does not appear + verbatim simply yields no excerpt, which `carries_identity` then reports as + "this card cannot tell them apart" -- the cautious answer. + """ + if not text or not name: + return () + found, at = [], 0 + while len(found) < limit: + hit = text.find(name, at) + if hit < 0: + break + found.append(text[max(0, hit - window):hit + len(name) + window]) + # Past the END of this window, not past the match: consecutive + # mentions one sentence apart would otherwise yield near-identical + # excerpts and spend the whole budget on one paragraph. + at = hit + len(name) + window + return tuple(found) + + +def _press_titles(case_detail): + """The `display_name` of every press release bound to the case. + + This is the highest-signal line available and it costs nothing: CIAA's own + title names the district, the local unit and the office. + """ + titles = [] + for entry in case_detail.get("evidence") or (): + material = (entry or {}).get("material") or {} + if material.get("material_type") != "press_release": + continue + name = (material.get("display_name") or "").strip() + if name: + titles.append(name) + return tuple(dict.fromkeys(titles)) + + +def _districts(case_detail): + """District names from the case's bound location entities. + + Read off the IRI tail with the NES disambiguation code stripped, so + `location/district/jhapa-np0104` and a bare `location/district/jhapa` -- + both of which sit on one real case -- collapse to the same `jhapa` instead + of counting as two districts and blocking `discriminator`. + """ + names = [] + for bind in case_detail.get("entities") or (): + nes_id = (bind or {}).get("nes_id") or "" + if "/district/" not in nes_id: + continue + tail = nes_id.rstrip("/").rsplit("/", 1)[-1] + tail = _LOCATION_CODE.sub("", tail) + if tail: + names.append(tail) + return tuple(dict.fromkeys(names)) + + +def case_identity(case_detail, names, *, court_cases=()): + """Build one case's `CaseIdentity` for `names`. No HTTP, no model call.""" + description = case_detail.get("description") or "" + mentions = {} + for name in names: + key = normalise_name(name) + if not key or key in mentions: + continue + found = _windows(description, name) + if found: + mentions[key] = found + return CaseIdentity( + slug=case_detail.get("slug") or "", + court_cases=tuple(court_cases), + title=(case_detail.get("title") or "").strip(), + press_titles=_press_titles(case_detail), + districts=_districts(case_detail), + mentions=mentions) + + +def discriminator(card): + """A slug fragment separating this case's person from their namesake. + + DERIVED, never taken from the model's reply. This fragment lands in a + permanent public entity IRI, so it is read off the case itself: the one + district the case is bound to, else the court case number. Both are facts + the case already records. + + Falls back whenever the district is not unique -- `079-CR-0071` is bound to + both Jhapa and Morang, because the river it concerns is the border between + them, and neither is "the" district of the accused. + """ + if len(card.districts) == 1: + return card.districts[0] + for number in card.court_cases: + if number: + return normalise_name(number).replace(" ", "-") + return "" + + +@dataclass(frozen=True) +class HeldVerdict: + """The model's answer for one held name. + + `failed` marks "the model did not answer" as distinct from "the model could + not tell". Both leave the name held; only one means the run is degraded. + Without the distinction a provider outage produces a full set of `unclear` + verdicts, which for this stage is an entirely ordinary-looking result. + """ + verdict: str + confidence: str = "" + evidence: str = "" + per_case: dict = field(default_factory=dict) + failed: bool = False + + @property + def is_actionable(self): + """Whether the binder may act on this instead of holding the name. + + Every condition is load-bearing: + + `high` is the bar because both actions are irreversible in public. A + `same` verdict merges two people's cases onto one entity; a `different` + verdict publishes two entities for what may be one man. Neither is a + coin-flip decision, and `medium` is the model saying it is guessing. + + `evidence` must clear `EVIDENCE_FLOOR` because it IS the audit record. + A verdict whose reasoning was truncated to `"रौतहट र झापा"` cannot be + reviewed, and this is the only place that reasoning is kept. + + `per_case` must name EVERY case, not just some: the operator reading + the held file needs the role this verdict assigns to each side, and a + `different` verdict that describes only one of three cases has not + actually separated the other two. + """ + if self.failed or self.verdict not in ("same", "different"): + return False + if self.confidence != "high" or len(self.evidence.strip()) < EVIDENCE_FLOOR: + return False + return bool(self.per_case) + + def covers(self, slugs): + """Whether `per_case` describes every case sharing the name.""" + return set(self.per_case) >= set(slugs) + + +SYSTEM = """You compare defendants named in Nepali anti-corruption filings. + +You are given ONE personal name and the cases that name it as a defendant +(प्रतिवादी). Decide whether those cases accuse the SAME human being or +DIFFERENT people who share a name. + +Weigh only identifying facts: + - district (जिल्ला) and local unit (नगरपालिका/गाउँपालिका) + - the office or post held (पद) -- an elected वडा अध्यक्ष is not a contracted + अधिकृत, and neither is a ठेकेदार + - dates of service, where a post is held continuously + +The shared name is NOT evidence of anything. Common Nepali surnames such as +चौधरी, यादव, साह, श्रेष्ठ and पौडेल recur constantly across unrelated people, and +these filings carry no address, citizenship number or father's name to +separate them. + +Answer "unclear" whenever the material does not settle it. "unclear" is a +correct and expected answer, and it is strongly preferred over a guess: a wrong +"same" publicly attaches a person to a case they were never in, and a wrong +"different" splits one person's record in two. + +Reply with JSON only: +{"verdict": "same" | "different" | "unclear", + "confidence": "high" | "medium" | "low", + "evidence": "one or two sentences citing the specific facts you compared", + "per_case": {"": "that case's post and place for this person"}} + +Use "high" only when a stated fact rules the alternative out, not when one +reading merely seems likelier.""" + + +def build_content(name, cards): + """The user message for one held name: its cards, one block per case.""" + key = normalise_name(name) + lines = [f"Name under comparison: {name}", ""] + for card in cards: + lines.append(f"## case slug: {card.slug}") + if card.court_cases: + lines.append(f"court case(s): {', '.join(card.court_cases)}") + if card.title: + lines.append(f"case title: {card.title}") + for title in card.press_titles: + lines.append(f"CIAA press release title: {title}") + if card.districts: + lines.append(f"districts bound to the case: {', '.join(card.districts)}") + for excerpt in card.mentions.get(key, ()): + lines.append(f"mention in the case summary: ...{excerpt}...") + lines.append("") + return "\n".join(lines) + + +def _verdict_from(reply): + """Parse the model's JSON into a `HeldVerdict`, or a failed one.""" + if not isinstance(reply, dict): + return HeldVerdict("unclear", failed=True, + evidence=f"the model returned {type(reply).__name__}, " + "not a JSON object") + verdict = str(reply.get("verdict") or "").strip().lower() + if verdict not in ("same", "different", "unclear"): + return HeldVerdict("unclear", failed=True, + evidence=f"the model returned verdict={verdict!r}, " + "which is not one of same/different/unclear") + per_case = reply.get("per_case") + if not isinstance(per_case, dict): + per_case = {} + return HeldVerdict( + verdict=verdict, + confidence=str(reply.get("confidence") or "").strip().lower(), + evidence=str(reply.get("evidence") or "").strip(), + per_case={str(k): str(v) for k, v in per_case.items()}) + + +def compare_identities(name, cards, invoke_json, *, tier="premium", usage=None, + max_tokens=700): + """One model call: is `name` one person across `cards`, or several? + + Refused WITHOUT a call when fewer than two cards carry a distinguishing + fact. A card with no press release, no district and no mention of the name + contributes only its slug and its templated title, so the model would be + left comparing the shared name against itself -- the one input the system + prompt forbids it to reason from. Cheaper and more honest to hold. + """ + key = normalise_name(name) + usable = [c for c in cards if c.carries_identity(key)] + if len(usable) < 2: + thin = ", ".join(c.slug for c in cards if not c.carries_identity(key)) + return HeldVerdict( + "unclear", + evidence=("not compared: no press release, district or summary " + f"mention to tell this name apart on {thin or 'these cases'}")) + try: + reply = invoke_json(SYSTEM, build_content(name, cards), + max_tokens=max_tokens, tier=tier, usage=usage) + except Exception as exc: # noqa: BLE001 - one name's call failing is not the run's + return HeldVerdict("unclear", failed=True, + evidence=f"the comparison call raised " + f"{type(exc).__name__}") + got = _verdict_from(reply) + if got.is_actionable and not got.covers(c.slug for c in cards): + # Actionable on its own fields, but silent about at least one case it + # was asked about. Downgraded rather than dropped: the verdict text is + # still worth showing a human, it just may not drive a bind. + missing = sorted({c.slug for c in cards} - set(got.per_case)) + return HeldVerdict("unclear", confidence=got.confidence, + evidence=(f"{got.evidence} [downgraded: the reply " + f"said nothing about {', '.join(missing)}]"), + per_case=got.per_case) + return got + + +def compare_held(held, cards_by_slug, invoke_json, *, tier="premium", usage=None, + on_verdict=None): + """`{name: HeldVerdict}` for every held name. One call each, in name order. + + `on_verdict(name, slugs, verdict)` is called as each answer lands so the + caller can log it while the sweep is still running -- these are the only + LLM calls in the stage, and a silent minutes-long gap between pass 1 and + pass 2 is what the binder's own pass-1 progress logging exists to avoid. + """ + verdicts = {} + for name, slugs in sorted(held.items()): + cards = [cards_by_slug[s] for s in sorted(slugs) if s in cards_by_slug] + got = compare_identities(name, cards, invoke_json, tier=tier, usage=usage) + verdicts[name] = got + if on_verdict: + on_verdict(name, sorted(slugs), got) + return verdicts diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index 4260863f..e3874817 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -2086,8 +2086,8 @@ def test_the_module_imports_without_django(tmp_path): """The standalone constraint, pinned deterministically. Checking only `returncode == 0` proves little on its own: - `casework.common.llm.bootstrap` (never called by this module, but the - thing this test guards against a future edit calling) sets + `casework.common.llm.bootstrap` -- which `main` DOES call, but only inside + the held-name comparison branch, never at import -- sets `DJANGO_SETTINGS_MODULE` itself via `os.environ.setdefault` and would fail closed here only because this shell has no `SECRET_KEY` -- a shell that exports a complete `.env` would let Django configure successfully, @@ -2107,3 +2107,472 @@ def test_the_module_imports_without_django(tmp_path): "assert not loaded, loaded"], env=env, capture_output=True, text=True) assert proc.returncode == 0, proc.stderr + + +# -------------------------------------------------------------------------- +# The held-name comparison (`casework.held_identity`) and what the binder is +# allowed to do with its answer. +# -------------------------------------------------------------------------- + +from casework.enrich_court_record import ( # noqa: E402 + _held_outcome, + bindable_defendants, + write_held_file, +) +from casework.held_identity import CaseIdentity, HeldVerdict # noqa: E402 + +SHARED = "कृष्ण प्रसाद यादव" +SHARED_KEY = normalise_name(SHARED) + + +@pytest.fixture(autouse=True) +def _no_live_model(monkeypatch): + """No test in this module may reach a real provider. + + Under pytest `DJANGO_SETTINGS_MODULE` is already configured, so `main`'s + `bootstrap()` and `from llm.invoke import invoke_json` both SUCCEED here -- + every CLI test that produces a held name would otherwise spend a real + premium call. Returning `{}` means "no verdict for any name", which is + precisely the pre-comparison behaviour those tests already assert, so this + stub changes no existing expectation. Tests about the comparison itself + override it; `test_the_comparison_sweep_runs_unless_it_is_turned_off` pins + that the sweep is genuinely reached, so this fixture cannot hide a removed + or broken sweep. + """ + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "compare_held", lambda *a, **kw: {}) + + +def _verdict(**over): + base = {"verdict": "different", "confidence": "high", + "evidence": ("Rautahat elected ward chair versus Jhapa contracted " + "environment officer -- different districts and posts."), + "per_case": {"case-a": "वडा अध्यक्ष, रौतहट", + "case-b": "वातावरण अधिकृत, झापा"}} + base.update(over) + return HeldVerdict(**base) + + +def _identity(slug, *, districts=(), court_cases=()): + return CaseIdentity(slug=slug, districts=tuple(districts), + court_cases=tuple(court_cases)) + + +def _binds(case_slug, *, held, decisions=None, identity=None, api=None, + run_entities=None, parties=None, dry_run=True): + record = _record(parties=parties or [{"side": "defendant", "name": SHARED}]) + return _accused_binds( + api or _SearchApi(), _case(slug=case_slug), [record], + live_prefixes=["person"], + run_entities={} if run_entities is None else run_entities, + dry_run=dry_run, held=held, decisions=decisions, identity=identity) + + +class _SlugStoreApi(_SearchApi): + """One slug, one entity -- what the server actually enforces. + + Needed for any test about entity SHARING: under `--dry-run` + `resolve_defendant` derives the IRI from the slug and never posts, so two + cases resolving the same name produce the same IRI whether or not they + shared a `run_entities` entry. Only a real create can tell reuse (one POST, + both bound) from a collision (two POSTs, the second binding nothing). + """ + + def create_entity(self, payload, timeout=60): + taken = {p["slug"] for p in self.posted} + self.posted.append(payload) + iri = build_entity_iri(PERSON_PREFIX, payload["slug"]) + if payload["slug"] in taken: + raise EntityAlreadyExists(iri) + return {"@id": iri} + + +# ------------------------------------------------- the hold, verdict by verdict + +def test_no_verdict_at_all_leaves_a_held_name_held(): + # The regression guard for the whole feature: an absent verdict must behave + # exactly as the binder did before the comparison existed. + items, rows, _ = _binds("case-a", + held={SHARED_KEY: frozenset({"case-a", "case-b"})}) + assert items == [] + assert rows[0]["how"] == "held" + assert "held for a human to rule on" in rows[0]["reason"] + + +@pytest.mark.parametrize("over", [ + {"confidence": "medium"}, + {"verdict": "unclear"}, + {"evidence": "छोटो"}, + {"per_case": {}}, +]) +def test_a_verdict_short_of_the_bar_leaves_the_name_held(over): + held = {SHARED_KEY: frozenset({"case-a", "case-b"})} + items, rows, _ = _binds("case-a", held=held, + decisions={SHARED_KEY: _verdict(**over)}) + assert items == [] + assert rows[0]["how"] == "held" + + +def test_a_held_row_quotes_what_the_model_actually_said(): + # The operator must see WHY it is still held: "unclear/low" and "the model + # never answered" call for different follow-up. + held = {SHARED_KEY: frozenset({"case-a", "case-b"})} + items, rows, _ = _binds( + "case-a", held=held, + decisions={SHARED_KEY: _verdict(verdict="unclear", confidence="low", + evidence="both cases name a मालपोत office")}) + assert items == [] + assert "unclear/low" in rows[0]["reason"] + assert "मालपोत" in rows[0]["reason"] + + +def test_a_failed_comparison_is_reported_as_the_model_not_answering(): + held = {SHARED_KEY: frozenset({"case-a", "case-b"})} + _, rows, _ = _binds("case-a", held=held, + decisions={SHARED_KEY: _verdict(failed=True)}) + assert "the model did not answer" in rows[0]["reason"] + + +# ------------------------------------------------------------ different: split + +def test_a_different_verdict_gives_each_case_its_own_entity(): + # Two real creates, two distinct slugs, neither colliding -- the split has + # to survive a server that enforces one slug per entity, not just produce + # two different strings in a dry run. + held = {SHARED_KEY: frozenset({"case-a", "case-b"})} + decisions = {SHARED_KEY: _verdict()} + api, run_entities = _SlugStoreApi(), {} + items_a, rows_a, _ = _binds("case-a", held=held, decisions=decisions, api=api, + identity=_identity("case-a", districts=["rautahat"]), + run_entities=run_entities, dry_run=False) + items_b, rows_b, _ = _binds("case-b", held=held, decisions=decisions, api=api, + identity=_identity("case-b", districts=["jhapa"]), + run_entities=run_entities, dry_run=False) + assert [p["slug"] for p in api.posted] == ["krishna-prasada-yadava-rautahat", + "krishna-prasada-yadava-jhapa"] + assert items_a[0]["nes_id"].endswith("-rautahat") + assert items_b[0]["nes_id"].endswith("-jhapa") + assert rows_a[0]["how"] == "created" and rows_b[0]["how"] == "created" + + +def test_a_different_verdict_refuses_the_one_existing_namesake_entity(): + # Ladder rung 2 binds the single person entity carrying this name. The + # verdict has just said these cases name two people, so at most one of them + # IS that entity and nothing here can say which -- matching would hand both + # cases the same IRI, the merge the split exists to prevent. + api = _SearchApi(results=[_hit(YADAV, SHARED)], complete=True) + held = {SHARED_KEY: frozenset({"case-a", "case-b"})} + items, rows, _ = _binds("case-a", held=held, decisions={SHARED_KEY: _verdict()}, + identity=_identity("case-a", districts=["rautahat"]), + api=api) + assert items[0]["nes_id"] != YADAV + assert items[0]["nes_id"].endswith("-rautahat") + assert rows[0]["how"] == "created" + # The verdict itself is the row's record of why the match was passed over. + assert rows[0]["reason"].startswith("held verdict different/high") + + +def test_a_different_verdict_falls_back_to_the_court_case_number(): + held = {SHARED_KEY: frozenset({"case-a", "case-b"})} + items, _, _ = _binds( + "case-a", held=held, decisions={SHARED_KEY: _verdict()}, + identity=_identity("case-a", districts=["jhapa", "morang"], + court_cases=["079-CR-0071"])) + assert items[0]["nes_id"].endswith("-079-cr-0071") + + +def test_a_different_verdict_with_nothing_to_separate_by_binds_nothing(): + # No single district and no court case number: both cases would derive the + # SAME slug, so the split cannot be carried out. Reported as that, rather + # than left to surface as a slug collision on whichever case ran second. + held = {SHARED_KEY: frozenset({"case-a", "case-b"})} + items, rows, _ = _binds("case-a", held=held, decisions={SHARED_KEY: _verdict()}, + identity=_identity("case-a")) + assert items == [] + assert rows[0]["how"] == "failed" + assert "neither a single district nor a court case number" in rows[0]["reason"] + + +# ------------------------------------------------------------- same: one entity + +def test_a_same_verdict_creates_the_entity_once_and_binds_it_to_both_cases(): + held = {SHARED_KEY: frozenset({"case-a", "case-b"})} + decisions = {SHARED_KEY: _verdict(verdict="same")} + api, run_entities = _SlugStoreApi(), {} + items_a, _, _ = _binds("case-a", held=held, decisions=decisions, api=api, + run_entities=run_entities, dry_run=False) + items_b, rows_b, _ = _binds("case-b", held=held, decisions=decisions, api=api, + run_entities=run_entities, dry_run=False) + assert len(api.posted) == 1 + assert items_a[0]["nes_id"] == items_b[0]["nes_id"] + assert rows_b[0]["reason"].startswith("held verdict same/high") + + +def test_a_same_verdict_shares_the_entity_even_when_one_row_carries_an_address(): + # `run_entity_key` normally keys on name AND address to keep namesakes + # apart. A `same` verdict has replaced the address as the thing + # establishing identity, so an address on one case's row only must not + # split the person that verdict just merged. Without the override the two + # cases key differently, case-b reaches the create rung, collides on the + # slug case-a already took, and binds NOTHING. + held = {SHARED_KEY: frozenset({"case-a", "case-b"})} + decisions = {SHARED_KEY: _verdict(verdict="same")} + api, run_entities = _SlugStoreApi(), {} + items_a, _, _ = _binds( + "case-a", held=held, decisions=decisions, api=api, dry_run=False, + run_entities=run_entities, + parties=[{"side": "defendant", "name": SHARED, + "address": "सर्लाही, हरिपुर-४"}]) + items_b, rows_b, _ = _binds("case-b", held=held, decisions=decisions, api=api, + run_entities=run_entities, dry_run=False) + assert len(api.posted) == 1 + assert items_b and items_a[0]["nes_id"] == items_b[0]["nes_id"] + assert rows_b[0]["how"] == "created" + + +def test_a_same_verdict_still_uses_the_one_exact_match_when_there_is_one(): + # The opposite of the `different` case: if NES holds exactly one person + # with this name and the verdict says both cases mean one man, that entity + # is the answer and nothing needs creating. + api = _SearchApi(results=[_hit(YADAV, SHARED)], complete=True) + held = {SHARED_KEY: frozenset({"case-a", "case-b"})} + items, rows, _ = _binds("case-a", held=held, + decisions={SHARED_KEY: _verdict(verdict="same")}, + api=api) + assert items[0]["nes_id"] == YADAV + assert rows[0]["how"] == "exact" + + +# ------------------------------------------------------------------ provenance + +def test_an_acted_on_verdict_is_recorded_on_the_row_it_bound(): + held = {SHARED_KEY: frozenset({"case-a", "case-b"})} + _, rows, _ = _binds("case-a", held=held, decisions={SHARED_KEY: _verdict()}, + identity=_identity("case-a", districts=["rautahat"])) + assert rows[0]["reason"].startswith("held verdict different/high") + assert "Rautahat elected ward chair" in rows[0]["reason"] + + +def test_the_court_rows_own_nes_id_outranks_a_different_verdict(): + # Rung 1 is a pure copy of what the portal itself asserts about this row, + # and the portal is the authority on its own records -- a model's inference + # does not override a stated identity. This cohort carries no `nes_id` at + # all, so the path is documented here rather than exercised in production. + held = {SHARED_KEY: frozenset({"case-a", "case-b"})} + items, rows, _ = _binds( + "case-a", held=held, decisions={SHARED_KEY: _verdict()}, + identity=_identity("case-a", districts=["rautahat"]), + parties=[{"side": "defendant", "name": SHARED, "nes_id": YADAV}]) + assert items[0]["nes_id"] == YADAV + assert rows[0]["how"] == "nes_id" + + +def test_held_outcome_names_only_the_other_cases(): + reason, distinct, disc, override = _held_outcome( + frozenset({"case-a", "case-b"}), "case-a", None, None) + assert "case-b" in reason and "case-a" not in reason + assert (distinct, disc, override) == (False, "", None) + + +# -------------------------------------------------------------- the held file + +def test_the_held_file_records_the_verdict_and_whether_it_was_acted_on(tmp_path): + records = [_record(parties=[{"side": "defendant", "name": SHARED}])] + court_records = {"case-a": (records, []), "case-b": (records, [])} + path = write_held_file( + tmp_path / "review.held.json", + {SHARED_KEY: frozenset({"case-a", "case-b"})}, court_records, + run_id="r1", verdicts={SHARED_KEY: _verdict()}) + entry = json.loads(path.read_text(encoding="utf-8"))["held"][0] + assert entry["comparison"]["verdict"] == "different" + assert entry["comparison"]["acted_on"] is True + assert entry["comparison"]["model_answered"] is True + assert "Rautahat" in entry["comparison"]["evidence"] + + +def test_the_held_file_says_so_when_a_name_was_never_compared(tmp_path): + records = [_record(parties=[{"side": "defendant", "name": SHARED}])] + court_records = {"case-a": (records, []), "case-b": (records, [])} + path = write_held_file( + tmp_path / "review.held.json", + {SHARED_KEY: frozenset({"case-a", "case-b"})}, court_records, + run_id="r1", verdicts={}) + assert json.loads(path.read_text(encoding="utf-8"))["held"][0]["comparison"] is None + + +# ------------------------------------------------------------------ card input + +def test_bindable_defendants_skips_a_non_prosecution_reference(): + # The identity cards must see the same defendants the index does: a + # ministry named on an `OA` writ was never a bind candidate, so scanning + # the case description for its name would be wasted work. + records = [_record(number="079-OA-0004", + parties=[{"side": "defendant", "name": "नेपाल सरकार"}]), + _record(parties=[{"side": "defendant", "name": SHARED}])] + assert bindable_defendants(records) == [SHARED] + + +def test_bindable_defendants_de_duplicates_across_references(): + records = [_record(parties=[{"side": "defendant", "name": SHARED}]), + _record(number="080-cr-0002", + parties=[{"side": "defendant", "name": SHARED}])] + assert bindable_defendants(records) == [SHARED] + + +# ------------------------------------------------------------------ CLI wiring + +def _canned_compare(**over): + """A `compare_held` stand-in that honours the real one's `on_verdict` hook. + + Calling the hook matters: `main` logs each verdict through it, so a stub + that skipped it would leave the per-name `held_compare` events untested. + """ + def _compare(held, cards, invoke_json, *, on_verdict=None, **kw): + verdicts = {} + for name, slugs in sorted(held.items()): + verdicts[name] = _verdict(**over) + if on_verdict: + on_verdict(name, sorted(slugs), verdicts[name]) + return verdicts + return _compare + + +def _two_case_api(shared=SHARED): + case_a = _case(slug="case-a") + case_b = _case(slug="case-b", court_cases=[ + "https://jawafdehi.org/courtcase/special/080-cr-0002"]) + return _MultiCaseApi( + [case_a, case_b], + {"079-cr-0151": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": shared}]}, + "080-cr-0002": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": shared}]}}) + + +def test_the_comparison_sweep_runs_unless_it_is_turned_off(tmp_path, monkeypatch): + # Pins that `main` genuinely reaches `compare_held` for a run with a held + # name -- without this, the module-wide `_no_live_model` stub could hide a + # removed sweep and every other test here would still pass. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + monkeypatch.setenv("CASEWORK_API_USER", "dev") + monkeypatch.setenv("CASEWORK_API_PASSWORD", "dev") + api = _two_case_api() + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + seen = {} + + def _fake(held, cards, invoke_json, **kw): + seen["held"] = dict(held) + seen["cards"] = set(cards) + seen["tier"] = kw.get("tier") + return {name: _verdict() for name in held} + + monkeypatch.setattr(ecr, "compare_held", _fake) + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + assert list(seen["held"]) == [SHARED_KEY] + assert seen["cards"] == {"case-a", "case-b"} + assert seen["tier"] == "premium" + + +def test_no_held_compare_asks_no_model_and_holds_every_name(tmp_path, monkeypatch): + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + monkeypatch.setenv("CASEWORK_API_USER", "dev") + monkeypatch.setenv("CASEWORK_API_PASSWORD", "dev") + api = _two_case_api() + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + def _never(*a, **kw): + raise AssertionError("--no-held-compare must not reach the model") + + monkeypatch.setattr(ecr, "compare_held", _never) + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--no-held-compare", "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + resolve = [e for e in _events(tmp_path) if e["step"] == "defendant_resolve"] + assert resolve and all(e["detail"].startswith("held: ") for e in resolve) + assert not [e for e in _events(tmp_path) if e["step"] == "held_compare"] + + +def test_a_run_that_holds_nothing_never_asks_the_model(tmp_path, monkeypatch): + # The stage's ordinary case: only 80 of ~1,414 measured defendant rows + # carry a name that lands on two cases, so most runs must spend no tokens. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + monkeypatch.setenv("CASEWORK_API_USER", "dev") + monkeypatch.setenv("CASEWORK_API_PASSWORD", "dev") + api = _MultiCaseApi( + [_case()], + {"079-cr-0151": {"detail": {"registration_date_ad": "2023-06-22"}, + "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": SHARED}]}}) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + def _never(*a, **kw): + raise AssertionError("a run with no held name must not reach the model") + + monkeypatch.setattr(ecr, "compare_held", _never) + assert main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) == 0 + + +def test_each_held_verdict_is_logged_with_its_evidence(tmp_path, monkeypatch): + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + monkeypatch.setenv("CASEWORK_API_USER", "dev") + monkeypatch.setenv("CASEWORK_API_PASSWORD", "dev") + api = _two_case_api() + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + monkeypatch.setattr(ecr, "compare_held", _canned_compare()) + main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + compare = [e for e in _events(tmp_path) if e["step"] == "held_compare"] + assert any("different/high" in e["detail"] for e in compare) + assert any("Rautahat elected ward chair" in e["detail"] for e in compare) + assert any("1 settled by the model" in e["detail"] for e in compare) + + +def test_a_still_held_verdict_is_logged_as_still_held(tmp_path, monkeypatch): + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + monkeypatch.setenv("CASEWORK_API_USER", "dev") + monkeypatch.setenv("CASEWORK_API_PASSWORD", "dev") + api = _two_case_api() + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + monkeypatch.setattr(ecr, "compare_held", + _canned_compare(verdict="unclear", confidence="low")) + main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + compare = [e for e in _events(tmp_path) if e["step"] == "held_compare"] + assert any("(still held)" in e["detail"] for e in compare) + assert any("0 settled by the model" in e["detail"] for e in compare) + + +def test_an_unavailable_provider_holds_every_name_instead_of_crashing( + tmp_path, monkeypatch, +): + # A provider outage must cost the held names and nothing else: the dates on + # both cases are still planned. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + monkeypatch.setenv("CASEWORK_API_USER", "dev") + monkeypatch.setenv("CASEWORK_API_PASSWORD", "dev") + api = _two_case_api() + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + + def _boom(*a, **kw): + raise RuntimeError("no provider keys") + + monkeypatch.setattr(ecr, "bootstrap", _boom) + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + events = _events(tmp_path) + unavailable = [e for e in events if e["step"] == "held_compare"] + assert unavailable and unavailable[0]["status"] == "unavailable" + assert "stays held for a human" in unavailable[0]["detail"] + assert [e for e in events if e["step"] == "dates"] diff --git a/tests/casework/test_held_identity.py b/tests/casework/test_held_identity.py new file mode 100644 index 00000000..84d0cc0b --- /dev/null +++ b/tests/casework/test_held_identity.py @@ -0,0 +1,326 @@ +"""`casework.held_identity` -- telling same-named court defendants apart. + +The two cases used throughout are the real collision the FY078/079 dry run +surfaced: `ज्ञानेन्द्र चौधरी` is an elected ward chair in Rautahat on +`078-CR-0118` and a contracted environment officer in Jhapa on `079-CR-0071`. +""" + +import pytest + +from casework.held_identity import ( + EVIDENCE_FLOOR, + MAX_MENTIONS, + CaseIdentity, + HeldVerdict, + build_content, + case_identity, + compare_held, + compare_identities, + discriminator, +) + +HELD_NAME = "ज्ञानेन्द्र चौधरी" + +RAUTAHAT = { + "slug": "case-078-cr-0118", + "title": "CIAA Special Court Case 078-CR-0118: गोपाल राय यादव समेत २१", + "description": ( + "नगरप्रमुख, नगर उप-प्रमुख, वडा अध्यक्ष र नगर कार्यपालिका सदस्यहरूले लाल बकैया " + f"नदीको ठेक्काको निर्णय कार्यान्वयन गराउन वेवास्ता गरेको। {HELD_NAME} " + "समेत १८ जनाले सहीछाप गरेको।"), + "evidence": [{"material": { + "material_type": "press_release", + "display_name": ("जिल्ला रौतहट, फतुवा विजयपुर नगरपालिका ... बडा अध्यक्षहरु " + "र नगर कार्यपालिका सदस्यहरु समेत २१ जनाउपर")}}], + "entities": [{"nes_id": "https://jawafdehi.org/entity/location/district/rautahat-np0232"}], +} + +JHAPA = { + "slug": "case-079-cr-0071", + "title": "CIAA Special Court Case 079-CR-0071: ज्ञानेन्द्र चौधरी समेत ४", + "description": ( + f"दमक नगरपालिकाका वातावरण अधिकृत {HELD_NAME} र मिक्लाजुङ गाउँपालिकाका " + "असिस्टेन्ट सव-इन्जिनियर विवेक कार्कीले बढी उत्खनन नियन्त्रण नगरेको।"), + "evidence": [{"material": { + "material_type": "press_release", + "display_name": ("जिल्ला झापा, दमक नगरपालिकाका वातावरण अधिकृत ज्ञानेन्द्र " + "चौधरी, मिक्लाजुङ्ग गाउँपालिका मोरङ्गका असिस्टेन्ट सव-इन्जिनियर")}}], + "entities": [ + {"nes_id": "https://jawafdehi.org/entity/location/district/jhapa-np0104"}, + {"nes_id": "https://jawafdehi.org/entity/location/district/jhapa"}, + ], +} + + +def _card(payload, names=(HELD_NAME,), court_cases=()): + return case_identity(payload, names, court_cases=court_cases) + + +class _Recorder: + """An `invoke_json` stub that records its call and returns a canned reply.""" + + def __init__(self, reply): + self.reply = reply + self.calls = [] + + def __call__(self, system, content, **kwargs): + self.calls.append({"system": system, "content": content, **kwargs}) + if isinstance(self.reply, Exception): + raise self.reply + return self.reply + + +ACTIONABLE = { + "verdict": "different", + "confidence": "high", + "evidence": ("Rautahat elected ward chair versus Jhapa contracted " + "environment officer; different districts and posts."), + "per_case": {"case-078-cr-0118": "वडा अध्यक्ष, रौतहट", + "case-079-cr-0071": "वातावरण अधिकृत, झापा"}, +} + + +# ---------------------------------------------------------------- card building + +def test_the_press_release_title_is_what_the_card_leads_with(): + card = _card(JHAPA) + assert card.press_titles == (JHAPA["evidence"][0]["material"]["display_name"],) + + +def test_a_non_press_release_material_is_not_mistaken_for_one(): + payload = dict(JHAPA, evidence=[ + {"material": {"material_type": "court_order", "display_name": "फैसला"}}]) + assert _card(payload).press_titles == () + + +def test_the_two_spellings_of_one_district_collapse_to_one(): + # `079-CR-0071` really carries both `district/jhapa-np0104` and a bare + # `district/jhapa`. Counted as two, `discriminator` would fall back to the + # case number for a case that has exactly one district. + assert _card(JHAPA).districts == ("jhapa",) + + +def test_a_location_that_is_not_a_district_is_ignored(): + payload = dict(JHAPA, entities=[ + {"nes_id": "https://jawafdehi.org/entity/location/localunit/damak-municipality-11107"}]) + assert _card(payload).districts == () + + +def test_the_name_mention_is_excerpted_from_the_description(): + card = _card(JHAPA) + excerpt = card.mentions["ज्ञानेन्द्र चौधरी"][0] + assert "वातावरण अधिकृत" in excerpt + + +def test_a_name_absent_from_the_description_gets_no_excerpt(): + card = _card(JHAPA, names=("सीता शर्मा",)) + assert "सीता शर्मा" not in card.mentions + + +def test_mentions_are_capped_so_one_long_judgment_cannot_fill_the_prompt(): + payload = dict(JHAPA, description=(HELD_NAME + " ") * 40) + card = _card(payload) + assert len(card.mentions["ज्ञानेन्द्र चौधरी"]) == MAX_MENTIONS + + +def test_consecutive_mentions_do_not_yield_the_same_excerpt_twice(): + # Windows advance past the END of the previous window, so two mentions a + # few characters apart produce ONE excerpt rather than two near-copies. + payload = dict(JHAPA, description=f"{HELD_NAME} र {HELD_NAME}") + assert len(_card(payload).mentions["ज्ञानेन्द्र चौधरी"]) == 1 + + +def test_a_case_with_no_identifying_field_admits_it(): + bare = {"slug": "case-x", "title": "समेत ४", "description": "", "evidence": [], + "entities": []} + assert not _card(bare).carries_identity("ज्ञानेन्द्र चौधरी") + + +def test_a_title_alone_does_not_count_as_identity(): + # Every title in this corpus follows one template, so two of them differ + # only in the co-defendant count -- that is not a distinguishing fact. + titled = {"slug": "case-x", "title": JHAPA["title"], "description": "", + "evidence": [], "entities": []} + assert not _card(titled).carries_identity("ज्ञानेन्द्र चौधरी") + + +# ------------------------------------------------------------- discriminator + +def test_the_discriminator_prefers_the_single_district(): + assert discriminator(_card(JHAPA)) == "jhapa" + + +def test_two_districts_fall_back_to_the_court_case_number(): + # The Mawa Khola is the Jhapa/Morang border, so the real case binds both + # districts and neither is "the" district of the accused. + payload = dict(JHAPA, entities=JHAPA["entities"] + [ + {"nes_id": "https://jawafdehi.org/entity/location/district/morang-np0105"}]) + card = _card(payload, court_cases=("079-CR-0071",)) + assert discriminator(card) == "079-cr-0071" + + +def test_no_district_and_no_court_case_yields_no_discriminator(): + bare = {"slug": "case-x", "title": "", "description": "", "evidence": [], + "entities": []} + assert discriminator(_card(bare)) == "" + + +# ------------------------------------------------------------------- verdicts + +def test_a_high_confidence_verdict_with_evidence_is_actionable(): + assert HeldVerdict(**ACTIONABLE).is_actionable + + +@pytest.mark.parametrize("field,value", [ + ("confidence", "medium"), + ("verdict", "unclear"), +]) +def test_a_verdict_short_of_the_bar_is_not_actionable(field, value): + assert not HeldVerdict(**{**ACTIONABLE, field: value}).is_actionable + + +def test_a_truncated_evidence_string_is_not_actionable(): + # `salvage_json` closes the open string of a reply cut off at max_tokens, + # so a truncated verdict arrives well-formed and near-empty. + short = HeldVerdict(**{**ACTIONABLE, "evidence": "रौतहट र झापा"}) + assert len(short.evidence) < EVIDENCE_FLOOR + assert not short.is_actionable + + +def test_a_verdict_with_no_per_case_detail_is_not_actionable(): + assert not HeldVerdict(**{**ACTIONABLE, "per_case": {}}).is_actionable + + +def test_a_failed_call_is_not_actionable_even_if_it_parsed(): + assert not HeldVerdict(**ACTIONABLE, failed=True).is_actionable + + +# ------------------------------------------------------- compare_identities + +def test_the_real_collision_is_settled_as_two_people(): + stub = _Recorder(ACTIONABLE) + got = compare_identities(HELD_NAME, [_card(RAUTAHAT), _card(JHAPA)], stub) + assert (got.verdict, got.is_actionable) == ("different", True) + + +def test_both_press_releases_reach_the_prompt(): + content = build_content(HELD_NAME, [_card(RAUTAHAT), _card(JHAPA)]) + assert "रौतहट" in content and "झापा" in content + assert "वातावरण अधिकृत" in content and "वडा अध्यक्ष" in content + + +def test_the_prompt_names_every_case_slug_the_reply_must_cover(): + content = build_content(HELD_NAME, [_card(RAUTAHAT), _card(JHAPA)]) + assert RAUTAHAT["slug"] in content and JHAPA["slug"] in content + + +def test_a_thin_card_is_held_without_spending_a_call(): + bare = {"slug": "case-x", "title": "", "description": "", "evidence": [], + "entities": []} + stub = _Recorder(ACTIONABLE) + got = compare_identities(HELD_NAME, [_card(JHAPA), _card(bare)], stub) + assert stub.calls == [] + assert (got.verdict, got.is_actionable) == ("unclear", False) + assert "no press release" in got.evidence + + +def test_a_raising_provider_leaves_the_name_held_and_says_the_model_failed(): + stub = _Recorder(RuntimeError("provider down")) + got = compare_identities(HELD_NAME, [_card(RAUTAHAT), _card(JHAPA)], stub) + assert (got.failed, got.is_actionable) == (True, False) + assert "RuntimeError" in got.evidence + + +def test_a_reply_that_is_not_a_dict_fails_rather_than_binding(): + got = compare_identities(HELD_NAME, [_card(RAUTAHAT), _card(JHAPA)], + _Recorder(["different"])) + assert (got.failed, got.is_actionable) == (True, False) + + +def test_an_unknown_verdict_word_fails_rather_than_binding(): + reply = {**ACTIONABLE, "verdict": "probably the same"} + got = compare_identities(HELD_NAME, [_card(RAUTAHAT), _card(JHAPA)], + _Recorder(reply)) + assert (got.failed, got.is_actionable) == (True, False) + + +def test_a_verdict_silent_about_one_case_is_downgraded_not_acted_on(): + # Actionable on its own fields, but it never described `078-CR-0118`, so it + # has not actually separated that case from the other. + reply = {**ACTIONABLE, + "per_case": {"case-079-cr-0071": "वातावरण अधिकृत, झापा"}} + got = compare_identities(HELD_NAME, [_card(RAUTAHAT), _card(JHAPA)], + _Recorder(reply)) + assert (got.verdict, got.is_actionable) == ("unclear", False) + assert "case-078-cr-0118" in got.evidence + + +def test_the_configured_tier_reaches_the_provider(): + stub = _Recorder(ACTIONABLE) + compare_identities(HELD_NAME, [_card(RAUTAHAT), _card(JHAPA)], stub, + tier="premium") + assert stub.calls[0]["tier"] == "premium" + + +# ------------------------------------------------------------- compare_held + +def test_one_call_per_held_name_not_per_case_and_not_per_pair(): + stub = _Recorder(ACTIONABLE) + held = {"ज्ञानेन्द्र चौधरी": frozenset({RAUTAHAT["slug"], JHAPA["slug"]})} + cards = {RAUTAHAT["slug"]: _card(RAUTAHAT), JHAPA["slug"]: _card(JHAPA)} + verdicts = compare_held(held, cards, stub) + assert len(stub.calls) == 1 + assert set(verdicts) == set(held) + + +def test_a_name_on_three_cases_is_still_one_call(): + third = dict(JHAPA, slug="case-080-cr-0001") + stub = _Recorder(ACTIONABLE) + held = {"ज्ञानेन्द्र चौधरी": frozenset( + {RAUTAHAT["slug"], JHAPA["slug"], third["slug"]})} + cards = {RAUTAHAT["slug"]: _card(RAUTAHAT), JHAPA["slug"]: _card(JHAPA), + third["slug"]: _card(third)} + compare_held(held, cards, stub) + assert len(stub.calls) == 1 + + +def test_each_verdict_is_reported_as_it_lands(): + seen = [] + held = {"ज्ञानेन्द्र चौधरी": frozenset({RAUTAHAT["slug"], JHAPA["slug"]})} + cards = {RAUTAHAT["slug"]: _card(RAUTAHAT), JHAPA["slug"]: _card(JHAPA)} + compare_held(held, cards, _Recorder(ACTIONABLE), + on_verdict=lambda n, s, v: seen.append((n, tuple(s), v.verdict))) + assert seen == [(HELD_NAME, (RAUTAHAT["slug"], JHAPA["slug"]), "different")] + + +def test_a_card_missing_for_one_case_still_compares_what_it_has(): + # A pass-1 read failure removes a case from `identities`. The remaining + # single card cannot be compared, so the name stays held -- it must not + # raise a KeyError and take the whole run down. + held = {"ज्ञानेन्द्र चौधरी": frozenset({RAUTAHAT["slug"], "case-gone"})} + verdicts = compare_held(held, {RAUTAHAT["slug"]: _card(RAUTAHAT)}, + _Recorder(ACTIONABLE)) + assert not verdicts["ज्ञानेन्द्र चौधरी"].is_actionable + + +def test_the_system_prompt_forbids_reasoning_from_the_shared_name(): + from casework.held_identity import SYSTEM + assert "shared name is NOT evidence" in SYSTEM + assert '"unclear"' in SYSTEM + + +def test_an_empty_card_map_never_calls_the_model(): + stub = _Recorder(ACTIONABLE) + assert compare_held({}, {}, stub) == {} + assert stub.calls == [] + + +def test_a_card_is_a_frozen_dataclass_so_a_verdict_cannot_edit_its_source(): + with pytest.raises(Exception): + _card(JHAPA).slug = "case-other" + + +def test_case_identity_needs_no_api_object(): + # The whole point: cards are built from a payload pass 1 already read, so a + # comparison adds no HTTP request to a run measured at 8.8 per case. + assert isinstance(_card(JHAPA), CaseIdentity) From 1c02ef13597aa1453c96844bd3a326732cf843ab Mon Sep 17 00:00:00 2001 From: GAURAV Date: Mon, 10 Aug 2026 06:10:24 -0700 Subject: [PATCH 28/29] fix(casework): close the review findings on the held-name comparison Six findings, all real, all in code I added. THE MERGE THE SPLIT EXISTS TO PREVENT, arriving through the run cache. A `different` verdict separated two people by putting `held_identity.discriminator` in the `run_entities` key, and that discriminator is the case's one district. Two split cases bound to the SAME district -- one person each, different municipality and post, exactly the distinction the prompt asks the model to weigh -- computed an identical key, so case B found case A's entry and returned "reused from this run". One entity, two people, and the create's 409 guard never ran because the cache short-circuited it. Same collapse for two duplicate case records citing one court case number, a known production condition. Two fixes, because one is not enough: - `held_identity.splittable` refuses the verdict up front when the cases yield no pairwise-distinct discriminator, and `main` downgrades it to held with a WARNING. This is the honest report: the problem is not that the name is ambiguous, it is that this RUN cannot name the two apart. - `run_entity_key`'s third component is now the CASE SLUG, not the discriminator, so the cross-case reuse the map exists to perform cannot happen for a name a verdict has split. Defence in depth: if a future edge reaches the create anyway, the 409 refuses it. `_districts` matched the bare `/district/` substring, so `organization/government/district/dfo` -- a District Forest Office, documented verbatim in `enrich_related_entities.PREFIX_PROMPT_TEMPLATE` -- read as the district `dfo`, and `discriminator` would have baked `-dfo` into a permanent public person IRI. It also counted as a second district, pushing a single-district case onto the court-number fallback. Now matches `location/district/`. `_press_titles` accepted only `press_release`, while the shared `PRESS_TYPES` is `(press_release, ciaa_press_release, charge_sheet)` and its comment records that the width is load-bearing: `charge_sheet` measured 100% MARKDOWN coverage against 8.6% for `press_release`. A case whose only charge-stage document is a charge sheet contributed no title, so `carries_identity` was false, the comparison was refused without a call, and the name stayed held -- the feature no-oping on precisely the cases it was built for. `court_cases` on the identity card was built from every reference rather than the bindable ones, so a case citing an `OA` writ first named its person's permanent IRI after a reference this binder refuses to bind from. `_accused_binds` already takes the same care ("the first BINDABLE record, never `records[0]`"). The stage spent premium tokens and recorded none: `compare_held` was called without `usage=`, so an 80-call run and a 0-call run left an identical footer. Now accumulated and rendered like every sibling enricher. And the module docstring still claimed "Zero Django" after `bootstrap()` became reachable. It is zero at IMPORT -- the guard test pins that -- and configured at runtime on any run that holds a name. Said that way instead. Each fix is pinned by a test verified to FAIL when the fix is reverted; the two that had no coverage at all (the same-district split, the district office) are why the review found them. Re-verified against production, dry run, both colliding cases: still `different/high`, still two distinct entities. Co-Authored-By: Claude Opus 5 (1M context) --- casework/enrich_court_record.py | 88 +++++++++++++++++---- casework/held_identity.py | 41 +++++++++- tests/casework/test_enrich_court_record.py | 91 +++++++++++++++++++++- tests/casework/test_held_identity.py | 77 ++++++++++++++++++ 4 files changed, 274 insertions(+), 23 deletions(-) diff --git a/casework/enrich_court_record.py b/casework/enrich_court_record.py index af485dc5..77aeee44 100644 --- a/casework/enrich_court_record.py +++ b/casework/enrich_court_record.py @@ -1,7 +1,9 @@ #!/usr/bin/env python """Accused binds and case dates, read from the case's own NGM court record. -Zero Django, zero source documents. The court record states these facts rather +No source documents, and no Django at IMPORT (the guard test pins that) -- +though `bootstrap()` does configure it at runtime on any run that holds a name, +for the one comparison call below. The court record states these facts rather than inferring them: a defendant is a defendant because a charge sheet says so, and a verdict date is a verdict date because the Special Court's docket says so. @@ -106,7 +108,12 @@ ) from casework.entity_identity import entity_slug, prefix_is_creatable from casework.entity_resolver import normalise_name -from casework.held_identity import case_identity, compare_held +from casework.held_identity import ( + HeldVerdict, + case_identity, + compare_held, + splittable, +) from casework.held_identity import discriminator as held_discriminator from casework.enrich_related_entities import ( bind_key, @@ -282,15 +289,21 @@ def exact_person_match(api, name): return next(iter(hits)), "" -def run_entity_key(name, address, discriminator=""): +def run_entity_key(name, address, scope=""): """The `run_entities` key for one court-record party row. - `discriminator` is set only for a name a `different` held verdict split - (see `held_identity.discriminator`). It is part of the key because that - verdict's whole content is "these two are not the same person", and a - shared key would hand the second case the first case's entity -- the exact - reuse this map exists to perform, applied to the one pair where it is - wrong. + `scope` is the CASE SLUG, and is set only for a name a `different` held + verdict split. That verdict's whole content is "these two are not the same + person", so the cross-case reuse this map exists to perform is exactly + wrong for it, and the key must not let the second case find the first's + entry. + + The case slug, not the discriminator: two split cases bound to the same + single district produce the SAME discriminator, so keying on that shares + the entry again and hands case B case A's entity -- reintroducing the merge + through the run cache, with the create's 409 guard never reached. + `held_identity.splittable` refuses that verdict up front; this keying is the + second line. NAME PLUS ADDRESS, never the bare name. `run_entities` is shared across every case in the run so that one person named on two cases becomes ONE @@ -313,12 +326,12 @@ def run_entity_key(name, address, discriminator=""): halves go through `normalise_name` so a spacing or punctuation difference in the portal's transcription does not split one person into two entities. """ - return normalise_name(name), normalise_name(address or ""), discriminator + return normalise_name(name), normalise_name(address or ""), scope def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, run_entities, dry_run, address="", discriminator="", - distinct=False): + distinct=False, scope=""): """Turn one court-record defendant name into an NES entity id. The ladder, top to bottom: @@ -331,7 +344,8 @@ def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, and the verdict has just said the cases name two people -- so at most one of them is that entity and nothing here can say which. Matching would give both cases the same IRI, which is the merge the split exists to prevent. - `discriminator` then separates their created slugs. + `discriminator` then separates their created slugs, and `scope` (the case + slug) keeps the run cache from sharing one entity between them. `run_entities` maps a `run_entity_key` (name AND address) to an IRI already created THIS RUN, and is shared across cases on purpose: without it, two @@ -366,7 +380,7 @@ def resolve_defendant(api, name, row_nes_id, *, citation, live_prefixes, if matched: return Resolution(matched, "exact") - key = run_entity_key(name, address, discriminator) + key = run_entity_key(name, address, scope) if key in run_entities: return Resolution(run_entities[key], "created", "reused from this run") @@ -669,7 +683,8 @@ def _accused_binds(api, case, records, *, live_prefixes, run_entities, dry_run, api, name, party.get("nes_id"), citation=citation, live_prefixes=live_prefixes, run_entities=run_entities, dry_run=dry_run, address=address, discriminator=disc, - distinct=distinct) + distinct=distinct, + scope=case.get("slug") or "" if distinct else "") row = {"slug": case.get("slug"), "name": name, "how": got.how, "nes_id": got.nes_id, "outcome": outcome, "reason": "; ".join(p for p in (settled, got.reason) if p), @@ -1078,9 +1093,15 @@ def main(argv=None): # bounded excerpt per name, so retaining one per case costs far less # than retaining `case_detail` itself would. records_for_card, _ = court_records[slug] + # BINDABLE references only, matching `_accused_binds`'s own "the first + # BINDABLE record, never `records[0]`" rule: `discriminator` falls back + # to `court_cases[0]`, and naming a permanent public entity IRI after a + # reference this binder refuses to bind from would be a false claim + # about where the person came from. identities[slug] = case_identity( case_detail, bindable_defendants(records_for_card), - court_cases=[r["number"] for r in records_for_card]) + court_cases=[r["number"] for r in records_for_card + if case_number_code(r["number"]) in BINDABLE_CODES]) # Pass 1 pays one HTTP round trip per case before any write happens, # so a full-corpus run is silent for hours without this -- the same # reason `CaseworkApi.iter_cases` narrates its own page fetches. @@ -1135,11 +1156,14 @@ def main(argv=None): # A run holding nothing spends no tokens and never imports the LLM stack -- # which is still this stage's ordinary case, since only 80 of ~1,414 # measured defendant rows carry a name that lands on two cases. - verdicts = {} + verdicts, usage = {}, None if held and not args.no_held_compare: try: bootstrap(args.provider, args.model) from llm.invoke import invoke_json + from llm.usage import UsageAccumulator + + usage = UsageAccumulator() except Exception as exc: # noqa: BLE001 - no model means every name stays held log_event(logger, events, run_id=run_id, stage=STAGE, slug="", step="held_compare", status="unavailable", @@ -1160,7 +1184,30 @@ def _log_verdict(name, slugs, verdict): logger.info("comparing %d held name(s) against their press releases", len(held)) verdicts = compare_held(held, identities, invoke_json, - tier=tier_for(STAGE), on_verdict=_log_verdict) + tier=tier_for(STAGE), usage=usage, + on_verdict=_log_verdict) + # A `different` verdict is only actionable if the cases can be told + # apart in the IRI. Two cases bound to the same single district each + # discriminate to that district, so both would derive one slug and + # the split would land as the merge it was ordered to prevent. + for name, verdict in list(verdicts.items()): + if verdict.verdict != "different" or not verdict.is_actionable: + continue + cards = [identities[s] for s in sorted(held[name]) + if s in identities] + if splittable(cards): + continue + verdicts[name] = HeldVerdict( + "unclear", confidence=verdict.confidence, + per_case=verdict.per_case, + evidence=(f"{verdict.evidence} [downgraded: these cases " + "yield no distinct district or court case number, " + "so this run cannot name the two people apart]")) + log_event(logger, events, run_id=run_id, stage=STAGE, slug="", + step="held_compare", status="ok", + detail=f"{name}: split refused -- no distinct " + "discriminator across its cases; held for a human", + level=logging.WARNING) acted = sum(1 for v in verdicts.values() if v.is_actionable) stats["held_compared"] = len(verdicts) stats["held_settled"] = acted @@ -1306,6 +1353,13 @@ def _log_verdict(name, slugs, verdict): review.write() log_run_footer(logger, stage=STAGE, stats=stats, duration_s=time.time() - started) print_summary(stats, args.dry_run, "court-record binder") + if usage is not None and usage.calls: + # Recorded because this stage now spends tokens. Without it an 80-call + # run and a 0-call run leave an identical footer, and every sibling + # enricher reports its usage (`enrich_news_articles.main`). + from llm.usage import render_usage_table + print(render_usage_table(usage.totals(), + title="held-name comparison")) print(f"review file: {review.path}") print(f"held-names file: {held_path}") return 0 diff --git a/casework/held_identity.py b/casework/held_identity.py index ff83e14f..c4959d27 100644 --- a/casework/held_identity.py +++ b/casework/held_identity.py @@ -35,8 +35,16 @@ import re from dataclasses import dataclass, field +from casework.common.pipeline import PRESS_TYPES from casework.entity_resolver import normalise_name +#: IRI prefix of a real district entity. NOT the bare `/district/` substring: +#: this corpus binds government offices under `organization/government/district/dfo` +#: (see `enrich_related_entities.PREFIX_PROMPT_TEMPLATE`), so a substring test +#: reads a District Forest Office as the district `dfo` -- and `discriminator` +#: would then bake `-dfo` into a permanent public person IRI. +DISTRICT_PREFIX = "location/district/" + #: Characters of `description` kept either side of a name mention. MENTION_WINDOW = 220 @@ -108,15 +116,22 @@ def _windows(text, name, *, window=MENTION_WINDOW, limit=MAX_MENTIONS): def _press_titles(case_detail): - """The `display_name` of every press release bound to the case. + """The `display_name` of every charge-stage document bound to the case. This is the highest-signal line available and it costs nothing: CIAA's own title names the district, the local unit and the office. + + Gated on the shared `PRESS_TYPES`, not on `press_release` alone. Task 8 + measured MARKDOWN coverage at 100% for `charge_sheet` against 8.6% for + `press_release`, so accepting only the latter would leave the commonest + case contributing no title at all -- and a card with no title, no district + and no verbatim mention is refused by `carries_identity` without a call. + The narrow test made this feature no-op on exactly the cases it is for. """ titles = [] for entry in case_detail.get("evidence") or (): material = (entry or {}).get("material") or {} - if material.get("material_type") != "press_release": + if material.get("material_type") not in PRESS_TYPES: continue name = (material.get("display_name") or "").strip() if name: @@ -135,7 +150,7 @@ def _districts(case_detail): names = [] for bind in case_detail.get("entities") or (): nes_id = (bind or {}).get("nes_id") or "" - if "/district/" not in nes_id: + if DISTRICT_PREFIX not in nes_id: continue tail = nes_id.rstrip("/").rsplit("/", 1)[-1] tail = _LOCATION_CODE.sub("", tail) @@ -184,6 +199,26 @@ def discriminator(card): return "" +def splittable(cards): + """Whether these cards yield a DISTINCT discriminator each. + + A `different` verdict is only actionable if the cases can be told apart in + the IRI. Two cases bound to the same single district -- one person in each, + different municipality and post, which is precisely the distinction the + prompt asks the model to weigh -- both discriminate to that district, so + both derive the same entity slug and the split silently becomes the merge it + was ordered to prevent. Same for two duplicate case records sharing one + court case number, a known production condition. + + Checked before acting rather than left to the create's 409: with the run + cache keyed per case the second create does refuse, but "this name is not + unique to this person" is the wrong reason to report when the real problem + is that this RUN cannot name the two apart. + """ + discriminators = [discriminator(c) for c in cards] + return all(discriminators) and len(set(discriminators)) == len(discriminators) + + @dataclass(frozen=True) class HeldVerdict: """The model's answer for one held name. diff --git a/tests/casework/test_enrich_court_record.py b/tests/casework/test_enrich_court_record.py index e3874817..57d8cdb5 100644 --- a/tests/casework/test_enrich_court_record.py +++ b/tests/casework/test_enrich_court_record.py @@ -2272,6 +2272,60 @@ def test_a_different_verdict_refuses_the_one_existing_namesake_entity(): assert rows[0]["reason"].startswith("held verdict different/high") +def test_two_split_cases_in_one_district_never_share_an_entity(): + """Review finding 1: the split silently became the merge it prevents. + + Both cases bound to the same single district discriminate to that district, + so with the run cache keyed on the discriminator they computed the IDENTICAL + key -- case-b found case-a's entry and returned "reused from this run", + binding one entity to two people the verdict had just separated. The create's + 409 guard never ran, because the cache short-circuited it. + + `main` now refuses such a verdict up front (`splittable`); this pins the + second line of defence, the per-case cache scope, by handing + `_accused_binds` the verdict `main` would have downgraded. + """ + held = {SHARED_KEY: frozenset({"case-a", "case-b"})} + decisions = {SHARED_KEY: _verdict()} + api, run_entities = _SlugStoreApi(), {} + items_a, _, _ = _binds("case-a", held=held, decisions=decisions, api=api, + identity=_identity("case-a", districts=["jhapa"]), + run_entities=run_entities, dry_run=False) + items_b, rows_b, _ = _binds("case-b", held=held, decisions=decisions, api=api, + identity=_identity("case-b", districts=["jhapa"]), + run_entities=run_entities, dry_run=False) + # case-a binds its entity; case-b must NOT be handed the same one. + assert items_a and items_a[0]["nes_id"].endswith("-jhapa") + assert items_b == [] + assert rows_b[0]["how"] == "failed" + assert "reused from this run" not in rows_b[0]["reason"] + + +def test_a_split_with_no_distinct_discriminator_is_downgraded_before_it_binds( + tmp_path, monkeypatch, +): + # The first line of defence: `main` sees both cases discriminate to the same + # district and holds the name instead of acting on `different`. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + monkeypatch.setenv("CASEWORK_API_USER", "dev") + monkeypatch.setenv("CASEWORK_API_PASSWORD", "dev") + # Both cases bound to the SAME single district, so both discriminate to it. + api = _two_case_api(entities=[ + {"nes_id": "https://jawafdehi.org/entity/location/district/jhapa-np0104"}]) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + monkeypatch.setattr(ecr, "compare_held", _canned_compare()) + rc = main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) + assert rc == 0 + events = _events(tmp_path) + assert any("split refused" in e["detail"] for e in events + if e["step"] == "held_compare") + resolve = [e for e in events if e["step"] == "defendant_resolve"] + assert resolve and all(e["detail"].startswith("held: ") for e in resolve) + assert api.posted == [] + + def test_a_different_verdict_falls_back_to_the_court_case_number(): held = {SHARED_KEY: frozenset({"case-a", "case-b"})} items, _, _ = _binds( @@ -2437,9 +2491,9 @@ def _compare(held, cards, invoke_json, *, on_verdict=None, **kw): return _compare -def _two_case_api(shared=SHARED): - case_a = _case(slug="case-a") - case_b = _case(slug="case-b", court_cases=[ +def _two_case_api(shared=SHARED, entities=None): + case_a = _case(slug="case-a", entities=list(entities or [])) + case_b = _case(slug="case-b", entities=list(entities or []), court_cases=[ "https://jawafdehi.org/courtcase/special/080-cr-0002"]) return _MultiCaseApi( [case_a, case_b], @@ -2478,6 +2532,37 @@ def _fake(held, cards, invoke_json, **kw): assert seen["tier"] == "premium" +def test_the_fallback_discriminator_ignores_a_non_prosecution_reference( + tmp_path, monkeypatch, +): + # Review finding 6: `court_cases` was built from EVERY reference, so a case + # whose first reference is an `OA` writ named its person's permanent entity + # IRI after a court reference this binder refuses to bind from. + monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) + monkeypatch.setenv("CASEWORK_API_USER", "dev") + monkeypatch.setenv("CASEWORK_API_PASSWORD", "dev") + # case-a cites an OA writ FIRST, then the CR prosecution. No district bind + # on either case, so `discriminator` takes the court-number fallback. + case_a = _case(slug="case-a", entities=[], court_cases=[ + "https://jawafdehi.org/courtcase/special/079-oa-0004", + "https://jawafdehi.org/courtcase/special/079-cr-0151"]) + case_b = _case(slug="case-b", entities=[], court_cases=[ + "https://jawafdehi.org/courtcase/special/080-cr-0002"]) + ref = {"detail": {"registration_date_ad": "2023-06-22"}, "hearings": [DECIDED], + "parties": [{"side": "defendant", "name": SHARED}]} + api = _MultiCaseApi([case_a, case_b], + {"079-oa-0004": ref, "079-cr-0151": ref, "080-cr-0002": ref}) + import casework.enrich_court_record as ecr + monkeypatch.setattr(ecr, "build_api", lambda args: api) + monkeypatch.setattr(ecr, "compare_held", _canned_compare()) + assert main(["--api-base-url", "http://127.0.0.1:48010", "--dry-run", + "--review-file", str(tmp_path / "review.md")]) == 0 + detail = [e["detail"] for e in _events(tmp_path) + if e["step"] == "defendant_resolve" and e["slug"] == "case-a"][0] + assert "-079-cr-0151" in detail + assert "-079-oa-0004" not in detail + + def test_no_held_compare_asks_no_model_and_holds_every_name(tmp_path, monkeypatch): monkeypatch.setenv("CASEWORK_RUN_LOG_DIR", str(tmp_path)) monkeypatch.setenv("CASEWORK_API_USER", "dev") diff --git a/tests/casework/test_held_identity.py b/tests/casework/test_held_identity.py index 84d0cc0b..9aa830e4 100644 --- a/tests/casework/test_held_identity.py +++ b/tests/casework/test_held_identity.py @@ -17,6 +17,7 @@ compare_held, compare_identities, discriminator, + splittable, ) HELD_NAME = "ज्ञानेन्द्र चौधरी" @@ -324,3 +325,79 @@ def test_case_identity_needs_no_api_object(): # The whole point: cards are built from a payload pass 1 already read, so a # comparison adds no HTTP request to a run measured at 8.8 per case. assert isinstance(_card(JHAPA), CaseIdentity) + + +# --------------------------------------------------- review findings 1, 2, 3 + +def test_a_district_office_is_not_read_as_a_district(): + # This corpus binds government offices under + # `organization/government/district/dfo`. A bare `/district/` substring test + # reads that as the district `dfo` -- and `discriminator` would then bake + # `-dfo` into a permanent public person IRI. + payload = dict(JHAPA, entities=[ + {"nes_id": "https://jawafdehi.org/entity/organization/government/district/dfo"}]) + card = _card(payload) + assert card.districts == () + assert not discriminator(card).endswith("dfo") + + +def test_a_district_office_does_not_suppress_the_real_district(): + # Counted as a second "district", the office would push a + # single-district case onto the court-number fallback. + payload = dict(JHAPA, entities=JHAPA["entities"] + [ + {"nes_id": "https://jawafdehi.org/entity/organization/government/district/dfo"}]) + assert discriminator(_card(payload)) == "jhapa" + + +@pytest.mark.parametrize("material_type", ["press_release", "ciaa_press_release", + "charge_sheet"]) +def test_every_established_press_type_contributes_its_title(material_type): + # `PRESS_TYPES` is deliberately wide: `charge_sheet` was measured at 100% + # MARKDOWN coverage against 8.6% for `press_release`, so accepting only the + # latter would leave the commonest case with no title, no comparison, and + # the name held -- the feature no-oping on what it exists for. + payload = dict(JHAPA, evidence=[{"material": { + "material_type": material_type, + "display_name": "जिल्ला झापा, दमक नगरपालिकाका वातावरण अधिकृत"}}]) + assert _card(payload).press_titles != () + assert _card(payload).carries_identity("ज्ञानेन्द्र चौधरी") + + +def test_a_court_order_still_does_not_count_as_a_press_title(): + payload = dict(JHAPA, evidence=[ + {"material": {"material_type": "court_order", "display_name": "फैसला"}}]) + assert _card(payload).press_titles == () + + +def test_two_cases_in_one_district_are_not_splittable(): + # Each discriminates to the same district, so both would derive one entity + # slug and the split would land as the merge it was ordered to prevent. + a = CaseIdentity(slug="case-a", districts=("jhapa",)) + b = CaseIdentity(slug="case-b", districts=("jhapa",)) + assert not splittable([a, b]) + + +def test_two_cases_sharing_one_court_number_are_not_splittable(): + # Duplicate case records citing one court reference: a known prod condition. + a = CaseIdentity(slug="case-a", court_cases=("079-CR-0071",)) + b = CaseIdentity(slug="case-b", court_cases=("079-CR-0071",)) + assert not splittable([a, b]) + + +def test_a_case_with_no_discriminator_at_all_is_not_splittable(): + a = CaseIdentity(slug="case-a", districts=("jhapa",)) + assert not splittable([a, CaseIdentity(slug="case-b")]) + + +def test_distinct_districts_are_splittable(): + a = CaseIdentity(slug="case-a", districts=("rautahat",)) + b = CaseIdentity(slug="case-b", districts=("jhapa",)) + assert splittable([a, b]) + + +def test_the_configured_usage_accumulator_reaches_the_provider(): + stub = _Recorder(ACTIONABLE) + sentinel = object() + compare_identities(HELD_NAME, [_card(RAUTAHAT), _card(JHAPA)], stub, + usage=sentinel) + assert stub.calls[0]["usage"] is sentinel From 92688485eaa3a5785f9842d71b25c61a652d0e52 Mon Sep 17 00:00:00 2001 From: GAURAV Date: Mon, 10 Aug 2026 09:57:01 -0700 Subject: [PATCH 29/29] fix(casework): hold a name whose cases lack evidence, and parse district IRIs Both CodeRabbit findings on the held-name comparison. Both real. A THIN CARD COULD REACH THE MODEL AND GET BOUND. The guard asked for two cards carrying identifying evidence, then sent ALL of them. A name on three cases with two rich and one thin therefore sent the thin one, and the reply could describe that slug from nothing, satisfy `covers`, go actionable -- and the binder would bind an entity on the one case with no evidence at all. Now a single thin card holds the whole name: a verdict covers every case sharing the name, so there is no way to settle two and hold the third. `_districts` matched `location/district/` as a SUBSTRING, which let three things through. A nested path (`organization/foo/location/district/bar`) matched. So did the legacy `entity:location/district/` scheme the repo's IRI rules forbid reintroducing. And the district-office case the previous commit fixed was only half-closed. Fixed with `jawafdehi_shared.entities.ids.parse_entity_iri` and prefix EQUALITY, rather than the reviewer's suggested full-HTTPS `startswith`. The parser is already this module family's idiom (`_is_person` uses it), it rejects the legacy scheme by raising instead of relying on the host string, and it does not hardcode `https://jawafdehi.org/entity/` into casework -- which would be a second place to edit if the IRI base ever moved. Verified against all four shapes: canonical district parses to `location/district`; `organization/government/district/dfo`, the nested path, and the legacy scheme are all refused. Prefix equality, not `_is_person`'s first-segment test: `person/politician` is still a person, but nothing nests under a district. Five tests added, each verified to FAIL when its fix is reverted. Re-verified against production, dry run, both colliding cases: still `different/high`, still two distinct entities. Co-Authored-By: Claude Opus 5 (1M context) --- casework/held_identity.py | 53 +++++++++++++++++++--------- tests/casework/test_held_identity.py | 49 +++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 17 deletions(-) diff --git a/casework/held_identity.py b/casework/held_identity.py index c4959d27..0c4ce633 100644 --- a/casework/held_identity.py +++ b/casework/held_identity.py @@ -37,13 +37,23 @@ from casework.common.pipeline import PRESS_TYPES from casework.entity_resolver import normalise_name - -#: IRI prefix of a real district entity. NOT the bare `/district/` substring: -#: this corpus binds government offices under `organization/government/district/dfo` -#: (see `enrich_related_entities.PREFIX_PROMPT_TEMPLATE`), so a substring test -#: reads a District Forest Office as the district `dfo` -- and `discriminator` -#: would then bake `-dfo` into a permanent public person IRI. -DISTRICT_PREFIX = "location/district/" +from jawafdehi_shared.entities.ids import parse_entity_iri + +#: NES prefix of a real district entity, compared against `parse_entity_iri`'s +#: own `prefix` -- never matched as a substring of the IRI. +#: +#: Three things a substring test lets through, and this does not. This corpus +#: binds government offices under `organization/government/district/dfo` (see +#: `enrich_related_entities.PREFIX_PROMPT_TEMPLATE`), so `/district/` reads a +#: District Forest Office as the district `dfo`. A nested path +#: (`organization/foo/location/district/bar`) matches too. And the legacy +#: `entity:location/district/` scheme matches, which the repo's IRI rules +#: forbid reintroducing -- `parse_entity_iri` raises on it. +#: +#: Compared by equality, unlike `_is_person`'s first-segment test: a person +#: entity nests real subtypes (`person/politician`) that are all still people, +#: while nothing nests under a district. +DISTRICT_PREFIX = "location/district" #: Characters of `description` kept either side of a name mention. MENTION_WINDOW = 220 @@ -149,11 +159,13 @@ def _districts(case_detail): """ names = [] for bind in case_detail.get("entities") or (): - nes_id = (bind or {}).get("nes_id") or "" - if DISTRICT_PREFIX not in nes_id: + try: + parsed = parse_entity_iri((bind or {}).get("nes_id") or "") + except Exception: # noqa: BLE001 - a malformed IRI is simply not a district + continue + if parsed.prefix != DISTRICT_PREFIX: continue - tail = nes_id.rstrip("/").rsplit("/", 1)[-1] - tail = _LOCATION_CODE.sub("", tail) + tail = _LOCATION_CODE.sub("", parsed.slug) if tail: names.append(tail) return tuple(dict.fromkeys(names)) @@ -342,20 +354,27 @@ def compare_identities(name, cards, invoke_json, *, tier="premium", usage=None, max_tokens=700): """One model call: is `name` one person across `cards`, or several? - Refused WITHOUT a call when fewer than two cards carry a distinguishing - fact. A card with no press release, no district and no mention of the name + Refused WITHOUT a call unless EVERY card carries a distinguishing fact. A + card with no press release, no district and no mention of the name contributes only its slug and its templated title, so the model would be left comparing the shared name against itself -- the one input the system prompt forbids it to reason from. Cheaper and more honest to hold. + + Every card, not merely two of them: a name on three cases where two are rich + and one is thin still sent the thin one, and the reply could then describe + that slug from nothing, satisfy `covers`, and go actionable -- so the binder + would bind an entity on the one case carrying no evidence at all. There is + no way to settle two of a name's cases and hold the third: a verdict covers + the whole name. So a single thin card holds all of them. """ key = normalise_name(name) - usable = [c for c in cards if c.carries_identity(key)] - if len(usable) < 2: - thin = ", ".join(c.slug for c in cards if not c.carries_identity(key)) + thin = [c.slug for c in cards if not c.carries_identity(key)] + if len(cards) < 2 or thin: return HeldVerdict( "unclear", evidence=("not compared: no press release, district or summary " - f"mention to tell this name apart on {thin or 'these cases'}")) + "mention to tell this name apart on " + f"{', '.join(thin) or 'these cases'}")) try: reply = invoke_json(SYSTEM, build_content(name, cards), max_tokens=max_tokens, tier=tier, usage=usage) diff --git a/tests/casework/test_held_identity.py b/tests/casework/test_held_identity.py index 9aa830e4..cfdeca7b 100644 --- a/tests/casework/test_held_identity.py +++ b/tests/casework/test_held_identity.py @@ -225,6 +225,32 @@ def test_a_thin_card_is_held_without_spending_a_call(): assert "no press release" in got.evidence +def test_one_thin_card_among_three_holds_the_whole_name(): + """Two rich cards no longer license a call that includes a thin one. + + The reply could describe the thin case's slug from nothing, satisfy + `covers`, go actionable, and the binder would then bind an entity on the one + case carrying no identifying evidence at all. + """ + bare = {"slug": "case-thin", "title": "", "description": "", "evidence": [], + "entities": []} + stub = _Recorder(ACTIONABLE) + got = compare_identities(HELD_NAME, + [_card(RAUTAHAT), _card(JHAPA), _card(bare)], stub) + assert stub.calls == [] + assert not got.is_actionable + assert "case-thin" in got.evidence + + +def test_three_rich_cards_are_still_compared_in_one_call(): + # The guard must not refuse a name simply for being on three cases. + third = dict(JHAPA, slug="case-080-cr-0001") + stub = _Recorder(ACTIONABLE) + compare_identities(HELD_NAME, + [_card(RAUTAHAT), _card(JHAPA), _card(third)], stub) + assert len(stub.calls) == 1 + + def test_a_raising_provider_leaves_the_name_held_and_says_the_model_failed(): stub = _Recorder(RuntimeError("provider down")) got = compare_identities(HELD_NAME, [_card(RAUTAHAT), _card(JHAPA)], stub) @@ -341,6 +367,29 @@ def test_a_district_office_is_not_read_as_a_district(): assert not discriminator(card).endswith("dfo") +def test_a_legacy_scheme_district_iri_is_refused(): + # The repo's IRI rules forbid reintroducing `entity:/`, and a + # substring test on `location/district/` accepted it. `parse_entity_iri` + # raises on the legacy form, so it can never reach a public person IRI as a + # discriminator. + payload = dict(JHAPA, entities=[{"nes_id": "entity:location/district/jhapa"}]) + assert _card(payload).districts == () + + +def test_a_district_nested_under_another_prefix_is_refused(): + # `organization/foo/location/district/bar` contains the district path but is + # not a district. Equality on the parsed prefix rejects it; a substring + # test did not. + payload = dict(JHAPA, entities=[{ + "nes_id": "https://jawafdehi.org/entity/organization/foo/location/district/bar"}]) + assert _card(payload).districts == () + + +def test_a_malformed_bind_iri_does_not_break_the_card(): + payload = dict(JHAPA, entities=[{"nes_id": "not-an-iri"}, {"nes_id": None}, {}]) + assert _card(payload).districts == () + + def test_a_district_office_does_not_suppress_the_real_district(): # Counted as a second "district", the office would push a # single-district case onto the court-number fallback.