File size: 19,991 Bytes
eea689d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
"""Induce a report's own reporting schema into a strict-JSON contract (#92, the A1 pass).

The A1 pass reads one report and drafts, per report, the strict-JSON contract:

```json
{
  "report_id": "TCGA-AJ-A3NH",
  "detected_standard": "cap_uterus/5.1.0.0",
  "fields": [
    {
      "name": "histologic_type",
      "value_or_absent": "endometrioid adenocarcinoma",
      "reason_if_absent": null,
      "span": {"page": 2, "bbox": [0.12, 0.34, 0.61, 0.38]},
      "confidence": 0.94
    }
  ]
}
```

This is the A1 half of the LLM-native Stage-1 mapping engine. A2 (`src/endopath/mapping.py`)
compiles the target dictionary into the canonical concept list; A1 here induces each report's own
surface schema, which ranges from synoptic to narrative across institutions and eras, into the same
provenance-carrying shape. #93 draws edges from the fields A1 emits to the concepts A2 compiles.

The pass is LLM-native: a language model reads the report together with the canonical registry
(`docs/taxonomy.md`) and drafts the contract. The report's text arrives through the pluggable text
source seam (`src/endopath/textsource.py`), never a hardcoded reader, so the vision-LLM MVP and a
local on-premise model plug in at the same seam. The model call itself goes through a `SchemaInducer`,
a provider-agnostic seam, so the fast test suite runs against an injected fake with no API key.

Two hard rules the contract enforces structurally, not by prompt alone:

1. No field without its source span. A field that carries a value carries a `span` (a page plus a
   normalized bounding box). A value the pass cannot localize is not emitted as a bare value: the
   assembly raises rather than persisting a value with no evidence (CLAUDE.md invariant 2).
2. Absence is a coded reason. An absent field sets `value_or_absent` to null and `reason_if_absent`
   to a code from the closed `docs/prd.md` section 4.3 vocabulary, never a blank.

The two invariants this pass serves:

1. Numbers stay numbers. The induced `value_or_absent` is the surface value the report itself stated
   (for example "4 foci" or "5 mm in a 21 mm wall"), the most primitive quantity, so a later
   projection can still ask either convention's question. A1 records the surface form; it does not
   categorize it.
2. No value without its evidence. Every emitted value carries its span, and `AbsenceReason` codes the
   reason for every field that carries none.
"""

from __future__ import annotations

import base64
import csv
import json
from enum import Enum
from pathlib import Path
from typing import Optional, Protocol, runtime_checkable

import anthropic
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator

from endopath import textsource
from endopath.llm_extraction import get_client
from endopath.textsource import TextRequest

REPO_ROOT = Path(__file__).resolve().parents[2]
REGISTRY_PATH = REPO_ROOT / "docs" / "taxonomy.md"
SOURCES_PATH = REPO_ROOT / "data" / "taxonomy" / "sources.csv"

# The induction model is named explicitly here rather than inherited from the extraction pass,
# which pins its own model (`src/endopath/llm_extraction.py`). Schema induction reads the whole
# registry and reasons over an unfamiliar report structure, so it runs on the most capable model by
# default; a caller overrides it per call. Both the model id and the reader stay swappable.
INDUCTION_MODEL = "claude-sonnet-5"

# The two structural forms `detected_standard` may take beyond a registry `source_id`: a report with
# no synoptic template (`narrative`), or one that mixes a template with narrative prose (`mixed`).
STRUCTURAL_STANDARDS: frozenset[str] = frozenset({"narrative", "mixed"})


class InductionError(ValueError):
    """The induction did not yield a valid contract: a value with no span, an absent field with no
    coded reason, a confidence outside [0, 1], a malformed span, or a detected_standard that names
    neither a registry standard nor a structural form."""


class AbsenceReason(str, Enum):
    """The closed vocabulary a field with no plain value takes (docs/prd.md section 4.3, from the
    HL7 `dataAbsentReason` code system and its two neighbours). Silence is never the response to a
    miss: an absent field always carries one of these."""

    NOT_APPLICABLE = "not_applicable"  # a gate removes the field's meaning here
    ASKED_UNKNOWN = "asked-unknown"  # the field applies, the report was searched, and it is silent
    INDETERMINATE = "indeterminate"  # the report states the feature cannot be assessed
    DETERMINED_BY_JOIN = "determined_by_join"  # the value arrives from another source, not the report


class Span(BaseModel):
    """Where a value came from: a 1-indexed page and a normalized bounding box `[x0, y0, x1, y1]`
    in `[0, 1]`, so a reviewer and a second centre look at the same region of the same page
    (docs/prd.md section 4.6)."""

    model_config = ConfigDict(frozen=True)

    page: int
    bbox: tuple[float, float, float, float]
    # Which specimen within the containing InducedReport this value describes (issue #110).
    # The report itself is already named by the enclosing InducedReport.report_id, so only the
    # specimen dimension is new here. Optional and unpopulated by today's induction pass.
    specimen_id: Optional[str] = None

    @model_validator(mode="after")
    def _page_and_bbox_are_well_formed(self) -> "Span":
        if self.page < 1:
            raise ValueError(f"span page must be 1-indexed, got {self.page}")
        x0, y0, x1, y1 = self.bbox
        if not all(0.0 <= c <= 1.0 for c in self.bbox):
            raise ValueError(f"span bbox must be normalized to [0, 1], got {self.bbox}")
        if x1 < x0 or y1 < y0:
            raise ValueError(f"span bbox must have x0<=x1 and y0<=y1, got {self.bbox}")
        return self


class InducedField(BaseModel):
    """One induced field: the report's own field name, its surface value or a coded absence, the
    span the value came from, and a confidence. The contract row #92 defines."""

    model_config = ConfigDict(frozen=True)

    name: str
    value_or_absent: Optional[str] = None
    reason_if_absent: Optional[AbsenceReason] = None
    span: Optional[Span] = None
    confidence: float

    @model_validator(mode="after")
    def _value_absence_and_span_are_consistent(self) -> "InducedField":
        if not 0.0 <= self.confidence <= 1.0:
            raise ValueError(f"{self.name}: confidence must be in [0, 1], got {self.confidence}")
        present = self.value_or_absent is not None
        if present:
            # No value without its span, and no value that is also coded absent.
            if self.reason_if_absent is not None:
                raise ValueError(
                    f"{self.name}: a field with a value must not also carry a reason_if_absent"
                )
            if self.span is None:
                raise ValueError(
                    f"{self.name}: a value carries no span and is not emitted as a bare value "
                    "(CLAUDE.md invariant 2)"
                )
        else:
            # Absence is a coded reason, never a blank.
            if self.reason_if_absent is None:
                raise ValueError(
                    f"{self.name}: an absent field must carry a coded reason_if_absent, not a blank"
                )
        return self


class InducedReport(BaseModel):
    """The A1 contract for one report: its id, the detected standard, and the induced fields."""

    model_config = ConfigDict(frozen=True)

    report_id: str
    detected_standard: str
    fields: tuple[InducedField, ...]


@runtime_checkable
class SchemaInducer(Protocol):
    """Turns a report plus the canonical registry into the raw contract dict. The seam a local,
    on-premise model or a test fake plugs into: it never surfaces its provider to the call site."""

    def induce(self, *, request: TextRequest, report_text: str, registry: str) -> dict: ...


# --- The schema-constrained tool the hosted model is forced to call --------------------------------

_SPAN_SCHEMA = {
    "type": "object",
    "properties": {
        "page": {"type": "integer", "minimum": 1, "description": "1-indexed page the value appears on."},
        "bbox": {
            "type": "array",
            "items": {"type": "number", "minimum": 0, "maximum": 1},
            "minItems": 4,
            "maxItems": 4,
            "description": "Normalized [x0, y0, x1, y1] region of the page, each coordinate in [0, 1].",
        },
    },
    "required": ["page", "bbox"],
}

INDUCTION_TOOL = {
    "name": "induce_report_schema",
    "description": (
        "Record the induced reporting schema of one pathology report: the detected reporting "
        "standard and, per field, either the surface value with the page region it came from, or a "
        "coded reason it is absent."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "detected_standard": {
                "type": "string",
                "description": (
                    "The reporting standard this report follows, inferred from its surface forms. "
                    "Use the registry source_id when the report follows a named synoptic template "
                    "(for example 'cap_uterus/5.1.0.0'), 'narrative' when it has no template, or "
                    "'mixed' when it interleaves a template with narrative prose."
                ),
            },
            "fields": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "name": {"type": "string", "description": "The report's own field name."},
                        "value_or_absent": {
                            "anyOf": [{"type": "string"}, {"type": "null"}],
                            "description": (
                                "The surface value exactly as the report states it (the most "
                                "primitive quantity, for example '4 foci' or '5 mm'), or null when "
                                "the field carries no plain value."
                            ),
                        },
                        "reason_if_absent": {
                            "anyOf": [
                                {"type": "string", "enum": [r.value for r in AbsenceReason]},
                                {"type": "null"},
                            ],
                            "description": (
                                "Null when value_or_absent is set. Otherwise a coded reason: "
                                "not_applicable (a gate removes its meaning), asked-unknown "
                                "(applies but the report is silent), indeterminate (the report says "
                                "it cannot be assessed), or determined_by_join (it comes from "
                                "another source)."
                            ),
                        },
                        "span": {
                            "anyOf": [_SPAN_SCHEMA, {"type": "null"}],
                            "description": (
                                "Required whenever value_or_absent is set: the page region the value "
                                "came from. A value that cannot be localized is not emitted."
                            ),
                        },
                        "confidence": {"type": "number", "minimum": 0, "maximum": 1},
                    },
                    "required": ["name", "value_or_absent", "reason_if_absent", "span", "confidence"],
                },
            },
        },
        "required": ["detected_standard", "fields"],
    },
}

_INSTRUCTIONS = """\
You induce the reporting schema of one endometrial carcinoma pathology report and call \
induce_report_schema with the result. You do not extract against a fixed checklist; you read the \
report's own structure, which ranges from a synoptic template to free narrative, and record it.

Use the canonical registry below to recognize what each report finding means and which value sets \
it draws from. Report the surface value verbatim, the most primitive quantity the report states, so \
a later step can project it into any convention. Do not categorize or normalize it.

Two rules are absolute:
- No value without its span. Emit a value only with the page and normalized region it came from. If \
you cannot localize a finding to a region, do not emit it as a value.
- Absence is a coded reason. For a field that applies but carries no plain value, set value_or_absent \
to null and reason_if_absent to one of the coded reasons, never a blank.
"""


class AnthropicSchemaInducer:
    """The hosted vision-LLM inducer: it reads the report text (and the rendered pages, when the
    request carries them, so it can localize spans) together with the registry, and returns the raw
    contract through a forced tool call. Names no provider at the call site."""

    def __init__(
        self,
        client: Optional[anthropic.Anthropic] = None,
        model: str = INDUCTION_MODEL,
        max_tokens: int = 8192,
    ) -> None:
        self._client = client
        self._model = model
        self._max_tokens = max_tokens

    def induce(self, *, request: TextRequest, report_text: str, registry: str) -> dict:
        client = self._client or get_client()
        system = [
            {"type": "text", "text": _INSTRUCTIONS},
            {
                "type": "text",
                "text": "CANONICAL REGISTRY (docs/taxonomy.md):\n\n" + registry,
                # The registry is identical across every report, so cache it once.
                "cache_control": {"type": "ephemeral"},
            },
        ]
        content: list[dict] = [
            {"type": "text", "text": f"REPORT report_id={request.case_barcode}\n\n{report_text}"}
        ]
        content.extend(_image_blocks(request.page_images))
        content.append({"type": "text", "text": "Call induce_report_schema now."})

        response = client.messages.create(
            model=self._model,
            max_tokens=self._max_tokens,
            system=system,
            tools=[INDUCTION_TOOL],
            tool_choice={"type": "tool", "name": INDUCTION_TOOL["name"]},
            messages=[{"role": "user", "content": content}],
        )
        # Fail loudly on truncation rather than returning a partial tool call that would be
        # persisted as a truncated contract.
        if response.stop_reason == "max_tokens":
            raise InductionError(
                "induction hit max_tokens before completing the tool call; raise max_tokens"
            )
        tool_call = next((b for b in response.content if b.type == "tool_use"), None)
        if tool_call is None:
            raise InductionError("induction returned no tool call")
        return tool_call.input


_IMAGE_MEDIA_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg"}


def _image_blocks(page_images: tuple[Path, ...]) -> list[dict]:
    """Base64 image blocks for the rendered pages, so the vision model localizes spans against the
    same pixels a reviewer will see. Skips a page whose bytes cannot be read rather than failing the
    whole induction."""
    blocks: list[dict] = []
    for path in page_images:
        media_type = _IMAGE_MEDIA_TYPES.get(Path(path).suffix.lower())
        if media_type is None:
            continue
        try:
            data = Path(path).read_bytes()
        except OSError:
            continue
        blocks.append(
            {
                "type": "image",
                "source": {
                    "type": "base64",
                    "media_type": media_type,
                    "data": base64.standard_b64encode(data).decode("ascii"),
                },
            }
        )
    return blocks


def load_registry() -> str:
    """The canonical registry the model reads, from docs/taxonomy.md."""
    return REGISTRY_PATH.read_text(encoding="utf-8")


def known_standards() -> frozenset[str]:
    """The registry source_ids a detected_standard may name, from data/taxonomy/sources.csv."""
    with SOURCES_PATH.open(newline="", encoding="utf-8") as handle:
        return frozenset(row["source_id"] for row in csv.DictReader(handle))


def _build_field(item: object) -> InducedField:
    if not isinstance(item, dict):
        raise InductionError(f"each induced field must be a JSON object, got {type(item).__name__}")
    payload = {key: item.get(key) for key in ("name", "value_or_absent", "reason_if_absent", "span", "confidence")}
    try:
        return InducedField(**payload)
    except ValidationError as error:
        raise InductionError(f"field {item.get('name')!r}: {error}") from error


def build_induced_report(
    raw: object,
    *,
    report_id: str,
    standards: Optional[frozenset[str]] = None,
) -> InducedReport:
    """Validate the raw contract dict into an `InducedReport`, enforcing the two hard rules.

    Raises `InductionError` when the output is not a JSON object, names no detected_standard or one
    outside the registry, emits a value with no span, or emits an absent field with no coded reason.
    `report_id` is the case identity, set from the request rather than trusted from the model.
    """
    if not isinstance(raw, dict):
        raise InductionError(f"induction output must be a JSON object, got {type(raw).__name__}")
    detected = raw.get("detected_standard")
    if not isinstance(detected, str) or not detected.strip():
        raise InductionError("induction output must name a detected_standard")
    allowed = standards if standards is not None else known_standards()
    if detected not in allowed and detected not in STRUCTURAL_STANDARDS:
        raise InductionError(
            f"detected_standard {detected!r} names neither a registry standard nor one of "
            f"{sorted(STRUCTURAL_STANDARDS)}"
        )
    raw_fields = raw.get("fields")
    if not isinstance(raw_fields, list):
        raise InductionError("induction output must carry a 'fields' list")
    fields = tuple(_build_field(item) for item in raw_fields)
    return InducedReport(report_id=report_id, detected_standard=detected, fields=fields)


def induce_report(
    request: TextRequest,
    *,
    source_id: str = textsource.DEFAULT_SOURCE_ID,
    page_reader: Optional[textsource.PageReader] = None,
    inducer: Optional[SchemaInducer] = None,
    registry: Optional[str] = None,
    standards: Optional[frozenset[str]] = None,
) -> InducedReport:
    """The A1 pass for one report: obtain its text through the pluggable source, induce the schema,
    and validate it into the contract.

    The report's content arrives through `textsource.select(source_id)` rather than a hardcoded
    reader, so the vision MVP and a local model plug in at that one seam. `inducer` is the model
    seam: the default calls the hosted vision model, a fake substitutes for it in a test. Raises
    `InductionError` when the source yields no text (a schema cannot be induced from nothing) or the
    induced contract violates a rule.
    """
    source = textsource.select(source_id, page_reader=page_reader)
    report_text = source.text_for(request)
    if not report_text:
        raise InductionError(
            f"text source {source_id!r} returned no text for {request.case_barcode!r}; "
            "a schema cannot be induced without report content"
        )
    registry = registry if registry is not None else load_registry()
    inducer = inducer or AnthropicSchemaInducer()
    raw = inducer.induce(request=request, report_text=report_text, registry=registry)
    return build_induced_report(raw, report_id=request.case_barcode, standards=standards)


def contract_json(report: InducedReport) -> str:
    """The induced contract as strict JSON, the #92 artifact."""
    return json.dumps(report.model_dump(mode="json"), indent=2) + "\n"