Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
118b57e
feat(casework): read a court case and write scalars + a whole list in…
gaurav-karki Aug 7, 2026
9e8e8e7
feat(casework): read a case's whole court record, not just defendant …
gaurav-karki Aug 7, 2026
8dc5a71
feat(casework): derive case start and end dates from the court record
gaurav-karki Aug 7, 2026
a20321d
test(casework): prove deciding_hearing picks by max date, not filter …
gaurav-karki Aug 7, 2026
9dffe05
feat(casework): resolve court defendants by exact person name, or cre…
gaurav-karki Aug 7, 2026
00c4ac7
fix(casework): fail cautious on a truncated search window, and stop r…
gaurav-karki Aug 7, 2026
bdaa351
feat(casework): plan the court-record patch, merging binds and never …
gaurav-karki Aug 7, 2026
cfc05a9
fix(casework): merge against the PATCH shape, refuse a payload with n…
gaurav-karki Aug 7, 2026
d9c116a
test(casework): prove bind_outcome's ACQUITTED path, and normalise mi…
gaurav-karki Aug 7, 2026
2f71f18
feat(casework): the court-record binder CLI, with every step logged
gaurav-karki Aug 7, 2026
1437351
fix(casework): route court-read failures and 412s to the right event,…
gaurav-karki Aug 7, 2026
c6de859
feat(casework): register the court_record stage and correct the date …
gaurav-karki Aug 7, 2026
3010e9d
fix(casework): refuse the colliding bind, and stop the dry run pollut…
gaurav-karki Aug 7, 2026
ddf0947
fix(casework): classify the court case number, and bind only prosecut…
gaurav-karki Aug 8, 2026
f5c1bf1
fix(casework): route non-prosecution skips to bind_plan, and trim the…
gaurav-karki Aug 8, 2026
a94c7d9
feat(casework): hold a defendant name that appears on more than one case
gaurav-karki Aug 8, 2026
fc83d00
fix(casework): stop counting a held defendant as resolved or bound
gaurav-karki Aug 8, 2026
b645f25
feat(casework): wire the held-name index into main, and fix the revie…
gaurav-karki Aug 8, 2026
8b2936b
fix(casework): create the held file's directory, and surface a shrunk…
gaurav-karki Aug 8, 2026
00f585b
fix(casework): warn on a shrunk held index, not just a narrowed selec…
gaurav-karki Aug 8, 2026
b915890
fix(casework): make an unparseable case number skip, not bind
gaurav-karki Aug 8, 2026
704c15a
fix(casework): warn on --fiscal-year narrowing, and admit what the he…
gaurav-karki Aug 8, 2026
92c2486
fix(casework): keep a resolution's caveat instead of letting its IRI …
gaurav-karki Aug 8, 2026
eca85a6
test(casework): pin that IN_REVIEW feeds the held index but is never …
gaurav-karki Aug 8, 2026
7df92ab
revert(cases): drop the date help-text change and its migration
gaurav-karki Aug 8, 2026
d3a2477
Merge remote-tracking branch 'origin/main' into feat/casework-court-r…
gaurav-karki Aug 8, 2026
45a24cd
fix(casework): page hearings on next, and stop rung 1 binding a non-p…
gaurav-karki Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions casework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
85 changes: 82 additions & 3 deletions casework/common/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -384,6 +388,81 @@ 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. 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")
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 not batch or not data.get("next"):
return rows
page += 1
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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.
Expand Down
4 changes: 4 additions & 0 deletions casework/common/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
# 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",
}
DEFAULT_TIER = "cheap"

Expand Down
11 changes: 11 additions & 0 deletions casework/common/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,17 @@ class Stage:
requires_entity_roles=("accused",),
requires_stages=("card", "entities"),
),
# `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"),
),
}


Expand Down
149 changes: 128 additions & 21 deletions casework/court_record.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -36,20 +41,78 @@
"""

import logging
import re
import urllib.error

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"

#: 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]+)-")

#: 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 `-<letters>-` 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.

`""` 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):
"""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` 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()


def court_ref(iri):
"""`(court, case_number)` from a courtcase IRI, or None.
Expand Down Expand Up @@ -105,11 +168,55 @@ 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)
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
Loading
Loading