diff --git a/casework/common/missing_details.py b/casework/common/missing_details.py new file mode 100644 index 00000000..c01c05f9 --- /dev/null +++ b/casework/common/missing_details.py @@ -0,0 +1,359 @@ +"""`Case.missing_details` assembly: what the record cites that our evidence lacks. + +Two sources, combined here: a DETERMINISTIC floor from what is bound (verifiable, +never wrong), plus up to `MAX_LLM_ITEMS` specific documents the model found +referenced in the sources but absent from our evidence. + +Every constant below is measured against the 61 PUBLISHED cases carrying this +field, read 2026-08-07. Items are short Nepali NOUN PHRASES naming a document, +not sentences. The rationale, the corpus numbers and the two known limits are in +the pull request that added this module. +""" + +import re +from typing import Optional + +from casework.common.materials import typed_materials +from casework.common.pipeline import COURT_TYPES + +# Copied VERBATIM from published cases rather than composed here. +CHARGE_SHEET_ITEM = "अख्तियार दुरुपयोग अनुसन्धान आयोगले दायर गरेको अभियोगपत्र" +APPEAL_ITEM = ( + "हदम्याद भित्र वादी वा प्रतिवादीले सर्वोच्च अदालतमा पुनरावेदन गरे नगरेको ब्याहोरा" +) + +# Enumerator follows ITEM COUNT, matching the corpus: 1 item bare, 2 numerals, +# 3+ letters. Chosen after the model's items are accepted -- see `render`. +# NUMERALS is two entries because only the 2-item branch of `render` indexes it. +NUMERALS = ("१", "२") +LETTERS = ("क", "ख", "ग", "घ", "ङ", "च", "छ", "ज") + +CHARGE_SHEET_TYPE = "charge_sheet" +SUPREME_REF = "/courtcase/supreme/" + +MAX_LLM_ITEMS = 4 # + 2 floor items = the corpus's densest value (ntc-081-cr-0111, 6) +MAX_CHARS = 700 # SANITY GUARD only -- `MAX_LLM_ITEMS` is the real limit, see below +MAX_ITEM_CHARS = 110 # longer than this is a sentence, not a document name +MAX_ITEMS = 2 + MAX_LLM_ITEMS # 2 floor + the model's; must stay <= len(LETTERS) + +# Words that would NAME a document we already hold, keyed by material type -- +# the one grounding rule `reject_item` can check mechanically rather than trust. +# `court_order` must keep the BARE `फैसला` forms: `held_summary` prints +# `विशेष अदालतको फैसला` into the prompt, and echoing that label back is the model's +# most likely wrong answer. Listing only `फैसलाको …` let it through. +HELD_DOC_WORDS = { + "ciaa_press_release": ("प्रेस विज्ञप्ति", "प्रेस विज्ञप्ती", "प्रेश", "विज्ञप्ति"), + "press_release": ("प्रेस विज्ञप्ति", "प्रेस विज्ञप्ती", "प्रेश", "विज्ञप्ति"), + "charge_sheet": ("अभियोगपत्र", "आरोपपत्र", "अभियोग पत्र", "आरोप पत्र"), + "court_order": ("विशेष अदालतको फैसला", "अदालतको फैसला", "फैसला", + "फैसलाको प्रतिलिपि", "फैसलाको पूर्णपाठ", "फैसलाको पुर्णपाठ", + "फैसलाको मूलपाठ", "फैसलाको मूल पाठ"), + "news": ("समाचार",), +} + +# We hold the SPECIAL court's verdict, so another court's ruling is legitimately +# missing -- and `अदालतको फैसला` is a substring of `सर्वोच्च अदालतको फैसला`. +OTHER_COURT_WORDS = ("सर्वोच्च", "उच्च अदालत", "पुनरावेदन अदालत") + +# The exemption needs a court DOCUMENT, not just a court name. `सर्वोच्च` alone +# appears in APPEAL_ITEM itself, so matching on the name cancelled the +# appeal-restatement rule: `सर्वोच्च अदालतमा पुनरावेदन परेको वा नपरेको विवरण` was +# accepted and published next to the floor item saying the same thing. +# `पुनरावेदनपत्र` and the registry nouns MUST be here -- the appeal petition is the +# single most valuable document to name when an appeal was lodged, and without it +# the appeal-restatement rule dropped it on every case lacking a Supreme reference. +COURT_DOC_WORDS = ("फैसला", "आदेश", "मिसिल", "पर्चा", "इजलास", "अन्तरिम", + "पुनरावेदनपत्र", "निवेदन", "अभिलेख", "किताब") + +# Substrings marking an item as restating a FLOOR item. Checked only when that +# floor item is actually present -- with a charge sheet bound, a claim about the +# charge sheet's own contents is legitimate. +CHARGE_SHEET_WORDS = ("अभियोगपत्र", "आरोपपत्र", "अभियोग पत्र", "आरोप पत्र") +APPEAL_WORDS = ("पुनरावेदन",) + +# Filler the corpus is full of (15 and 17 cases) and a reader cannot act on. +# Multi-word, so a substring test is safe. +FILLER_PHRASES = ( + "अन्य आवश्यक स्रोत", + "थप आधार र प्रमाण", + "अन्य प्रमाण", + "अन्य कागजात", + "अन्य सबुत", + "अन्य सवुत", +) + +# Matched as WHOLE WORDS -- `आदि` also opens proper nouns, and as a substring it +# rejected `आदित्य …को बैंक खाता विवरण` and `आदिवासी …को लेखापरीक्षण प्रतिवेदन`. +FILLER_TOKENS = ("आदि", "अादी") + +# ASCII `.` `:` `;` belong here alongside the danda. Without them `…आदि।` was +# filler and `…आदि.` was not -- the model picks whichever full stop suits the +# script it is already mixing, so punctuation decided whether filler published. +_TOKEN_SPLIT = re.compile(r"[\s,।.:;()\[\]/–—-]+") + +# `व`/`ब` is the commonest Nepali orthographic variant (वकपत्र / बकपत्र), and the +# model emits both. Folded before the duplicate check so one document cannot take +# two of the item slots and print twice on the page. +_FOLD = str.maketrans({"ब": "व", "ष": "श", "ऱ": "र"}) + + +def bound_types(case: dict) -> set: + """Material types bound as evidence. Empty on a LIST payload, which resolves + no materials -- every check below then falls through rather than guessing.""" + return {mtype for mtype, _, _ in typed_materials(case)} + + +def has_verdict(case: dict) -> bool: + """Is a verdict bound? The gate for writing this field at all.""" + return bool(bound_types(case) & set(COURT_TYPES)) + + +def has_charge_sheet(case: dict) -> bool: + return CHARGE_SHEET_TYPE in bound_types(case) + + +def has_supreme_reference(case: dict) -> bool: + """Our only machine-readable signal that an appeal was actually lodged. Its + absence is what `APPEAL_ITEM` reports -- as "could not be determined", never + as "no appeal was filed".""" + return any(SUPREME_REF in (ref or "") for ref in case.get("court_cases") or []) + + +def held_summary(case: dict) -> str: + """The bound evidence, for the prompt -- lets the model DIFF instead of guess. + + Nepali labels: the model answers in Nepali, and a mixed-script inventory + invites it to echo the English type name into public prose. + """ + labels = { + "ciaa_press_release": "अख्तियारको प्रेस विज्ञप्ति", + "press_release": "प्रेस विज्ञप्ति", + "charge_sheet": "अभियोगपत्र", + "court_order": "विशेष अदालतको फैसला", + "news": "समाचार", + } + counts = {} + for mtype, _, _ in typed_materials(case): + # `material_type` is free-form, not a choices field, so an unlabelled type + # would render its snake_case English name straight into a Nepali prompt -- + # the very thing this function exists to avoid. Collapse it instead. Such a + # type has no HELD_DOC_WORDS entry either, so `reject_item` cannot check a + # claim about it; the collapsed label at least keeps the count honest. + counts[mtype if mtype in labels else "_other"] = ( + counts.get(mtype if mtype in labels else "_other", 0) + 1) + if not counts: + return "(कुनै पनि छैन)" + labels = {**labels, "_other": "अन्य कागजात"} + parts = [] + for mtype, n in sorted(counts.items()): + label = labels[mtype] + parts.append(f"{label} x{n}" if n > 1 else label) + return ", ".join(parts) + + +def floor_items(case: dict) -> list: + """Deterministic items from what is bound. Verifiable, never model output. + + Empty with no verdict (nothing is written then), and empty when a charge + sheet AND an appeal reference are both on file -- nothing is certainly absent. + """ + if not has_verdict(case): + return [] + items = [] + if not has_charge_sheet(case): + items.append(CHARGE_SHEET_ITEM) + if not has_supreme_reference(case): + items.append(APPEAL_ITEM) + return items + + +def render(items) -> str: + """Join items under the enumerator the corpus uses for that count.""" + items = [t.strip() for t in (items or []) if t and t.strip()] + if not items: + return "" + if len(items) == 1: + return items[0] + if len(items) == 2: + return "\n".join(f"{NUMERALS[i]}. {t}" for i, t in enumerate(items)) + return "\n".join(f"{LETTERS[i]}) {t}" for i, t in enumerate(items)) + + +def _fold(text: str) -> str: + """Item text normalised for comparison only -- never for output.""" + return " ".join(text.strip().lower().translate(_FOLD).split()) + + +def reject_item(item: Optional[str], case: dict, accepted=(), + bound=None, floor=None) -> Optional[str]: + """Why this model-proposed item must not be used, or None to accept it. + + A REASON STRING rather than a bool so the run log can name the rule that + fired -- a silently dropped item is indistinguishable from a model that found + nothing, and those need different follow-up. + + `bound` and `floor` are the case's material types and floor items. They cannot + change while filtering one reply, so `accept_items` computes them once and + passes them in; left None they are derived here, which is what a direct caller + (or a test) wants. + """ + if not item or not item.strip(): + return "empty" + item = item.strip() + bound = bound_types(case) if bound is None else bound + floor = floor_items(case) if floor is None else floor + + if "<" in item: + # The field renders through an HTML component, so a tag is interpreted. + return "contains markup" + + if "\n" in item or "\r" in item: + # `render` joins items with newlines, so an embedded one leaves a + # second, UN-enumerated line that closes the frontend's marker list and + # renders as a stray paragraph. + return "contains a line break" + + if len(item) > MAX_ITEM_CHARS: + return f"longer than {MAX_ITEM_CHARS} chars -- a sentence, not a document name" + + if any(w in item for w in FILLER_PHRASES) or _has_filler_token(item): + return "filler, not a specific document" + + # Already claimed this round, or already stated by the floor. Compared folded, + # so one document cannot take two slots under two spellings. + folded = _fold(item) + for prior in list(floor) + list(accepted): + if folded == _fold(prior): + return "duplicate of an item already listed" + + # The two rules below need DIFFERENT exemptions, because they are checking + # different things: + # - the held-verdict rule asks "is this the verdict we hold?", so the escape + # is naming ANOTHER COURT -- and it needs a document noun too, since + # `सर्वोच्च` alone appears in APPEAL_ITEM itself; + # - the appeal rule asks "is this just commentary on whether an appeal + # happened?", so the escape is naming a DOCUMENT, with or without a court. + # `पुनरावेदन दर्ता किताबको अभिलेख` names no court but is still a record. + names_a_document = any(w in item for w in COURT_DOC_WORDS) + names_other_court = names_a_document and any(w in item for w in OTHER_COURT_WORDS) + + # HEAD-matched, not substring-matched, for the same reason the held-document + # rule is: `अभियोगपत्रमा उल्लेखित संलग्न अनुसूची` is an annex the charge sheet + # REFERENCES. A substring test dropped it on every case with no charge sheet + # bound -- 24 of the first batch's 25 -- which is the module's own canonical + # keeper. The appeal rule below stays a substring test on purpose: it catches + # COMMENTARY about the appeal, which has no document head to match. + if CHARGE_SHEET_ITEM in floor and any(_names_held_document(item, w) + for w in CHARGE_SHEET_WORDS): + return "restates the charge-sheet item" + if (APPEAL_ITEM in floor and not names_a_document + and any(w in item for w in APPEAL_WORDS)): + return "restates the appeal item" + + # The model was shown what we hold, so a claim that a held document is + # missing is contradicted by our own bindings. + for mtype in bound: + if mtype in COURT_TYPES and names_other_court: + continue + for word in HELD_DOC_WORDS.get(mtype, ()): + if _names_held_document(item, word): + return f"claims a {mtype} is missing, but one is bound as evidence" + + return None + + +def _has_filler_token(item: str) -> bool: + """Does `item` use a filler word as a WHOLE word? See `FILLER_TOKENS`.""" + return any(tok.strip("।,") in FILLER_TOKENS for tok in _TOKEN_SPLIT.split(item)) + + +def _names_held_document(item: str, word: str) -> bool: + """Is the held document this phrase's HEAD, or only a modifier inside it? + + `अभियोगपत्रमा उल्लेखित संलग्न अनुसूची` -- an annex the charge sheet references -- + contains `अभियोगपत्र` but its head is `अनुसूची`, a different document, and + dropping it would throw away exactly the specific finding this output is for. + Nepali noun phrases are head-final, so the head is the last noun once trailing + parentheticals, copy-of nouns and case endings are peeled off. + + This replaced a `len(word) / len(item) >= 0.5` ratio, which was backwards. The + prompt demands specificity -- "name the document, with its date, party, phase, + or number" -- and every qualifier lengthens the item and pushes the ratio down, + so the rule stopped firing precisely on the items it was written for: + `विशेष अदालत काठमाडौंको फैसला (०८१-CR-००९१)` passed at 0.13 while the bare + `विशेष अदालतको फैसला` was caught. The more specific the wrong claim, the more + likely it published. + """ + return word in item and _head(item).endswith(word) + + +# Nouns meaning "a copy of the preceding document", so the head is what came +# before them. NOT generic words like `कागजात` -- those are heads in their own right. +COPY_NOUNS = ("प्रतिलिपि", "पूर्णपाठ", "पूर्ण पाठ", "पुर्णपाठ", "पुर्ण पाठ", + "मूलपाठ", "मूल पाठ", "सक्कल", "फोटोकपी", "प्रति") + +# Case endings that sit between a modifier and its head. +CASE_ENDINGS = ("को", "का", "की", "मा", "ले", "बाट", "लाई", "सँग", "उपर") + +_PARENTHETICAL = re.compile(r"[((][^))]*[))]") + + +def _head(item: str) -> str: + """The phrase with trailing qualifiers peeled off, so it ends on its head noun.""" + text = _PARENTHETICAL.sub("", item).strip(" ।,.-") + changed = True + while changed: + changed = False + for tail in COPY_NOUNS + CASE_ENDINGS: + if text.endswith(tail) and len(text) > len(tail): + text = text[: -len(tail)].strip(" ।,.-") + changed = True + return text + + +def accept_items(proposed, case: dict, max_items: int = MAX_LLM_ITEMS): + """Filter model items to the keepers. Returns `(accepted, [(item, reason)])`. + + Rejections are returned, not logged, to keep this module pure. + """ + accepted, rejected = [], [] + # Derived ONCE -- neither can change while filtering one reply, and + # `reject_item` used to re-walk the evidence list three times per item. + bound, floor = bound_types(case), floor_items(case) + for item in list(proposed or []): + if not isinstance(item, str): + rejected.append((repr(item), "not a string")) + continue + if len(accepted) >= max_items: + rejected.append((item.strip(), f"over the {max_items}-item cap")) + continue + reason = reject_item(item, case, accepted=accepted, bound=bound, floor=floor) + if reason: + rejected.append((item.strip(), reason)) + continue + accepted.append(item.strip()) + return accepted, rejected + + +def build(case: dict, items=()) -> Optional[str]: + """Final `missing_details` value for `case`, or None to write nothing. + + `items` is ALREADY-ACCEPTED model output (see `accept_items`). None is a real + outcome, not a failure -- with no verdict, or with both floor items satisfied + and nothing found, there is nothing honest to say, and the frontend hides the + section on a blank value. + + Over `MAX_CHARS`, trailing items are DROPPED whole rather than truncated + mid-phrase: a half-written document name is worse than an absent one. That + trim is a SANITY GUARD, not a policy -- at 299 and again at 450 it cut real + findings, always the last and most specific one, because specificity is long. + A full 2-floor + `MAX_LLM_ITEMS` value cannot reach 700, so the ITEM COUNT is + the binding limit. + """ + if not has_verdict(case): + return None + chosen = floor_items(case) + [t.strip() for t in (items or []) if t and t.strip()] + chosen = chosen[:MAX_ITEMS] + while chosen and len(render(chosen)) > MAX_CHARS: + chosen.pop() + return render(chosen) or None diff --git a/casework/common/pipeline.py b/casework/common/pipeline.py index 9bf21395..bc79d111 100644 --- a/casework/common/pipeline.py +++ b/casework/common/pipeline.py @@ -125,15 +125,10 @@ class Stage: requires_materials=PRESS_TYPES + COURT_TYPES, requires_stages=("convert",), ), - # provides is ("key_allegations",) ONLY. An earlier draft of the plan also - # listed "missing_details"; that field is real on the case API but this - # enricher never writes it -- the donor has zero references to it anywhere - # in its history, and patches exactly one field - # (enrich_allegations.py:303 `api.patch_field(case_slug, "key_allegations", ...)`). - # `provides` feeds the "already enriched, skip it" idempotency checks, so a - # phantom entry here would make a case look complete that this stage never - # touched -- silently skipping work, the same over-gating shape as the - # tags/entities defects. + # ("key_allegations",) ONLY -- this stage reads the press release, never the + # verdict, so it cannot answer `missing_details`. `provides` feeds the + # "already enriched, skip it" checks: a phantom entry would make a case look + # complete that this stage never touched. "allegations": Stage( "allegations", provides=("key_allegations",), requires_materials=PRESS_TYPES, @@ -150,21 +145,20 @@ class Stage: requires_materials=PRESS_TYPES + COURT_TYPES, requires_stages=("convert",), ), - # `description` writes the long public narrative. It reads the charge - # sheet, the press release AND the verdict, so it gates on both material - # families -- the same PRESS+COURT pair as `timeline`/`entities`, and for - # the same reason: a court-order-only case is still describable, so - # gating on PRESS_TYPES alone would strand it. + # Gates on both material families, like `timeline`/`entities`: a + # court-order-only case is still describable. # - # provides is ("description",) ONLY -- deliberately NOT ("description", - # "title"). The donor regenerated `Case.title` as a side effect of this - # pass; this port drops that (see `casework/enrich_description.py`'s - # docstring) and `title` has exactly one owner, `enrich_card`. Naming - # "title" here would be the phantom-`provides` mistake documented on - # `allegations` above: an idempotency check reading `provides` would call - # a case title-complete that this stage never touched. + # `missing_details` is the one CONDITIONAL entry in this table. The stage + # writes it from an extra key in the same generate call, but that field + # needs the COURT ORDER specifically, while this stage runs on either + # family -- so a press-only case gets a description and no missing_details. + # Read the tuple as "can provide": an idempotency check requiring BOTH + # fields would loop forever on those cases. + # + # NOT ("description", "title"). The donor regenerated `Case.title` here; + # `title` now has exactly one owner, `enrich_card`. "description": Stage( - "description", provides=("description",), + "description", provides=("description", "missing_details"), requires_materials=PRESS_TYPES + COURT_TYPES, requires_stages=("convert",), ), diff --git a/casework/enrich_allegations.py b/casework/enrich_allegations.py index c1632fa2..e674646c 100644 --- a/casework/enrich_allegations.py +++ b/casework/enrich_allegations.py @@ -8,34 +8,9 @@ through `CaseworkApi.patch_field`, which this project's binding constraint restricts to loopback (`127.0.0.1:48010`) only. -CONCERN -- phantom `missing_details` field (flagged for the dispatcher, not -silently "fixed"): `STAGES["allegations"]` in `casework/common/pipeline.py` -declares `provides=("key_allegations", "missing_details")`, and the task-14 -brief accordingly asked for a `normalise_missing_details(value) -> str | None` -helper. Neither exists anywhere in the donor: `git log --all -p -- casework/ -enrich_allegations.py` never mentions `missing_details` in the DB-free script's -history, and the 367-line donor at 0321a85 (read in full) writes exactly ONE -field via `api.patch_field(case_slug, "key_allegations", allegations)`. The -`provides` tuple traces back to `task-11-brief.md` (written before this -donor was ever recovered for a Task-12-14 dispatch), not to any donor -behavior. `missing_details` IS a real `Case` field (see `cases/models.py`, -`cases/services/ciaa_draft_case_service.py`, and the OLD, still-live DB-based -`cases/management/commands/enrich_ciaa_allegations.py`), but this DB-free -enricher never wrote it, before or after deletion. Per this task's explicit -"donor is the source of truth" instruction, this port writes ONLY -`key_allegations` and does NOT invent a `normalise_missing_details` helper. -`STAGES["allegations"].provides` is left uncorrected (out of this task's -Create-file scope -- it is shared Task-11 infrastructure) but should be -revisited: a future idempotency check that requires ALL `provides` fields to -be populated before considering this stage "done" would never see it as -complete. - -The donor's own prompt-context construction is also narrower than -`enrich_missing_bigo.py`'s `_source_metadata`: the donor's -`USER_PROMPT_TEMPLATE` only ever includes the case title, the bigo display -string, and the full press-release markdown body -- it never built a -DocumentSource-title/description prompt block the way the bigo donor did, so -there is no analogous metadata-surfacing helper to port here. +Writes exactly ONE field, `key_allegations`. `missing_details` is written by +`casework/enrich_description.py`, which has the verdict in hand; this stage only +ever reads the press release. Usage: uv run python -m casework.enrich_allegations --dry-run diff --git a/casework/enrich_description.py b/casework/enrich_description.py index baf09b0b..5bfaec1f 100644 --- a/casework/enrich_description.py +++ b/casework/enrich_description.py @@ -5,55 +5,48 @@ `0321a85`, 619 lines). Reads a case's charge sheet, CIAA press release and Special Court verdict entirely over the Jawafdehi HTTP API and asks the premium LLM tier for the अभियोगदावी / बयान / फैसला structure of https://github.com/Jawafdehi/JawafdehiAPI/issues/199. -Never touches the database directly -- writes go through `CaseworkApi.patch_field`, +Never touches the database directly -- writes go through `CaseworkApi.patch_fields`, which this project's binding constraint restricts to loopback (`127.0.0.1:48010`). It is the field production is emptiest on: 188 of 3,003 cases carry a description. -ONE FIELD. This script writes `description` and NOTHING else -- see the three -deliberate deviations from the donor below. +TWO FIELDS, ONE CALL. This script writes `description` and `missing_details`, +and nothing else. The second rides in the SAME generate call because that call +has already paid to summarise the verdict -- `summarize_verdict` bills one +premium request per 150,000-char chunk, and batch verdicts run to a 141,000-char +median. A standalone stage would re-read the same फैसला from scratch, roughly +doubling the batch's premium spend. + +Derivation stays independent: `missing_details` comes from which materials are +BOUND plus which documents the sources cite, never from the finished narrative. +`casework/common/missing_details.py` holds that logic, LLM- and API-free. + +The gates DIFFER, so `build` returns None on cases this stage still describes: +`description` needs press release OR verdict, `missing_details` needs the verdict +specifically. Press-only cases are reported as `no verdict bound`, not an error. DEVIATION 1 -- THE DONOR'S TITLE PASS IS DROPPED. The donor regenerated -`Case.title` in the same LLM call, gated by `--skip-title`, because it had the -source documents in hand and the title came free. `title` now has exactly one -owner, `casework/enrich_card.py`. So `--skip-title`, the `TITLE_RULES` import from -`casework/common/titles.py`, `validate_title`, `title_has_headcount` and the -`"title"` key in the response contract are all gone, and `STAGES["description"] -.provides` is `("description",)` alone. What that buys: one prompt holding the -title rules instead of two, one answer to "why does this case have this title", -and a title that can be regenerated without re-fetching charge sheets. What it -costs: one extra cheap LLM call per case, since `enrich_card` reads the -`description` this script just wrote. `test_never_writes_title` pins it. +`Case.title` in the same call, gated by `--skip-title`. `title` now has exactly +one owner, `casework/enrich_card.py`, so `--skip-title`, `validate_title`, +`title_has_headcount` and the `"title"` response key are all gone. Costs one +extra cheap call per case, since `enrich_card` reads the description this script +wrote. `test_never_writes_title` pins it. DEVIATION 2 -- `invoke_text`, NOT the donor's `invoke_with_tools` + -`convert_date` tool. Two reasons, in order of weight: - 1. The donor passed the tool but never told the model it existed. Compare - `enrich_timeline.py`'s `EXTRACTION_SYSTEM_PROMPT`, which spends a 12-line - "DATE CONVERSION TOOL (MANDATORY)" block on it, against the donor's - description prompt, which mentions dates only under QUALITY RULES and never - names the tool. An unadvertised tool in a prose-generation call is a - tool-loop the model has no reason to enter. - 2. This stage emits prose, not structured dates. Every date reaching it is - already converted -- the FACTUAL TIMELINE block carries `date` (AD) and - `date_bs` written by `enrich_timeline`, which DID use the tool -- or is - copied verbatim from a source document, where the correct behaviour is to - leave the BS date exactly as written. -A single-turn call is also the cheaper shape: `invoke_with_tools` bills every -turn of the loop, and this is the most expensive call in the casework pipeline. -The safety pairing for removing the tool is one added QUALITY RULE forbidding the -model from converting dates itself; without it, dropping the tool would invite -exactly the silent BS<->AD arithmetic error the tool existed to prevent. - -DEVIATION 3 -- THE DONOR'S NGM SECTION IS NOT PORTED. The donor fetched -`GET /ngm/court_case/{special:NNN-CR-NNNN}` and formatted it as a prompt block. -That path is doubly dead and `casework/enrich_timeline.py` documents the -measurements: the colon-prefixed `special:` reference it scans for matches 0 of -109 real `court_cases` entries (they are full IRIs), and the endpoint itself was -removed in the 2026-07-01 hard cut of `config/urls.py`. `enrich_timeline` kept its -copy dead-but-intact for a port-vs-donor A/B that this task has no part in; -porting an unreachable HTTP call a second time would add a code path no run can -enter. Prompt-identical on current data either way: the donor's `{ngm_section}` -renders to the empty string whenever the fetch returns None, which is always. +`convert_date` tool. The donor passed the tool but never told the model it +existed, and this stage emits prose, not structured dates: every date reaching it +is already converted by `enrich_timeline` or copied verbatim from a source, where +leaving the BS date as written is correct. The paired safety change is the +QUALITY RULE forbidding the model from converting dates itself -- without it, +dropping the tool invites the silent BS<->AD arithmetic error it prevented. + +DEVIATION 3 -- THE DONOR'S NGM SECTION IS NOT PORTED. It fetched +`GET /ngm/court_case/{special:NNN-CR-NNNN}`, a doubly dead path: the +colon-prefixed reference matches 0 of 109 real `court_cases` entries (they are +full IRIs), and the endpoint was removed in the 2026-07-01 cut of `config/urls.py`. +Prompt-identical either way -- the donor's `{ngm_section}` renders empty whenever +the fetch returns None, which is always. `casework/enrich_timeline.py` has the +measurements. Usage: uv run python -m casework.enrich_description --dry-run @@ -67,7 +60,6 @@ import logging import sys import time -from typing import Optional from casework.common.api import CaseworkApi from casework.common.cli import ( @@ -83,6 +75,9 @@ from casework.common.format import format_bigo, format_entities, format_list from casework.common.llm import bootstrap, tier_for from casework.common.materials import source_chunks +from casework.common.missing_details import accept_items +from casework.common.missing_details import build as build_missing_details +from casework.common.missing_details import has_verdict, held_summary from casework.common.parse import parse_object_response from casework.common.pipeline import ( COURT_TYPES, @@ -206,8 +201,51 @@ - This is an official public record drawn from government/court documents; do not soften, editorialise, or add commentary. Neutral, factual tone only. +MISSING DOCUMENTS — a SECOND, separate output, independent of the description: + +The source documents REFER to other documents — witness depositions, defendant +statements, contracts, audit reports, bid papers, bank records, court orders. Some +of those we hold; most we do not. Your job is the DIFFERENCE. + +You are told below, under DOCUMENTS WE ALREADY HOLD, exactly what is in our +evidence. List the documents the sources REFERENCE OR RELY ON that are NOT in +that list, up to 4, most significant first. + +Rules: +- Each item must be a document the sources actually mention, cite, or quote. + If you cannot point to where it is referenced, leave it out. +- NEVER list something that appears in DOCUMENTS WE ALREADY HOLD. +- Be SPECIFIC. Name the document, with its date, party, phase, or number when the + sources give one. Specificity is the whole value of this output. +- Short Nepali noun phrases (देवनागरी), not sentences. No "… छैन।" — the page + already frames these as missing. Keep each under ~15 words. +- Do NOT list: अन्य आवश्यक स्रोतहरू, थप आधार र प्रमाण, अन्य प्रमाण, or any + catch-all filler. An item a reader cannot go and look for is worthless. +- Do NOT list the अभियोगपत्र/आरोपपत्र, and do NOT comment on whether an appeal + (पुनरावेदन) was filed. Both are already handled. +- Do not criticise the court's reasoning or the CIAA's investigation. This output + is about our archive's completeness, not the case's merits. +- Return [] when the sources reference nothing beyond what we hold. An empty list + is a perfectly good answer; padding it with vague items is not. + +Good (all from published cases): +- ३ चरणका ठेक्का सम्झौताका प्रतिलिपि +- मूल सम्झौता (2011) र पुरक सम्झौता +- लेखापरीक्षण प्रतिवेदन र महालेखापरीक्षकको टिप्पणी +- सुनिल पौडेलको UOB Singapore बैंक खातामा जम्मा भएको लेनदेन विवरण र मिति +- मिति २०८१।१२।१८ गते विशेष अदालतबाट भएको आदेश +- प्रतिवादीहरूले अदालतमा गरेको बयानको ब्याहोरा +- साक्षीहरूको वकपत्र + +Bad: +- अन्य आवश्यक स्रोतहरू। → filler; names nothing +- थप आधार र प्रमाण पुष्टि गर्ने प्रमाणिक स्रोत → filler +- अभियोगपत्र → already handled +- प्रेस विज्ञप्ति → we hold it; check the list before you write +- अदालतले पर्याप्त प्रमाण मूल्याङ्कन गरेको छैन। → a criticism, and a sentence + OUTPUT FORMAT — return ONLY a single JSON object, no markdown fences, no prose: -{"description": "### क) …\\n…"} +{"description": "### क) …\\n…", "missing_documents": []} """ EXTRACTION_USER_PROMPT = """\ @@ -231,6 +269,11 @@ paraphrase it, and never treat it as a published fact. {entities} +DOCUMENTS WE ALREADY HOLD (our complete evidence for this case — anything the +sources reference that is NOT here is what `missing_documents` must report; never +list one of these): +{held_documents} + SOURCE DOCUMENTS (press release, charge sheet, verdict — the factual basis for the description; quote specifics from here): @@ -338,7 +381,11 @@ def _assemble_source_text(chunks, invoke_text, usage): def _generate_description(detail, court_number, source_text, invoke_text, usage): - """One premium-tier call. Returns the description string, or None.""" + """One premium-tier call. Returns `(description, documents)`. + + `documents` is raw model output, unvalidated on purpose -- + `missing_details.accept_items` owns every acceptance rule. + """ prompt = EXTRACTION_USER_PROMPT.format( case_title=detail.get("title") or "", # UPPERCASED. `select.court_number()` reads the number off the canonical @@ -354,6 +401,7 @@ def _generate_description(detail, court_number, source_text, invoke_text, usage) key_allegations=format_list(detail.get("key_allegations")), timeline=json.dumps(detail.get("timeline") or [], ensure_ascii=False), entities=format_entities(detail.get("entities")), + held_documents=held_summary(detail), source_text=source_text, ) response_text = invoke_text( @@ -366,20 +414,69 @@ def _generate_description(detail, court_number, source_text, invoke_text, usage) return _parse_description_response(response_text) -def _parse_description_response(response_text: str) -> Optional[str]: - """Pull `description` out of the JSON object reply. +def _parse_description_response(response_text: str): + """Pull `description` and `missing_documents` out of the JSON object reply. - A `title` key in the reply is IGNORED, not written -- see deviation 1. The - model can still emit one (the OUTPUT FORMAT block does not ask for it, but - models volunteer keys); silently dropping it here is what makes the - single-owner rule hold even against a chatty response. + `description` may be None; `documents` is always a list. `description` is the + REQUIRED key for the object scan -- requiring `missing_documents` too would + reject every correct empty-list reply. A volunteered `title` key is ignored, + which is what holds the single-owner rule against a chatty response. """ obj = parse_object_response(response_text, "description") if obj is None: log.warning("No JSON object with a description found in the LLM response") - return None + return None, [] description = (obj.get("description") or "").strip() - return description or None + return (description or None), _coerce_documents(obj.get("missing_documents")) + + +def _coerce_documents(raw) -> list: + """Normalise the `missing_documents` value into a list of strings. + + Tolerant on SHAPE, strict on content -- content rules live in + `missing_details.reject_item`, where they can be tested. A bare string, a + quoted `"[]"`, or a newline-joined blob is a formatting slip, not a wrong + answer, so rejecting it would throw away a correct finding over punctuation. + """ + if raw is None: + return [] + if isinstance(raw, str): + raw = [raw] + if not isinstance(raw, (list, tuple)): + log.warning("missing_documents was %s, not a list -- ignored", type(raw).__name__) + return [] + out = [] + for entry in raw: + if not isinstance(entry, str): + continue + # Split a newline-joined blob, and strip any enumerator the model added + # despite being asked for plain phrases. + for line in entry.splitlines(): + line = line.strip().lstrip("-–•").strip() + for mark in ("क)", "ख)", "ग)", "घ)", "ङ)", "च)", + "१.", "२.", "३.", "४.", "५.", + "1.", "2.", "3.", "4.", "5."): + if line.startswith(mark): + line = line[len(mark):].strip() + break + # Checked PER ENTRY, not just on a bare string. A model that answers + # `["कुनै छैन"]` means "nothing"; treated as an item it publishes the + # word "none" as a missing document, and no content rule catches it. + if _is_nothing(line): + continue + if line: + out.append(line) + return out + + +# "no documents" spelled as text rather than as an empty list. +_NOTHING = ("", "null", "none", "n/a", "na", "-", "[]", "{}", "nil") +_NOTHING_NE = ("कुनै छैन", "कुनै पनि छैन", "छैन", "कुनै कागजात छैन") + + +def _is_nothing(line: str) -> bool: + text = (line or "").strip().strip("।.") + return text.lower() in _NOTHING or text in _NOTHING_NE def _has_substantial_description(case: dict) -> bool: @@ -505,8 +602,18 @@ def main(argv=None): before = (detail.get("description") or "").strip() - if _has_substantial_description(detail) and not args.force: + # PER FIELD, because this stage writes two. A description-only check + # skipped every case that already had a description before `missing_details` + # was even computed -- and `provides` names both fields, so an orchestrator + # reading it would call exactly those cases complete. The description itself + # is still never overwritten without --force; see the write below. + description_done = _has_substantial_description(detail) + missing_done = bool((detail.get("missing_details") or "").strip()) + if description_done and (missing_done or not has_verdict(detail)) \ + and not args.force: reason = f"description already {len(before):,} chars" + if not missing_done: + reason += "; missing_details needs a verdict" report.record(slug, "description", "already", reason) review.add(ReviewRow(slug=slug, status="already", before=before, note=reason)) log_event(logger, paths["events"], run_id=run_id, stage="description", @@ -569,7 +676,7 @@ def main(argv=None): continue try: - description = _generate_description( + description, documents = _generate_description( detail=detail, court_number=court_number(detail), source_text=source_block, @@ -598,17 +705,130 @@ def main(argv=None): log_event(logger, paths["events"], run_id=run_id, stage="description", slug=slug, step="generate", status="skipped", detail="LLM returned no description", level=logging.WARNING) + if documents: + # This stage abandons the case here, so the findings die with it. + # Unlogged they are indistinguishable from a model that found + # nothing -- the exact confusion the rejection logging exists for. + log_event(logger, paths["events"], run_id=run_id, stage="description", + slug=slug, step="documents", status="discarded", + detail=f"{len(documents)} item(s) lost with the blank " + "description: " + " | ".join(documents), + level=logging.WARNING) continue + before_missing = (detail.get("missing_details") or "").strip() + + # A PARTIAL FETCH IS THE TRAP HERE. `has_verdict` is BINDING-based, so a + # case whose court order failed to fetch still reports True and + # `held_summary` still tells the model we hold the verdict -- so it is asked + # to diff the sources against an inventory it could not read, and the result + # would be published. The description path has `source_note` for this; this + # field falls back to the deterministic floor, which is binding-based and + # therefore still true. + # + # Read off `chunks`, NOT off `text_unmet`'s prose. Parsing `f"{mtype}: …"` + # out of a human-readable reason meant rewording that message silently + # disabled the guard, and a case with two court orders where one failed + # tripped it even though a verdict HAD been read. + verdict_read = any(mtype in COURT_TYPES for mtype, _, _ in chunks) + verdict_lost = has_verdict(detail) and not verdict_read + + blocked = None + if not has_verdict(detail): + blocked = "no verdict bound" + elif verdict_lost: + blocked = "verdict source was not fetched; deterministic floor only" + elif before_missing: + blocked = f"missing_details already {len(before_missing):,} chars" + + # Every rejection is logged with the rule that fired: a silently dropped + # item looks identical to a model that found nothing, and those need + # opposite follow-up (prompt problem vs sourcing problem). Rejections + # never block the write -- the floor alone is a publishable value. + kept, rejected = accept_items(documents, detail) + for item, reason in rejected: + log_event(logger, paths["events"], run_id=run_id, stage="description", + slug=slug, step="documents", status="rejected", + detail=f"{reason}: {item[:120]}", level=logging.WARNING) + if kept and blocked: + # "accepted" followed by "skipped: " reads as a contradiction, + # and this log exists to tell a prompt problem from a sourcing problem. + log_event(logger, paths["events"], run_id=run_id, stage="description", + slug=slug, step="documents", status="discarded", + detail=f"{len(kept)} item(s) found but {blocked}: " + + " | ".join(kept), level=logging.WARNING) + elif kept: + log_event(logger, paths["events"], run_id=run_id, stage="description", + slug=slug, step="documents", status="accepted", + detail=f"{len(kept)} of {len(documents)}: " + " | ".join(kept)) + elif documents: + log_event(logger, paths["events"], run_id=run_id, stage="description", + slug=slug, step="documents", status="none-kept", + detail=f"all {len(documents)} proposed item(s) rejected", + level=logging.WARNING) + + missing = build_missing_details(detail, [] if verdict_lost else kept) + + # NEVER TOUCH A NON-EMPTY VALUE -- not even with --force. Two things share + # this field and neither can be safely merged into: + # - the importer's truncation guard + # (`CIAADraftCaseService._flag_truncated_roster`) appends + # `ACCUSED LIST INCOMPLETE`, and losing it would let a case publish with + # a knowingly truncated accused list; + # - the 61 hand-written published values. + # Appending was tried and withdrawn. A repeat --force run reads run 1's own + # output back as `before_missing`, and the floor items alone are not a + # usable signature for "this stage wrote it" -- they were copied verbatim + # FROM hand-written cases. So any append duplicates the floor and restarts + # the enumeration (`क) … ख) … क) … ख) …`), unbounded across runs, and the + # concatenation never passes back through `build` so MAX_CHARS never sees + # it. Refusing costs a manual clear; appending corrupts the page. + if missing and before_missing: + log_event(logger, paths["events"], run_id=run_id, stage="description", + slug=slug, step="missing_details", status="already", + detail=f"missing_details already {len(before_missing):,} chars; " + "not appending (clear the field by hand to regenerate)" + + (" -- --force does NOT override this" if args.force else ""), + level=logging.WARNING if args.force else logging.INFO) + missing = None + elif not missing: + # Distinct reasons needing different follow-up: a press-only case wants + # sourcing, a case with both floor items satisfied wants nothing. + log_event(logger, paths["events"], run_id=run_id, stage="description", + slug=slug, step="missing_details", status="skipped", + detail=blocked or "no gap to report") + detail_msg = f"description={len(description):,} chars" + if missing: + detail_msg += f", missing_details={len(missing):,} chars" log_event(logger, paths["events"], run_id=run_id, stage="description", slug=slug, step="generate", status="ok", detail=detail_msg) + # `generated` stays the DESCRIPTION ALONE. The review file prints + # `len(generated)` in its summary table and section heading under a header + # naming `description`, and that before/after size comparison is what flags + # a truncated or runaway description -- folding a second field into it + # over-reported by the length of that field. + # + # missing_details rides in `note` instead, which the file prints per case. + # Discarded findings go there too: without them, "found four documents and + # threw them away" looks identical to "found nothing" to the person + # approving the run, which is the confusion the reject logging exists to + # prevent. + md_note = "" + if missing: + md_note = "missing_details → " + " · ".join(missing.splitlines()) + elif blocked and kept: + md_note = (f"missing_details NOT written ({blocked}); discarded: " + + " · ".join(kept)) + elif blocked: + md_note = f"missing_details NOT written ({blocked})" + note = "; ".join(filter(None, (source_note, md_note))) + if args.dry_run: report.record(slug, "description", "would-enrich", detail_msg) review.add(ReviewRow(slug=slug, status="would-enrich", before=before, - generated=description, sources=fed, - note=source_note)) + generated=description, sources=fed, note=note)) log_event(logger, paths["events"], run_id=run_id, stage="description", slug=slug, step="write", status="would-enrich", detail=detail_msg) continue @@ -626,18 +846,36 @@ def main(argv=None): report.record(slug, "description", "error", reason) review.add(ReviewRow(slug=slug, status="error", before=before, generated=description, sources=fed, - note="; ".join(filter(None, (reason, source_note))))) + note="; ".join(filter(None, (reason, note))))) log_event(logger, paths["events"], run_id=run_id, stage="description", slug=slug, step="write", status="error", detail=reason, level=logging.ERROR) continue try: - api.patch_field(slug, "description", description, if_match=etag) + # ONE conditional request for both fields. `patch_fields`, not two + # `patch_field` calls: the second call's ETag would already be stale + # from the first write, so a loop cannot stay conditional. + # + # The description is omitted when the case already had a substantial + # one and --force was not passed. That is what lets the per-field gate + # above admit a described case for its EMPTY missing_details without + # silently rewriting prose a human may have approved. + pairs = [] + if not description_done or args.force: + pairs.append(("description", description)) + if missing: + pairs.append(("missing_details", missing)) + if not pairs: + log_event(logger, paths["events"], run_id=run_id, stage="description", + slug=slug, step="write", status="skipped", + detail="nothing left to write") + report.record(slug, "description", "already", "nothing left to write") + continue + api.patch_fields(slug, pairs, if_match=etag) report.record(slug, "description", "enriched", detail_msg) review.add(ReviewRow(slug=slug, status="enriched", before=before, - generated=description, sources=fed, - note=source_note)) + generated=description, sources=fed, note=note)) log_event(logger, paths["events"], run_id=run_id, stage="description", slug=slug, step="write", status="enriched", detail=detail_msg) except Exception as exc: # noqa: BLE001 - a PATCH failure is recorded per-case and the run continues @@ -645,7 +883,7 @@ def main(argv=None): review.add(ReviewRow(slug=slug, status="error", before=before, generated=description, sources=fed, note="; ".join(filter(None, ( - f"PATCH failed: {exc}", source_note))))) + f"PATCH failed: {exc}", note))))) log_event(logger, paths["events"], run_id=run_id, stage="description", slug=slug, step="write", status="error", detail=str(exc), level=logging.ERROR) diff --git a/tests/casework/test_enrich_allegations.py b/tests/casework/test_enrich_allegations.py index 925a4d98..1257bdb8 100644 --- a/tests/casework/test_enrich_allegations.py +++ b/tests/casework/test_enrich_allegations.py @@ -4,19 +4,10 @@ from a case's CIAA press-release markdown, at the premium tier, and writes ONLY `key_allegations` (`api.patch_field(slug, "key_allegations", allegations)`). -BRIEF-VS-DONOR DIFFERENCE (see module docstring for the full writeup): the -task-14 brief asked for a `normalise_missing_details(value) -> str | None` -helper and treated `missing_details` as a second field this stage writes. -Neither exists in the donor at commit `0321a85` -- `git log --all -p -- -casework/enrich_allegations.py` never mentions `missing_details` anywhere in -this script's history, and the 367-line donor writes exactly one field. -`STAGES["allegations"].provides == ("key_allegations", "missing_details")` -in `casework/common/pipeline.py` traces back to `task-11-brief.md`, written -before this donor was ever recovered. This test file does NOT implement or -test `normalise_missing_details` (it would be an invented function with no -donor basis -- exactly the trap flagged for this task), and instead pins the -donor's real behavior: `key_allegations` is the only field ever PATCHed -(`test_only_key_allegations_field_is_ever_patched`). +`missing_details` is NOT this stage's field -- it belongs to +`casework/enrich_description.py`, which has the verdict in hand. This stage only +ever reads the press release. `test_only_key_allegations_field_is_ever_patched` +pins that. The `TestDonorFidelity` class re-derives `SYSTEM_PROMPT` / `USER_PROMPT_TEMPLATE` directly from the donor at commit `0321a85` (via `git show` + `ast.literal_eval`, diff --git a/tests/casework/test_enrich_description.py b/tests/casework/test_enrich_description.py index 344c4542..ba673b8b 100644 --- a/tests/casework/test_enrich_description.py +++ b/tests/casework/test_enrich_description.py @@ -30,6 +30,7 @@ import pytest from casework import enrich_description as ed +from casework.common import missing_details as md from casework.enrich_description import ( _allocate_budget, _assemble_source_text, @@ -276,17 +277,43 @@ def test_stage_is_registered_with_both_material_families(): assert stage.requires_stages == ("convert",) -def test_stage_provides_description_only(): - """`title` must NOT appear in `provides`. +def test_stage_provides_description_and_missing_details_but_never_title(): + """`title` must NOT appear in `provides`; `missing_details` must. - `provides` feeds "already enriched, skip it" idempotency checks, so - naming a field this stage never writes makes a case look title-complete - that this stage never touched -- the phantom-entry defect documented on - STAGES["allegations"]. + `provides` feeds "already enriched, skip it" idempotency checks, so naming a + field this stage never writes makes a case look complete that this stage + never touched -- the phantom-entry defect documented on STAGES["allegations"]. + `title` stays out because `enrich_card` owns it. + + `missing_details` is in because this stage really does write it, from one + extra key in the same generate call. See the conditional caveat below. """ from casework.common.pipeline import STAGES - assert STAGES["description"].provides == ("description",) + assert STAGES["description"].provides == ("description", "missing_details") + + +def test_provides_is_conditional_for_missing_details(): + """`missing_details` is the one CONDITIONAL entry in the STAGES table. + + This stage's own gate is any-of(press, court), but `missing_details` needs the + COURT ORDER specifically, so a press-release-only case gets a description and + no missing_details. An idempotency check that required BOTH fields before + calling the stage done would therefore loop forever on press-only cases. + + Pinned as behaviour, not just a comment: `build` must return None on exactly + that shape. + """ + from casework.common.missing_details import build + + press_only = { + "court_cases": [], + "evidence": [{"material_iri": "x", + "material": {"material_type": "ciaa_press_release"}}], + } + assert build(press_only) is None + assert build(press_only, ["साक्षीहरूको वकपत्र"]) is None, ( + "no verdict means no missing_details, even with accepted model items") def test_tier_is_premium(): @@ -301,23 +328,31 @@ def test_tier_is_premium(): class TestParseDescriptionResponse: + """`_parse_description_response` returns `(description, documents)`. + + `documents` is ALWAYS a list -- never None -- so callers can iterate without + a guard. `description` stays the required key for the object scan: a reply + carrying only a document list is not a usable answer. + """ + def test_parses_the_object(self): body = json.dumps({"description": "### क) अभियोगदावीको सार\nविवरण।"}) - assert _parse_description_response(body) == "### क) अभियोगदावीको सार\nविवरण।" + assert _parse_description_response(body) == ( + "### क) अभियोगदावीको सार\nविवरण।", []) def test_a_volunteered_title_key_is_ignored_not_returned(self): """The single-owner rule has to hold against a chatty model. The OUTPUT FORMAT block no longer asks for a title, but models - volunteer keys. Returning only the string is what makes it impossible - for a stray `"title"` to reach a PATCH. + volunteer keys. Never returning it is what makes it impossible for a + stray `"title"` to reach a PATCH. """ body = json.dumps({"title": "नयाँ शीर्षक (081-CR-0091)", "description": "विवरण।"}) - assert _parse_description_response(body) == "विवरण।" + assert _parse_description_response(body) == ("विवरण।", []) def test_fenced_json_is_parsed(self): body = 'यहाँ छ:\n```json\n{"description": "विवरण।"}\n```\n' - assert _parse_description_response(body) == "विवरण।" + assert _parse_description_response(body) == ("विवरण।", []) def test_a_leading_unrelated_object_does_not_stop_the_scan(self): """Donor behaviour: every `{` is tried, not just the first. @@ -326,19 +361,75 @@ def test_a_leading_unrelated_object_does_not_stop_the_scan(self): tool argument) returns None under a `text.find("{")` parser. """ body = '{"note": "thinking"} then: {"description": "असली विवरण।"}' - assert _parse_description_response(body) == "असली विवरण।" + assert _parse_description_response(body) == ("असली विवरण।", []) def test_returns_none_when_the_key_is_absent(self): - assert _parse_description_response('{"other": "value"}') is None + assert _parse_description_response('{"other": "value"}') == (None, []) def test_returns_none_for_a_blank_description(self): - assert _parse_description_response(json.dumps({"description": " "})) is None + assert _parse_description_response( + json.dumps({"description": " "})) == (None, []) def test_returns_none_for_unparseable_text(self): - assert _parse_description_response("not json at all") is None + assert _parse_description_response("not json at all") == (None, []) def test_returns_none_for_empty_input(self): - assert _parse_description_response("") is None + assert _parse_description_response("") == (None, []) + + def test_missing_documents_are_returned(self): + body = json.dumps({ + "description": "विवरण।", + "missing_documents": ["३ चरणका ठेक्का सम्झौताका प्रतिलिपि", "साक्षीहरूको वकपत्र"], + }) + assert _parse_description_response(body) == ( + "विवरण।", ["३ चरणका ठेक्का सम्झौताका प्रतिलिपि", "साक्षीहरूको वकपत्र"]) + + def test_a_bare_string_is_accepted_as_one_document(self): + """A formatting slip, not a wrong answer -- rejecting it would throw away + a correct finding over punctuation.""" + body = json.dumps({"description": "विवरण।", "missing_documents": "मूल सम्झौता (2011)"}) + assert _parse_description_response(body) == ("विवरण।", ["मूल सम्झौता (2011)"]) + + def test_a_quoted_empty_list_is_no_documents(self): + body = json.dumps({"description": "विवरण।", "missing_documents": "[]"}) + assert _parse_description_response(body) == ("विवरण।", []) + + def test_enumerators_the_model_added_are_stripped(self): + """The prompt asks for plain phrases; models number them anyway. Leaving + the marks in would double-enumerate once `render` adds its own.""" + body = json.dumps({ + "description": "विवरण।", + "missing_documents": ["क) साक्षीहरूको वकपत्र", "- मूल सम्झौता", "२. लेखापरीक्षण प्रतिवेदन"], + }) + _, docs = _parse_description_response(body) + assert docs == ["साक्षीहरूको वकपत्र", "मूल सम्झौता", "लेखापरीक्षण प्रतिवेदन"] + + def test_a_newline_joined_blob_is_split(self): + body = json.dumps({ + "description": "विवरण।", + "missing_documents": ["साक्षीहरूको वकपत्र\nमूल सम्झौता (2011)"], + }) + _, docs = _parse_description_response(body) + assert docs == ["साक्षीहरूको वकपत्र", "मूल सम्झौता (2011)"] + + def test_a_non_list_non_string_shape_is_ignored(self): + body = json.dumps({"description": "विवरण।", "missing_documents": {"a": 1}}) + assert _parse_description_response(body) == ("विवरण।", []) + + @pytest.mark.parametrize("sentinel", ["कुनै छैन", "कुनै पनि छैन", "[]", "N/A", + "none", "-", "छैन।"]) + def test_a_nothing_sentinel_INSIDE_a_list_is_not_an_item(self, sentinel): + """The sentinel test used to run only on a bare string. A model answering + `["कुनै छैन"]` means "nothing" -- treated as an item it publishes the word + "none" as a missing document, and no content rule in `reject_item` catches + it.""" + body = json.dumps({"description": "विवरण।", "missing_documents": [sentinel]}) + assert _parse_description_response(body) == ("विवरण।", []) + + def test_a_sentinel_mixed_with_real_findings_drops_only_the_sentinel(self): + body = json.dumps({"description": "विवरण।", + "missing_documents": ["साक्षीहरूको वकपत्र", "कुनै छैन"]}) + assert _parse_description_response(body) == ("विवरण।", ["साक्षीहरूको वकपत्र"]) def test_prompt_context_comes_from_the_shared_formatters(donor_source): @@ -549,7 +640,7 @@ def stub(**kw): detail=DETAIL_FOR_PROMPT, court_number="081-cr-0091", source_text="स्रोत पाठ", invoke_text=stub, usage=None, ) - assert result == "विवरण।" + assert result == ("विवरण।", []) assert seen["tier"] == "premium" assert seen["max_tokens"] == 8000 assert seen["system"] == ed.EXTRACTION_SYSTEM_PROMPT @@ -657,6 +748,38 @@ def test_missing_and_whitespace_are_both_empty(self): CASE_ALREADY = dict(CASE_READY, slug="case-already", description="क" * 900) +# `CASE_READY` holds a press release and a court order, no charge sheet, and a +# `special:` court-case ref only -- so BOTH deterministic floor items fire. That +# is the shape 24 of the 25 cases in the first production batch have. +MD_FLOOR_BOTH = ( + "१. अख्तियार दुरुपयोग अनुसन्धान आयोगले दायर गरेको अभियोगपत्र\n" + "२. हदम्याद भित्र वादी वा प्रतिवादीले सर्वोच्च अदालतमा पुनरावेदन गरे नगरेको ब्याहोरा" +) + +# Same case with a Supreme Court reference on file: the appeal item drops, so the +# floor is one item and renders bare, with no enumerator. +CASE_WITH_APPEAL = dict( + CASE_READY, slug="case-with-appeal", + court_cases=["https://jawafdehi.org/courtcase/special/081-cr-0091", + "https://jawafdehi.org/courtcase/supreme/081-cr-2319"], +) + +# Press release only. `description` still runs (its gate is any-of), but +# `missing_details` must not be written -- there is no verdict to diff against. +CASE_PRESS_ONLY = dict( + CASE_READY, slug="case-press-only", + evidence=[CASE_READY["evidence"][0]], +) + +# A case the importer already flagged. The truncation marker must survive. +TRUNCATION_MARKER = ( + "ACCUSED LIST INCOMPLETE: 2 defendant(s) imported (NGM parsed 2); court " + "record states ≈5. Roster truncated at source — rebuild from the court " + "order before publishing." +) +CASE_FLAGGED = dict( + CASE_READY, slug="case-flagged", missing_details=TRUNCATION_MARKER) + # A template stub title plus a stub description: the case a title-writing # regression would visibly damage. CASE_STUB_TITLE = dict( @@ -674,6 +797,10 @@ def __init__(self, cases, etag="W/\"abc123\"", fail_detail_for=()): self._etag = etag self._fail_detail_for = set(fail_detail_for) self.patched = [] + # One entry per PATCH REQUEST, as `(slug, [field, ...], if_match)` -- + # `patched` is per-field and cannot show that both fields went in a + # single conditional write. + self.patch_calls = [] def iter_cases(self, params=None, timeout=60): yield from self._cases.values() @@ -688,6 +815,21 @@ def patch_field(self, slug, field, value, timeout=60, if_match=None): self._cases[slug][field] = value return {} + def patch_fields(self, slug, pairs, timeout=60, if_match=None): + """Record each pair as its own `patched` entry. + + Flattened deliberately: every existing assertion in this file reads + `api.patched` as `(slug, field, value, if_match)` tuples, and the fused + write is still ONE conditional request per case. `patch_calls` below + counts requests for the tests that care about that instead. + """ + pairs = list(pairs) + self.patch_calls.append((slug, [f for f, _ in pairs], if_match)) + for field, value in pairs: + self.patched.append((slug, field, value, if_match)) + self._cases[slug][field] = value + return {} + class _FakeUsage: def __init__(self): @@ -763,10 +905,13 @@ def test_unmet_prerequisite_is_recorded_not_silently_skipped( assert api.patched == [] -def test_an_already_described_case_is_skipped_without_calling_the_llm( +def test_an_already_described_case_is_skipped_when_BOTH_fields_are_done( monkeypatch, patched_fetch_markdown ): - api = _StubApi([CASE_ALREADY]) + """The gate is PER FIELD now that the stage writes two.""" + case = dict(CASE_ALREADY) + case["missing_details"] = MD_FLOOR_BOTH + api = _StubApi([case]) stub = _tracking_stub() report = _run_main(monkeypatch, api, stub, BASE_ARGV + ["--dry-run"]) assert report.rows[0]["status"] == "already" @@ -774,6 +919,21 @@ def test_an_already_described_case_is_skipped_without_calling_the_llm( assert api.patched == [] +def test_a_described_case_with_an_empty_missing_details_is_still_processed( + monkeypatch, patched_fetch_markdown +): + """A description-only gate skipped these before `missing_details` was even + computed -- so the ~188 production cases that already carry a description could + never get the new field, while `provides` claimed they were complete. The + description itself is NOT rewritten: only the empty field is patched.""" + api = _StubApi([CASE_ALREADY]) # substantial description, no missing_details + _run_main(monkeypatch, api, _stub_with_documents("नयाँ विवरण।", []), + BASE_ARGV + ["--apply"]) + written = {f: v for _, f, v, _ in api.patched} + assert "missing_details" in written + assert "description" not in written, "rewrote a description without --force" + + def test_force_regenerates_an_already_described_case(monkeypatch, patched_fetch_markdown): api = _StubApi([CASE_ALREADY]) report = _run_main( @@ -781,8 +941,12 @@ def test_force_regenerates_an_already_described_case(monkeypatch, patched_fetch_ BASE_ARGV + ["--force", "--apply"], ) assert report.rows[0]["status"] == "enriched" - assert [(s, f, v) for s, f, v, _ in api.patched] == [ - ("case-already", "description", "नयाँ विवरण।")] + written = {f: v for _, f, v, _ in api.patched} + assert written["description"] == "नयाँ विवरण।" + # CASE_ALREADY carries no prior missing_details, so the floor is written + # outright. That --force does NOT touch a populated value is pinned by + # test_force_never_touches_an_existing_missing_details. + assert MD_FLOOR_BOTH in written["missing_details"] def test_dry_run_generates_but_does_not_patch(monkeypatch, patched_fetch_markdown): @@ -801,7 +965,11 @@ def test_apply_patches_description_with_the_etag_as_if_match( report = _run_main( monkeypatch, api, _tracking_stub("विवरण।"), BASE_ARGV + ["--apply"]) assert report.rows[0]["status"] == "enriched" - assert api.patched == [("case-ready", "description", "विवरण।", 'W/"etag-42"')] + assert ("case-ready", "description", "विवरण।", 'W/"etag-42"') in api.patched + # BOTH fields go in ONE conditional request. Two `patch_field` calls could + # not stay conditional -- the second would carry an ETag the first invalidated. + assert api.patch_calls == [ + ("case-ready", ["description", "missing_details"], 'W/"etag-42"')] def test_never_writes_title(monkeypatch, patched_fetch_markdown): @@ -824,7 +992,12 @@ def stub(**kw): report = _run_main(monkeypatch, api, stub, BASE_ARGV + ["--force", "--apply"]) assert report.rows[0]["status"] == "enriched" - assert {field for _, field, _, _ in api.patched} == {"description"} + fields = {field for _, field, _, _ in api.patched} + # Asserted as an exclusion, not an exact set: this test is about `title` + # never being written, and pinning the exact set makes it fail whenever an + # unrelated field is legitimately added (as `missing_details` was). + assert "title" not in fields + assert fields <= {"description", "missing_details"} assert api._cases["case-stub-title"]["title"] == before_title @@ -1123,3 +1296,298 @@ def test_a_dry_run_is_not_blocked_by_a_missing_etag( assert api.patched == [] text = _review_file(tmp_path).read_text(encoding="utf-8") assert "ठेक्का विवरण।" in text + + +# -------------------------------------------------------------------------- +# missing_details: the second field this stage writes +# +# The value is assembled from a DETERMINISTIC floor (what is bound) plus up to +# four specific documents the model found referenced in the sources but absent +# from our evidence. Pure-function rules live in +# tests/casework/test_missing_details.py; these are the end-to-end paths. +# -------------------------------------------------------------------------- + + +def _stub_with_documents(description, documents): + def stub(**kw): + return json.dumps( + {"description": description, "missing_documents": documents}) + + return stub + + +def test_apply_writes_the_floor_when_the_model_finds_nothing( + monkeypatch, patched_fetch_markdown +): + """An empty document list is a good answer, not a failure. + + The floor alone is a complete, publishable value -- it is what 5 published + cases carry -- so the field is still written. + """ + api = _StubApi([CASE_READY]) + report = _run_main(monkeypatch, api, _stub_with_documents("विवरण।", []), + BASE_ARGV + ["--apply"]) + assert report.rows[0]["status"] == "enriched" + written = {f: v for _, f, v, _ in api.patched} + assert written["missing_details"] == MD_FLOOR_BOTH + + +def test_model_documents_are_appended_and_switch_the_enumerator( + monkeypatch, patched_fetch_markdown +): + """Two floor items plus two found documents = 4 items, so the enumerator + moves from Devanagari numerals to Nepali letters. Chosen AFTER the model's + items are accepted -- never before.""" + api = _StubApi([CASE_READY]) + _run_main( + monkeypatch, api, + _stub_with_documents( + "विवरण।", ["३ चरणका ठेक्का सम्झौताका प्रतिलिपि", "साक्षीहरूको वकपत्र"]), + BASE_ARGV + ["--apply"], + ) + value = {f: v for _, f, v, _ in api.patched}["missing_details"] + assert value.startswith("क) अख्तियार") + assert "ग) ३ चरणका ठेक्का सम्झौताका प्रतिलिपि" in value + assert "घ) साक्षीहरूको वकपत्र" in value + assert "१." not in value, "numerals are for the 2-item shape only" + + +def test_a_document_we_already_hold_is_rejected(monkeypatch, patched_fetch_markdown): + """THE CHECKABLE GROUNDING RULE. The model is told what we hold; a claim that + a held document is missing is contradicted by our own bindings, so it is + dropped in code rather than trusted.""" + api = _StubApi([CASE_READY]) + _run_main( + monkeypatch, api, + _stub_with_documents("विवरण।", ["प्रेस विज्ञप्ति", "साक्षीहरूको वकपत्र"]), + BASE_ARGV + ["--apply"], + ) + value = {f: v for _, f, v, _ in api.patched}["missing_details"] + assert "प्रेस विज्ञप्ति" not in value + assert "साक्षीहरूको वकपत्र" in value + + +def test_filler_items_are_rejected(monkeypatch, patched_fetch_markdown): + """`अन्य आवश्यक स्रोतहरू` appears in 15 published cases and names nothing a + reader can go and look for. It must not consume an item slot.""" + api = _StubApi([CASE_READY]) + _run_main( + monkeypatch, api, + _stub_with_documents( + "विवरण।", ["अन्य आवश्यक स्रोतहरू।", "थप आधार र प्रमाण पुष्टि गर्ने प्रमाणिक स्रोत"]), + BASE_ARGV + ["--apply"], + ) + value = {f: v for _, f, v, _ in api.patched}["missing_details"] + assert value == MD_FLOOR_BOTH + + +def test_a_document_restating_the_floor_is_rejected(monkeypatch, patched_fetch_markdown): + """A copy of a floor document, and bare commentary about the appeal, both go -- + leaving the floor exactly as it was.""" + api = _StubApi([CASE_READY]) + _run_main( + monkeypatch, api, + _stub_with_documents("विवरण।", ["अभियोगपत्रको पूर्णपाठ", + "पुनरावेदन भएको वा नभएको ब्यहोरा"]), + BASE_ARGV + ["--apply"], + ) + value = {f: v for _, f, v, _ in api.patched}["missing_details"] + assert value == MD_FLOOR_BOTH + + +def test_a_supreme_reference_drops_the_appeal_item(monkeypatch, patched_fetch_markdown): + """One floor item renders BARE, with no enumerator -- the 1-item corpus shape + (mishra-revenue-leakage-080-cr-0061).""" + api = _StubApi([CASE_WITH_APPEAL]) + _run_main(monkeypatch, api, _stub_with_documents("विवरण।", []), + BASE_ARGV + ["--apply"]) + value = {f: v for _, f, v, _ in api.patched}["missing_details"] + assert value == "अख्तियार दुरुपयोग अनुसन्धान आयोगले दायर गरेको अभियोगपत्र" + assert "पुनरावेदन" not in value + + +def test_a_press_only_case_gets_a_description_but_no_missing_details( + monkeypatch, patched_fetch_markdown +): + """The two fields have DIFFERENT gates. `description` needs press OR verdict; + `missing_details` needs the verdict specifically. Reported, not an error.""" + api = _StubApi([CASE_PRESS_ONLY]) + report = _run_main(monkeypatch, api, _stub_with_documents("विवरण।", ["साक्षीको वकपत्र"]), + BASE_ARGV + ["--apply"]) + assert report.rows[0]["status"] == "enriched" + fields = {f for _, f, _, _ in api.patched} + assert fields == {"description"} + + +def test_an_existing_missing_details_is_not_overwritten( + monkeypatch, patched_fetch_markdown +): + api = _StubApi([CASE_FLAGGED]) + report = _run_main(monkeypatch, api, _stub_with_documents("विवरण।", []), + BASE_ARGV + ["--apply"]) + assert report.rows[0]["status"] == "enriched" + fields = {f for _, f, _, _ in api.patched} + assert fields == {"description"}, "description still writes; missing_details does not" + assert api._cases["case-flagged"]["missing_details"] == TRUNCATION_MARKER + + +def test_force_never_touches_an_existing_missing_details(monkeypatch, + patched_fetch_markdown): + """NEVER TOUCH A NON-EMPTY VALUE, not even with --force. The importer's + truncation guard puts `ACCUSED LIST INCOMPLETE` in this same field, and the 61 + published values are hand-written. The description is still rewritten.""" + api = _StubApi([CASE_FLAGGED]) + _run_main(monkeypatch, api, _stub_with_documents("विवरण।", []), + BASE_ARGV + ["--force", "--apply"]) + written = {f: v for _, f, v, _ in api.patched} + assert "missing_details" not in written + assert written["description"] == "विवरण।" + + +@pytest.mark.parametrize("second_run_item", [ + None, # byte-identical repeat + "मूल सम्झौता (2011)", # a DIFFERENT model item -- the case a substring test missed +]) +def test_a_repeated_forced_run_never_duplicates_the_floor( + monkeypatch, patched_fetch_markdown, second_run_item): + """An earlier fix tested `missing in before_missing`, which only caught the + byte-identical repeat. The floor is deterministic but the model's items are + not, so one changed item made the new value a non-substring and it appended + wholesale -- both floor items twice and a restarted `क)` enumeration, growing + every run. The floor strings cannot serve as a "we wrote this" signature + either: they were copied verbatim FROM hand-written published cases.""" + case = dict(CASE_READY) + case["missing_details"] = f"{TRUNCATION_MARKER}\n\n{MD_FLOOR_BOTH}" + api = _StubApi([case]) + _run_main(monkeypatch, api, + _stub_with_documents("विवरण।", [second_run_item] if second_run_item else []), + BASE_ARGV + ["--force", "--apply"]) + written = {f: v for _, f, v, _ in api.patched} + assert "missing_details" not in written, "wrote over or appended to a live value" + + +def test_a_lost_verdict_source_falls_back_to_the_floor(monkeypatch, tmp_path): + """`has_verdict` is BINDING-based, so a case whose court order failed to fetch + still reports True and `held_summary` still tells the model we hold the verdict. + The model is then diffing against an inventory it could not read, so its items + are dropped and only the deterministic floor -- which is binding-based and + therefore still true -- is written.""" + + def one_source_fails(link): + if "court" in link: + raise RuntimeError("500 from the material store") + return "प्रेस विज्ञप्तिको पाठ" + + monkeypatch.setattr("casework.common.materials.fetch_markdown", one_source_fails) + api = _StubApi([CASE_READY]) + _run_main(monkeypatch, api, + _stub_with_documents("विवरण।", ["साक्षी रामु खनालको वकपत्र"]), + BASE_ARGV + ["--apply"]) + written = {f: v for _, f, v, _ in api.patched} + assert written["missing_details"] == MD_FLOOR_BOTH, "used items from an unread verdict" + events = "\n".join(p.read_text() for p in tmp_path.glob("*.events.jsonl")) + assert "verdict source was not fetched" in events + + +def test_no_accepted_documents_are_logged_on_a_case_that_writes_nothing( + monkeypatch, patched_fetch_markdown, tmp_path): + """`build` writes nothing without a verdict, so "accepted" would be followed by + "skipped: no verdict bound". This log exists to tell a prompt problem from a + sourcing problem, which a contradictory pair defeats.""" + api = _StubApi([CASE_PRESS_ONLY]) + _run_main(monkeypatch, api, _stub_with_documents("विवरण।", ["साक्षीहरूको वकपत्र"]), + BASE_ARGV + ["--dry-run"]) + events = "\n".join(p.read_text() for p in tmp_path.glob("*.events.jsonl")) + assert '"status": "accepted"' not in events + assert '"status": "discarded"' in events + + +def test_the_prompt_tells_the_model_what_we_hold(monkeypatch, patched_fetch_markdown): + """Without the inventory the model guesses at absence instead of computing a + difference -- and the held-document rejection rule has nothing to check.""" + seen = {} + + def stub(**kw): + seen.update(kw) + return json.dumps({"description": "विवरण।", "missing_documents": []}) + + _run_main(monkeypatch, _StubApi([CASE_READY]), stub, BASE_ARGV + ["--dry-run"]) + assert "DOCUMENTS WE ALREADY HOLD" in ed.EXTRACTION_USER_PROMPT + assert "प्रेस विज्ञप्ति" in seen["content"] + assert "विशेष अदालतको फैसला" in seen["content"] + + +def test_missing_details_appears_in_the_review_file(monkeypatch, patched_fetch_markdown, + tmp_path): + """The review file is what a human reads before approving a run, so the + value has to be IN it, not only in the events log.""" + api = _StubApi([CASE_READY]) + _run_main(monkeypatch, api, + _stub_with_documents("### क) सार\nविवरण।", ["साक्षीहरूको वकपत्र"]), + BASE_ARGV + ["--dry-run"]) + text = _review_file(tmp_path).read_text(encoding="utf-8") + assert "missing_details →" in text + assert "साक्षीहरूको वकपत्र" in text + + +def test_the_review_files_char_count_measures_the_description_alone( + monkeypatch, patched_fetch_markdown, tmp_path): + """The file's header names `description`, and its before/after size comparison + is what flags a truncated or runaway one. Folding missing_details into + `generated` over-reported it by the length of a second field.""" + description = "### क) सार\n" + "क" * 400 + api = _StubApi([CASE_READY]) + _run_main(monkeypatch, api, + _stub_with_documents(description, ["साक्षीहरूको वकपत्र"]), + BASE_ARGV + ["--dry-run"]) + text = _review_file(tmp_path).read_text(encoding="utf-8") + assert f"### Generated ({len(description):,} chars)" in text + + +def test_discarded_findings_reach_the_review_file_not_just_the_events_log( + monkeypatch, patched_fetch_markdown, tmp_path): + """Otherwise "found four documents and threw them away" is indistinguishable + from "found nothing" to the person approving the run.""" + api = _StubApi([CASE_PRESS_ONLY]) # no verdict -> nothing is written + _run_main(monkeypatch, api, + _stub_with_documents("विवरण।", ["साक्षी रामु खनालको वकपत्र"]), + BASE_ARGV + ["--dry-run"]) + text = _review_file(tmp_path).read_text(encoding="utf-8") + assert "NOT written" in text and "no verdict bound" in text + assert "साक्षी रामु खनालको वकपत्र" in text + + +def test_the_prompt_and_the_enforced_item_cap_agree(): + """`MAX_LLM_ITEMS` is duplicated as a bare literal in the system prompt, which + cannot be an f-string because of the JSON braces in OUTPUT FORMAT. Raise the + constant and the model keeps returning the old number; lower it and the model + reliably produces one item that `accept_items` then logs as over-cap on every + single case.""" + assert f"up to {md.MAX_LLM_ITEMS}," in ed.EXTRACTION_SYSTEM_PROMPT + + +def test_nothing_left_to_write_when_both_fields_are_satisfied(monkeypatch, + patched_fetch_markdown): + """The per-field gate admits a described case for its EMPTY missing_details -- + but if BOTH floor items are already satisfied (charge sheet bound AND a Supreme + reference on file) and the model finds nothing, there is nothing honest to say + and no reason to rewrite the description either. That leaves an empty patch, + which must not become a request. Raised by CodeRabbit on #441.""" + case = dict( + CASE_WITH_APPEAL, # Supreme ref -> appeal item drops + slug="case-nothing-left", + description="क" * 900, # substantial -> not rewritten + evidence=CASE_WITH_APPEAL["evidence"] + [ + {"material_iri": "https://jawafdehi.org/material/charge_sheet/1", + "additional_details": "", + "material": {"material_type": "charge_sheet", + "urls": [{"link": _PRESS_MD, "role": "MARKDOWN"}]}}, + ], # charge sheet -> that item drops too + ) + api = _StubApi([case]) + report = _run_main(monkeypatch, api, _stub_with_documents("नयाँ विवरण।", []), + BASE_ARGV + ["--apply"]) + assert api.patched == [], "sent a PATCH with nothing in it" + assert api.patch_calls == [] + assert report.rows[-1]["status"] == "already" + assert report.rows[-1]["reason"] == "nothing left to write" diff --git a/tests/casework/test_missing_details.py b/tests/casework/test_missing_details.py new file mode 100644 index 00000000..554ba22b --- /dev/null +++ b/tests/casework/test_missing_details.py @@ -0,0 +1,525 @@ +"""Tests for `casework/common/missing_details.py`. + +The module is deliberately free of LLM and API dependencies, so every rule here +is a pure-function assertion. End-to-end paths through `enrich_description` live +in `tests/casework/test_enrich_description.py`. + +The published corpus is the spec. Where a test pins an exact string it is because +that string appears VERBATIM in a published case -- editing it is a deliberate +divergence from house style, not a typo, and the test says which case it came +from so the claim can be rechecked against production. +""" + +import pytest + +from casework.common.missing_details import ( + APPEAL_ITEM, + CHARGE_SHEET_ITEM, + LETTERS, + MAX_CHARS, + MAX_ITEM_CHARS, + MAX_ITEMS, + MAX_LLM_ITEMS, + NUMERALS, + accept_items, + bound_types, + build, + floor_items, + has_charge_sheet, + has_supreme_reference, + has_verdict, + held_summary, + reject_item, + render, +) + + +def _case(types=("ciaa_press_release", "court_order"), court_cases=None, **extra): + """A case payload shaped like the DETAIL endpoint's.""" + return { + "court_cases": (["https://jawafdehi.org/courtcase/special/078-cr-0038"] + if court_cases is None else court_cases), + "evidence": [{"material_iri": f"https://jawafdehi.org/material/{t}/1", + "material": {"material_type": t}} for t in types], + **extra, + } + + +PRESS_AND_VERDICT = _case() + + +# -------------------------------------------------------------------------- +# Shape detection +# -------------------------------------------------------------------------- + + +def test_bound_types_reads_the_resolved_material_not_the_iri(): + """`material_type` lives on the RESOLVED material dict. A LIST payload leaves + `material` null, and must NOT be mistaken for a case with no evidence-derived + shape -- it falls through to "not templated" instead of guessing.""" + assert bound_types(PRESS_AND_VERDICT) == {"ciaa_press_release", "court_order"} + list_payload = {"evidence": [{"material_iri": "https://x/material/court_order/1", + "material": None}]} + assert bound_types(list_payload) == set() + assert has_verdict(list_payload) is False + + +def test_has_verdict_only_counts_court_order(): + assert has_verdict(PRESS_AND_VERDICT) is True + assert has_verdict(_case(types=("ciaa_press_release",))) is False + assert has_verdict(_case(types=("charge_sheet", "news"))) is False + + +def test_has_charge_sheet(): + assert has_charge_sheet(PRESS_AND_VERDICT) is False + assert has_charge_sheet(_case(types=("charge_sheet", "court_order"))) is True + + +def test_has_supreme_reference_matches_only_the_supreme_court(): + assert has_supreme_reference(PRESS_AND_VERDICT) is False + assert has_supreme_reference(_case(court_cases=[ + "https://jawafdehi.org/courtcase/special/081-cr-0091", + "https://jawafdehi.org/courtcase/supreme/081-cr-2319"])) is True + + +def test_has_supreme_reference_tolerates_a_null_entry(): + assert has_supreme_reference(_case(court_cases=[None, ""])) is False + + +# -------------------------------------------------------------------------- +# The prompt inventory +# -------------------------------------------------------------------------- + + +def test_held_summary_lists_what_we_hold_in_nepali(): + """Nepali labels: the model answers in Nepali, and a mixed-script inventory + invites it to echo the English type name into public prose.""" + assert held_summary(PRESS_AND_VERDICT) == ( + "अख्तियारको प्रेस विज्ञप्ति, विशेष अदालतको फैसला") + + +def test_held_summary_counts_duplicates(): + assert "समाचार x3" in held_summary( + _case(types=("court_order", "news", "news", "news"))) + + +def test_held_summary_says_so_when_nothing_is_bound(): + assert held_summary({"evidence": []}) == "(कुनै पनि छैन)" + + +# -------------------------------------------------------------------------- +# The deterministic floor +# -------------------------------------------------------------------------- + + +def test_floor_is_both_items_for_the_production_batch_shape(): + """Press release + verdict, no charge sheet, no Supreme ref. 24 of the 25 + cases in the first production batch have exactly this shape.""" + assert floor_items(PRESS_AND_VERDICT) == [CHARGE_SHEET_ITEM, APPEAL_ITEM] + + +def test_a_bound_charge_sheet_drops_the_charge_sheet_item(): + assert floor_items(_case(types=("charge_sheet", "court_order"))) == [APPEAL_ITEM] + + +def test_a_supreme_reference_drops_the_appeal_item(): + case = _case(court_cases=["https://jawafdehi.org/courtcase/supreme/081-cr-2319"]) + assert floor_items(case) == [CHARGE_SHEET_ITEM] + + +def test_no_verdict_means_no_floor_at_all(): + assert floor_items(_case(types=("ciaa_press_release",))) == [] + + +def test_both_satisfied_means_an_empty_floor(): + case = _case(types=("charge_sheet", "court_order"), + court_cases=["https://jawafdehi.org/courtcase/supreme/081-cr-2319"]) + assert floor_items(case) == [] + + +# -------------------------------------------------------------------------- +# Enumerators -- one rule per corpus example +# -------------------------------------------------------------------------- + + +def test_one_item_renders_bare(): + """`mishra-revenue-leakage-malpot-parsa-080-cr-0061`, 65 chars, no enumerator.""" + assert render(["एउटै वस्तु"]) == "एउटै वस्तु" + + +def test_two_items_take_devanagari_numerals(): + """`raju-puri-080-cr-0007-illegal-assets` and the four gajendra-maharjan cases.""" + assert render(["क वस्तु", "ख वस्तु"]) == "१. क वस्तु\n२. ख वस्तु" + + +def test_three_items_take_nepali_letters(): + """`case-080-cr-0196-baikuntha-aryal` (3 items) and `bara-hulak-081-CR-0091`.""" + assert render(["एक", "दुई", "तीन"]) == "क) एक\nख) दुई\nग) तीन" + + +def test_render_drops_blanks_and_trims(): + assert render([" एक ", "", None, " "]) == "एक" + + +def test_render_of_nothing_is_empty_string(): + assert render([]) == "" + assert render(None) == "" + + +# -------------------------------------------------------------------------- +# Item acceptance +# -------------------------------------------------------------------------- + + +def test_a_specific_document_is_accepted(): + """Verbatim from `case-081-cr-0097-d8f6d5b2`.""" + assert reject_item("३ चरणका ठेक्का सम्झौताका प्रतिलिपि", PRESS_AND_VERDICT) is None + + +def test_a_held_document_is_rejected(): + """THE CHECKABLE GROUNDING RULE. The model was shown the inventory; a claim + that a bound document is missing is contradicted by our own data.""" + reason = reject_item("प्रेस विज्ञप्ति", PRESS_AND_VERDICT) + assert reason and "bound as evidence" in reason + + +def test_claiming_the_verdict_copy_is_missing_is_rejected_when_bound(): + reason = reject_item("विशेष अदालतको फैसलाको प्रतिलिपि", PRESS_AND_VERDICT) + assert reason and "bound as evidence" in reason + + +@pytest.mark.parametrize("item", [ + # Every one of these was ACCEPTED under the old `len(word)/len(item) >= 0.5` + # ratio. The prompt demands "the document, with its date, party, phase, or + # number", and each qualifier pushed the ratio down -- so the rule stopped + # firing precisely on the specific items it was written for. The more specific + # the wrong claim, the more likely it published. + "मिति २०८१।०५।१२ को अख्तियारको प्रेस विज्ञप्ति", + "विशेष अदालत काठमाडौंको फैसला (०८१-CR-००९१)", + "विशेष अदालतले मिति २०८१।१२।१८ मा गरेको फैसलाको पूर्ण पाठ", + "विशेष अदालतको फैसलाको प्रतिलिपि", +]) +def test_a_held_document_is_rejected_however_specifically_it_is_named(item): + reason = reject_item(item, PRESS_AND_VERDICT) + assert reason and "bound as evidence" in reason, f"{item!r} slipped through" + + +def test_a_document_referenced_by_a_held_one_is_still_a_different_document(): + """Nepali is head-final, so the head noun is last. `…अभियोगपत्रमा उल्लेखित संलग्न + अनुसूची` has `अनुसूची` as its head -- an annex the charge sheet references, not + the charge sheet. Tested with the charge sheet BOUND, because otherwise the + floor already names it and the restatement rule fires first (correctly).""" + bound = _case(types=("press_release", "court_order", "charge_sheet")) + assert reject_item("अभियोगपत्रमा उल्लेखित संलग्न अनुसूची", bound) is None + assert reject_item("प्रेस विज्ञप्तिमा उल्लेखित बैंक विवरण", bound) is None + # ...but the document itself, and any copy of it, still go. + for item in ["अभियोगपत्र", "अख्तियारले दायर गरेको अभियोगपत्रको प्रतिलिपि"]: + assert reject_item(item, bound), f"{item!r} slipped through" + + +@pytest.mark.parametrize("item", ["विशेष अदालतको फैसला", "अदालतको फैसला", "फैसला"]) +def test_the_bare_verdict_phrasings_are_rejected_too(item): + """`held_summary` prints `विशेष अदालतको फैसला` into the prompt, so echoing that + label back is the model's most likely wrong answer. An earlier version listed + only the possessive `फैसलाको …` forms, which let the most probable wrong answer + through as the one answer that passed.""" + reason = reject_item(item, PRESS_AND_VERDICT) + assert reason and "bound as evidence" in reason, f"{item!r} slipped through" + + +@pytest.mark.parametrize("item", [ + "सर्वोच्च अदालतको फैसला", + "सर्वोच्च अदालतको फैसलाको प्रतिलिपि", + "उच्च अदालत पाटनको आदेश", + # The appeal-restatement rule used to pre-empt the exemption and drop this + # one as "restates the appeal item" -- on every case without a Supreme + # reference, i.e. 24 of the first batch's 25, which made the + # `पुनरावेदन अदालत` entry in OTHER_COURT_WORDS dead code. + "पुनरावेदन अदालत पाटनको फैसलाको प्रतिलिपि", + "पुनरावेदन अदालतको आदेश", +]) +def test_another_courts_decision_is_a_different_document(item): + """We hold the SPECIAL court's verdict. `अदालतको फैसला` is a substring of + `सर्वोच्च अदालतको फैसला`, so the held-`court_order` rule would otherwise reject + the one appeal document worth naming.""" + assert reject_item(item, PRESS_AND_VERDICT) is None, f"{item!r} wrongly dropped" + + +@pytest.mark.parametrize("item", [ + "पुनरावेदन गरे नगरेको विवरण", + # `सर्वोच्च` alone appears in APPEAL_ITEM itself, so matching the exemption on + # the court NAME cancelled the rule it was scoped around: this was accepted and + # published directly beneath the floor item saying the same thing. + "सर्वोच्च अदालतमा पुनरावेदन परेको वा नपरेको विवरण", + "सर्वोच्च अदालतमा पुनरावेदन गरेको अवस्था", +]) +def test_a_plain_appeal_comment_is_still_rejected(item): + """The exemption is for a document FROM another court -- it needs a court name + AND a court-document noun. Commentary on whether an appeal happened is not a + document, and the floor item already says it honestly.""" + assert reject_item(item, PRESS_AND_VERDICT) == "restates the appeal item" + + +def test_an_unlabelled_material_type_does_not_leak_english_into_the_prompt(): + """`material_type` is free-form, not a choices field. An unlabelled type used + to render its snake_case English name into a Nepali prompt -- the exact + failure `held_summary` exists to prevent.""" + summary = held_summary(_case(types=("audit_report", "court_order"))) + assert "audit_report" not in summary + assert "अन्य कागजात" in summary + + +def test_the_same_claim_is_accepted_when_that_document_is_not_bound(): + """The rule is a DIFF against our bindings, not a keyword ban. A press-only + case genuinely lacks the verdict copy.""" + press_only = _case(types=("ciaa_press_release",)) + assert reject_item("विशेष अदालतको फैसलाको प्रतिलिपि", press_only) is None + + +@pytest.mark.parametrize("filler", [ + "अन्य आवश्यक स्रोतहरू।", + "थप आधार र प्रमाण पुष्टि गर्ने प्रमाणिक स्रोत", + "अन्य प्रमाण कागजात आदि", +]) +def test_filler_is_rejected(filler): + """These appear in 15 and 17 published cases respectively and name nothing a + reader can go and look for.""" + reason = reject_item(filler, PRESS_AND_VERDICT) + assert reason == "filler, not a specific document" + + +@pytest.mark.parametrize("item", [ + "आदित्य नारायण श्रेष्ठको बैंक खाता विवरण", + "आदिवासी जनजाति उत्थान प्रतिष्ठानको लेखापरीक्षण प्रतिवेदन", +]) +def test_a_proper_noun_starting_with_the_filler_word_is_not_filler(item): + """`आदि` is filler as a WHOLE word and a common opening for Nepali proper + nouns. Matched as a substring it rejected a named defendant's bank records and + a named audit report -- the exact specificity this output exists for.""" + assert reject_item(item, PRESS_AND_VERDICT) is None, f"{item!r} wrongly dropped" + + +@pytest.mark.parametrize("item", [ + "ठेक्का सम्झौता, कार्य सम्पन्न प्रतिवेदन आदि", + "बैंक विवरण, कर विवरण आदि।", +]) +def test_filler_used_as_a_whole_word_is_still_rejected(item): + assert reject_item(item, PRESS_AND_VERDICT) == "filler, not a specific document" + + +def test_restating_a_floor_item_is_rejected(): + """A COPY of a floor document restates it. Commentary about the appeal restates + the appeal item. But the appeal PETITION is a document in its own right -- + naming it is the most useful thing this output can do when an appeal was lodged, + and the appeal rule used to drop it. See COURT_DOC_WORDS.""" + assert reject_item("अभियोगपत्रको पूर्णपाठ", PRESS_AND_VERDICT) == ( + "restates the charge-sheet item") + assert reject_item("पुनरावेदन भएको वा नभएको ब्यहोरा", PRESS_AND_VERDICT) == ( + "restates the appeal item") + assert reject_item("पुनरावेदनपत्रको मूलपाठ", PRESS_AND_VERDICT) is None + + +def test_the_floor_check_is_conditional_not_a_blanket_ban(): + """With a charge sheet BOUND, `CHARGE_SHEET_ITEM` is absent from the floor, so + a claim about the charge sheet's own contents is legitimate.""" + with_charge = _case(types=("charge_sheet", "court_order")) + assert reject_item("अभियोगपत्रमा उल्लेखित संलग्न अनुसूची", with_charge) is None + + +def test_markup_is_rejected(): + """0 of 61 published values carry a tag, and the field renders through an HTML + component, so a tag would actually be interpreted.""" + assert reject_item("सम्झौता", PRESS_AND_VERDICT) == "contains markup" + + +def test_a_sentence_is_rejected_as_too_long(): + long_item = "क" * (MAX_ITEM_CHARS + 1) + reason = reject_item(long_item, PRESS_AND_VERDICT) + assert reason and "not a document name" in reason + + +def test_an_empty_item_is_rejected(): + assert reject_item("", PRESS_AND_VERDICT) == "empty" + assert reject_item(" ", PRESS_AND_VERDICT) == "empty" + assert reject_item(None, PRESS_AND_VERDICT) == "empty" + + +def test_a_duplicate_within_one_round_is_rejected(): + reason = reject_item("साक्षीहरूको वकपत्र", PRESS_AND_VERDICT, + accepted=["साक्षीहरूको वकपत्र"]) + assert reason == "duplicate of an item already listed" + + +# -------------------------------------------------------------------------- +# accept_items +# -------------------------------------------------------------------------- + + +def test_accept_items_returns_keepers_and_reasons(): + proposed = ["३ चरणका ठेक्का सम्झौताका प्रतिलिपि", "प्रेस विज्ञप्ति", + "अन्य आवश्यक स्रोतहरू।", "साक्षीहरूको वकपत्र"] + kept, rejected = accept_items(proposed, PRESS_AND_VERDICT) + assert kept == ["३ चरणका ठेक्का सम्झौताका प्रतिलिपि", "साक्षीहरूको वकपत्र"] + assert [r for _, r in rejected] == [ + "claims a ciaa_press_release is missing, but one is bound as evidence", + "filler, not a specific document", + ] + + +def test_accept_items_enforces_the_cap(): + proposed = [f"सम्झौता क्रमांक {n}" for n in range(MAX_LLM_ITEMS + 3)] + kept, rejected = accept_items(proposed, PRESS_AND_VERDICT) + assert len(kept) == MAX_LLM_ITEMS + assert all("over the" in r for _, r in rejected) + + +def test_accept_items_skips_non_strings(): + kept, rejected = accept_items(["साक्षीहरूको वकपत्र", 42, None], PRESS_AND_VERDICT) + assert kept == ["साक्षीहरूको वकपत्र"] + assert [r for _, r in rejected] == ["not a string", "not a string"] + + +def test_accept_items_of_nothing(): + assert accept_items(None, PRESS_AND_VERDICT) == ([], []) + assert accept_items([], PRESS_AND_VERDICT) == ([], []) + + +# -------------------------------------------------------------------------- +# build +# -------------------------------------------------------------------------- + + +def test_build_floor_only(): + assert build(PRESS_AND_VERDICT) == render([CHARGE_SHEET_ITEM, APPEAL_ITEM]) + + +def test_build_appends_accepted_items_and_reenumerates(): + value = build(PRESS_AND_VERDICT, ["३ चरणका ठेक्का सम्झौताका प्रतिलिपि"]) + assert value.startswith("क) ") + assert "ग) ३ चरणका ठेक्का सम्झौताका प्रतिलिपि" in value + assert "१." not in value + + +def test_build_returns_none_without_a_verdict(): + press_only = _case(types=("ciaa_press_release",)) + assert build(press_only) is None + assert build(press_only, ["साक्षीहरूको वकपत्र"]) is None, ( + "no verdict means no missing_details, even with accepted items") + + +def test_build_returns_none_when_there_is_nothing_to_say(): + """Both floor items satisfied and nothing found. An empty section is the right + rendering -- `MissingDetailsSection` returns null on a falsy value -- and is + strictly better than a placeholder.""" + complete = _case(types=("charge_sheet", "court_order"), + court_cases=["https://jawafdehi.org/courtcase/supreme/081-cr-2319"]) + assert build(complete) is None + + +def test_the_item_count_is_the_binding_limit_not_the_char_cap(): + """`MAX_CHARS` is a sanity guard, not a policy. A full 2-floor + + `MAX_LLM_ITEMS` value at the longest permitted item length must still fit, so + the ITEM COUNT is what limits the output -- a limit on how many findings, not + on how precise they may be. At 299 and again at 450 the cap cut real findings + on real cases, always the last and most specific one.""" + worst = ["स" * MAX_ITEM_CHARS for _ in range(MAX_LLM_ITEMS)] + value = build(PRESS_AND_VERDICT, worst) + assert len(value.splitlines()) == 2 + MAX_LLM_ITEMS, "the guard is still binding" + assert len(value) <= MAX_CHARS + + +def test_the_char_cap_still_drops_whole_items_when_it_does_fire(): + """Trailing items are DROPPED whole, never truncated mid-phrase -- a + half-written document name is worse than an absent one. `build` takes items + on trust, so this passes more than `accept_items` would ever hand it.""" + value = build(PRESS_AND_VERDICT, ["स" * MAX_ITEM_CHARS for _ in range(12)]) + assert len(value) <= MAX_CHARS + assert "…" not in value + for line in value.splitlines(): + assert line.strip() + + +@pytest.mark.parametrize("slug,found", [ + # Both real dry runs, 2026-08-08. At 299 the first lost two items and at 450 + # the second lost one -- in both cases the dropped item was the most specific + # in the set (dispatch numbers, account numbers), because specificity is long. + ("case-078-cr-0111", [ + "निरोज मैनाली (२०८०।०१।२०), पुष्प पिया, रबिन्द्र महर्जन (२०७९।०५।०८) समेतका बकपत्र", + "प्रतिवादी भिमकान्त भण्डारीले अदालतमा गरेको बयानको ब्याहोरा", + "विध म्यानेजमेन्टको आ.व. ०७८।०७९ को लेखापरीक्षण प्रतिवेदन र कर विवरण", + "मालपोत कार्यालय कलंकी (च.नं.३६८१) र ललितपुर (च.नं.११६४३) का जग्गा अभिलेख", + ]), + ("case-079-cr-0047", [ + "सि.डि.ई. सुदिप आचार्य समेतको टोलीको घर मूल्याङ्कन प्रतिवेदन (च.नं. १०७९८, मिति २०७९।०५।२०)", + "आयोगको कृषि विज्ञको प्रतिवेदन (२०७० देखि २०७८ सम्मको धान, गहुँ, मकै गणना)", + "प्रतिवादीहरूले अदालत तथा अनुसन्धान अधिकारीसमक्ष गरेको बयानको ब्यहोरा", + "राष्ट्रिय वाणिज्य बैंकको च.नं. २२२ (२०७८।०५।०२) र च.नं. २०४५ (२०७९।०२।१३) का बैंक विवरण", + ]), +]) +def test_every_real_finding_survives(slug, found): + value = build(PRESS_AND_VERDICT, found) + assert len(value.splitlines()) == 6, f"{slug}: a real finding was dropped to fit" + for item in found: + assert item in value, f"{slug}: lost {item[:40]!r}" + + +def test_build_never_emits_html(): + value = build(PRESS_AND_VERDICT, ["साक्षीहरूको वकपत्र"]) + assert "<" not in value + + +def test_the_floor_items_are_verbatim_from_published_cases(): + """Copied from `case-080-cr-0196-baikuntha-aryal` / `case-081-cr-0048` and + `case-081-cr-0060` / `bara-hulak-081-CR-0091` / `case-081-cr-0046`. Noun + phrases, not sentences, so they sit in one list with the model's items. + """ + assert CHARGE_SHEET_ITEM == "अख्तियार दुरुपयोग अनुसन्धान आयोगले दायर गरेको अभियोगपत्र" + assert APPEAL_ITEM == ( + "हदम्याद भित्र वादी वा प्रतिवादीले सर्वोच्च अदालतमा पुनरावेदन गरे नगरेको ब्याहोरा") + for item in (CHARGE_SHEET_ITEM, APPEAL_ITEM): + assert not item.endswith("।"), "noun phrase, not a sentence" + assert len(item) <= MAX_ITEM_CHARS + + +def test_the_charge_sheet_rule_matches_the_head_not_a_substring(): + """The same reason the held-document rule does. A substring test dropped + `अभियोगपत्रमा उल्लेखित संलग्न अनुसूची` -- the module's own canonical keeper -- on + every case with no charge sheet bound, which is 24 of the first batch's 25.""" + for item in ["अभियोगपत्रमा उल्लेखित संलग्न अनुसूची", + "अभियोगपत्रसाथ पेश भएको बैंक विवरण", + "आरोपपत्रमा उल्लेखित मालपोत अभिलेख"]: + assert reject_item(item, PRESS_AND_VERDICT) is None, f"{item!r} wrongly dropped" + # The charge sheet itself, and any copy of it, still restate the floor item. + for item in ["अभियोगपत्र", "अख्तियारले दायर गरेको अभियोगपत्रको प्रतिलिपि"]: + assert reject_item(item, PRESS_AND_VERDICT) == "restates the charge-sheet item" + + +@pytest.mark.parametrize("pair", [ + ("साक्षीहरूको वकपत्र", "साक्षीहरूको बकपत्र"), + ("प्रतिवादीको विवरण", "प्रतिवादीको बिवरण"), +]) +def test_one_document_under_two_spellings_takes_only_one_slot(pair): + """व/ब is the commonest Nepali orthographic variant and the model emits both -- + this diff's own tests quote each. Unfolded, one document took two of the four + slots and printed twice on the page.""" + kept, rejected = accept_items(list(pair), PRESS_AND_VERDICT) + assert len(kept) == 1 + assert rejected and rejected[0][1] == "duplicate of an item already listed" + + +@pytest.mark.parametrize("item", ["एक\nदुई", "एक\r\nदुई"]) +def test_an_item_containing_a_line_break_is_rejected(item): + """`render` joins items with newlines, so an embedded one leaves a second, + UN-enumerated line -- which closes the frontend's custom-marker list and renders + as a stray paragraph.""" + assert reject_item(item, PRESS_AND_VERDICT) == "contains a line break" + + +def test_the_enumerator_supply_covers_every_reachable_item_count(): + """`MAX_ITEMS` is 2 floor + `MAX_LLM_ITEMS`. It must not exceed the letters, + or `render` would index past them -- and `build` renders in order to measure, + so that crash would pre-empt the trim that would have fixed the list.""" + assert MAX_ITEMS == 2 + MAX_LLM_ITEMS <= len(LETTERS) + assert len(NUMERALS) == 2, "only render's 2-item branch indexes NUMERALS" + value = build(PRESS_AND_VERDICT, ["कागजात %d" % i for i in range(MAX_LLM_ITEMS)]) + assert all(line[0] in LETTERS for line in value.splitlines())