provinans / src /endopath /schema.py
reversely's picture
Upload folder using huggingface_hub
e163a2c verified
Raw
History Blame Contribute Delete
23 kB
"""CAP/ICCR endometrium checklist schema and case/field state model.
Implements docs/prd.md section 6 (the nine-to-fourteen-field CAP/ICCR endometrium
protocol, FIGO staging-edition tagging) and section 8.2 (case and field state
model). This module is the single source of truth for field definitions --
extraction, backend, and frontend code should all import from here rather
than re-deriving the checklist.
"""
from __future__ import annotations
from datetime import date
from enum import Enum
from typing import Any, Generic, Optional, TypeVar
from pydantic import BaseModel, Field, model_validator
# The field list and its value enums live in fields.py, which is stdlib-only so
# scripts/modal_precompute.py can share it (issue #37). Re-exported here because
# `from endopath.schema import Histotype` is used throughout.
from endopath.fields import (
Histotype,
LvsiStatus,
MolecularClassification,
)
__all__ = [
"Case",
"CaseStatus",
"ChecklistField",
"EndometrialChecklist",
"EvidenceSpan",
"FieldStatus",
"FigoStagingEdition",
"Histotype",
"LvsiStatus",
"MolecularClassification",
"Report",
"ReportType",
"Specimen",
"UndesignatedReportError",
]
T = TypeVar("T")
class FieldStatus(str, Enum):
"""docs/prd.md section 8.2: "Confirmed, Needs Review... or Flagged, meaning
actively searched for and not found, distinct from simply absent."
NOT_APPLICABLE is a state in its own right: a gate removed the field's
meaning here (FIGO grade on a non-endometrioid histotype, molecular
classification pre-2023). Distinct from CONFIRMED so a rule-suppressed field
never masquerades as one a reviewer accepted (issue #43).
"""
CONFIRMED = "confirmed"
NEEDS_REVIEW = "needs_review"
FLAGGED = "flagged"
NOT_APPLICABLE = "not_applicable"
class CaseStatus(str, Enum):
"""docs/prd.md section 8.2: "Queued to Processing to Ready for Review to In
Review to Confirmed to Exported, with a separate Flagged state."
"""
QUEUED = "queued"
PROCESSING = "processing"
READY_FOR_REVIEW = "ready_for_review"
IN_REVIEW = "in_review"
CONFIRMED = "confirmed"
EXPORTED = "exported"
FLAGGED = "flagged"
_VALID_CASE_TRANSITIONS: dict[CaseStatus, set[CaseStatus]] = {
CaseStatus.QUEUED: {CaseStatus.PROCESSING, CaseStatus.FLAGGED},
CaseStatus.PROCESSING: {CaseStatus.READY_FOR_REVIEW, CaseStatus.FLAGGED},
CaseStatus.READY_FOR_REVIEW: {CaseStatus.IN_REVIEW, CaseStatus.FLAGGED},
CaseStatus.IN_REVIEW: {CaseStatus.CONFIRMED, CaseStatus.FLAGGED},
CaseStatus.CONFIRMED: {CaseStatus.EXPORTED, CaseStatus.FLAGGED},
CaseStatus.EXPORTED: set(),
# Flagged cases (amended-report / template-clustering misses, per section
# 7.2) return to whichever stage a human resolution sends them back to.
CaseStatus.FLAGGED: {
CaseStatus.QUEUED,
CaseStatus.PROCESSING,
CaseStatus.READY_FOR_REVIEW,
CaseStatus.IN_REVIEW,
},
}
class FigoStagingEdition(str, Enum):
"""docs/prd.md section 6: FIGO staging changed editions in 2023, adding
molecular classification. Every stage/grade field needs this tag at
ingestion or cross-year exports silently mix incompatible values.
"""
FIGO_2009 = "figo_2009"
FIGO_2023 = "figo_2023"
@classmethod
def for_report_date(cls, report_date: Optional[date]) -> "FigoStagingEdition":
# The 2023 FIGO revision was published mid-2023; a report dated
# before that cannot have used it regardless of calendar year.
if report_date is not None and report_date >= date(2023, 6, 1):
return cls.FIGO_2023
return cls.FIGO_2009
# docs/prd.md section 6: FIGO grade "applies only to endometrioid and mucinous carcinoma."
# That is wrong. CAP v5.1 Note D grades endometrioid *only* (docs/taxonomy.md
# section 1), and `Histotype.MUCINOUS` is not in CAP's type list at all. Both the
# rule and the enum member are corrected in #36; this set is reproduced verbatim
# here so #37 stays a pure move with no behavior change.
_HISTOTYPES_WITH_FIGO_GRADE = {Histotype.ENDOMETRIOID, Histotype.MUCINOUS}
class EvidenceSpan(BaseModel):
"""Pointer back to whatever justified a field's value.
docs/prd.md section 8.3: "No field is ever shown as a bare value. It's shown
with what justified it."
"""
quote: str
char_start: Optional[int] = None
char_end: Optional[int] = None
page_number: Optional[int] = None
# Which pass produced this evidence -- ties into the multi-modal
# consensus and escalating not-found-search issues.
source: str = "text"
# Which document and specimen this value came from (issue #110). Optional and
# unpopulated by today's extraction pipeline; a value without them still resolves to a
# case's single report/specimen through Case's compatibility properties. Populating these
# for every extracted value is #111's job, once the observation becomes the stored atom.
report_id: Optional[str] = None
specimen_id: Optional[str] = None
class ChecklistField(BaseModel, Generic[T]):
"""One CAP/ICCR checklist field, evidence-first per section 8.3."""
value: Optional[T] = None
confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)
evidence: Optional[EvidenceSpan] = None
status: FieldStatus = FieldStatus.NEEDS_REVIEW
# A field can be genuinely inapplicable (e.g. FIGO grade for serous
# carcinoma, molecular classification pre-2023) rather than missing.
# Section 7.3: "Silence is never the response to a miss" -- inapplicable
# fields must carry a stated reason, not just an empty value.
applicable: bool = True
not_applicable_reason: Optional[str] = None
# Escalation steps actually tried before a field is marked Flagged.
# Section 6: "not found is an earned result" -- this is the record that
# earns it.
search_log: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def _not_applicable_is_internally_consistent(self) -> "ChecklistField[T]":
if not self.applicable and not self.not_applicable_reason:
raise ValueError(
"A field marked not applicable must carry not_applicable_reason "
"(section 8.3: silence is never the response to a miss)."
)
return self
@classmethod
def not_applicable(cls, reason: str) -> "ChecklistField[T]":
return cls(
value=None,
status=FieldStatus.NOT_APPLICABLE,
applicable=False,
not_applicable_reason=reason,
)
def set_applicability(self, applicable: bool, reason: Optional[str] = None) -> None:
"""Gate this field on or off without destroying its drafted value.
Applicability is a reviewed value's context, not the value itself
(issue #43). Suppressing a field records `not_applicable` as its state
while KEEPING `value`, `confidence`, `evidence`, and `search_log` as the
suppressed draft, so the confirmation screen can show what was drafted
and a reviewer can catch a wrong gate (invariant 2). Un-suppressing
returns that preserved draft and sends the field back to NEEDS_REVIEW:
the gate is itself a drafted value that can change, and the returned
field was never actually reviewed while suppressed.
Idempotent: a field already in the target applicability only has its
reason refreshed (the gate condition can reword it, e.g. a different
suppressing histotype), so calling this twice with the same input
changes nothing.
"""
if applicable:
if not self.applicable:
self.applicable = True
self.not_applicable_reason = None
self.status = FieldStatus.NEEDS_REVIEW
return
if reason is None:
raise ValueError(
"Suppressing a field requires a reason (section 8.3: silence is "
"never the response to a miss)."
)
if self.applicable:
self.applicable = False
self.status = FieldStatus.NOT_APPLICABLE
self.not_applicable_reason = reason
def confirm(self) -> None:
"""The fast path (section 8.3): accept the drafted value as-is."""
self.status = FieldStatus.CONFIRMED
def edit(self, value: Optional[T], evidence: Optional[EvidenceSpan] = None) -> None:
"""The exception path (section 8.3): a reviewer overrides the value."""
self.value = value
if evidence is not None:
self.evidence = evidence
self.status = FieldStatus.CONFIRMED
def flag_not_found(self, search_log: list[str]) -> None:
"""Only call once the full escalating search (LLM, embedding,
combined) has actually been run and come back empty -- section 6.
"""
if len(search_log) < 3:
raise ValueError(
"flag_not_found requires a search_log recording all three "
"escalation passes (LLM, embedding, combined); a shorter "
"log means the search wasn't actually earned (section 7)."
)
self.value = None
self.status = FieldStatus.FLAGGED
self.search_log = search_log
class EndometrialChecklist(BaseModel):
"""The nine-to-fourteen-field CAP/ICCR endometrium protocol (docs/prd.md
section 6), not the original seven-field draft.
"""
histologic_type: ChecklistField[Histotype] = Field(default_factory=ChecklistField)
histologic_grade: ChecklistField[str] = Field(default_factory=ChecklistField)
myometrial_invasion_percent: ChecklistField[float] = Field(default_factory=ChecklistField)
cervical_stromal_invasion: ChecklistField[bool] = Field(default_factory=ChecklistField)
lymphovascular_space_invasion: ChecklistField[LvsiStatus] = Field(default_factory=ChecklistField)
regional_lymph_node_status: ChecklistField[str] = Field(default_factory=ChecklistField)
pathologic_stage: ChecklistField[str] = Field(default_factory=ChecklistField)
molecular_classification: ChecklistField[MolecularClassification] = Field(
default_factory=ChecklistField
)
def apply_applicability_rules(self, staging_edition: FigoStagingEdition) -> None:
"""Recompute which gated fields apply. Call after histologic_type or
staging_edition changes (extracted or reviewer-edited).
Total and reversible (issue #43): every gated field is recomputed on
every call, so a gate that opens (a reviewer corrects a wrong histotype)
returns the previously suppressed field with its drafted value intact,
just as a gate that closes suppresses it. Suppression never deletes the
draft. The method is idempotent and order-independent.
"""
histotype_value = self.histologic_type.value
grade_suppressed = (
histotype_value is not None and histotype_value not in _HISTOTYPES_WITH_FIGO_GRADE
)
self.histologic_grade.set_applicability(
applicable=not grade_suppressed,
reason=(
f"FIGO grade only applies to endometrioid and mucinous carcinoma; "
# A value edited through the API arrives as a raw string until the
# case is next read back and coerced, so read .value defensively.
f"histotype is {getattr(histotype_value, 'value', histotype_value)}."
if grade_suppressed
else None
),
)
molecular_suppressed = staging_edition != FigoStagingEdition.FIGO_2023
self.molecular_classification.set_applicability(
applicable=not molecular_suppressed,
reason=(
"Report predates the 2023 FIGO revision that added molecular "
"classification."
if molecular_suppressed
else None
),
)
def all_fields(self) -> dict[str, ChecklistField]:
return {name: getattr(self, name) for name in self.__class__.model_fields}
class ReportType(str, Enum):
"""What kind of document a report is (issue #110). A typical case carries an outpatient
biopsy report and a hysterectomy report as two independently significant, non-superseding
documents, not one report occasionally amended: neither is "the" report by default.
"""
BIOPSY = "biopsy"
HYSTERECTOMY = "hysterectomy"
AMENDMENT = "amendment"
EXTERNAL_CONSULT = "external_consult"
ANCILLARY = "ancillary"
class UndesignatedReportError(ValueError):
"""A case carries more than one report and none, or more than one, is designated
authoritative. Issue #110: the system never infers this from report type, recency, or list
position -- a reviewer must designate exactly one before the case's checklist resolves.
Carries `suggested_report_id` (issue #127): a report-type-precedence suggestion the reviewer
can confirm, never applied automatically. `None` when no single report is unambiguously
first.
"""
def __init__(self, message: str, *, suggested_report_id: Optional[str] = None) -> None:
super().__init__(message)
self.suggested_report_id = suggested_report_id
class Specimen(BaseModel):
"""One specimen within a report: what a checklist's observations describe (issue #110).
The checklist lives here, not on Report or Case, because CAP's checklist is inherently
specimen-scoped (a hysterectomy specimen's findings).
"""
specimen_id: str
procedure: Optional[str] = None
checklist: EndometrialChecklist = Field(default_factory=EndometrialChecklist)
class Report(BaseModel):
"""One pathology document assembled into a case: a biopsy, hysterectomy, amendment,
external consult, or ancillary/molecular report (issue #110).
"""
report_id: str
report_type: ReportType = ReportType.HYSTERECTOMY
institution: Optional[str] = None
report_date: Optional[date] = None
staging_edition: FigoStagingEdition = FigoStagingEdition.FIGO_2009
# Amendment lineage only: an addendum names the report it supersedes. Two concurrent
# report types (e.g. biopsy and hysterectomy) are never in a supersedes relationship.
supersedes_report_id: Optional[str] = None
# None: undesignated. A reviewer sets True/False explicitly; the system never infers this
# from report type, recency, or position (issue #110).
is_authoritative: Optional[bool] = None
specimens: list[Specimen] = Field(default_factory=list)
# The recognized text this report was read from (issue #39), so a stored EvidenceSpan's
# char_start/char_end resolves to its snippet without re-reading the source corpus file.
report_text: Optional[str] = None
@model_validator(mode="after")
def _supersession_is_amendment_only(self) -> "Report":
if self.supersedes_report_id is not None and self.report_type is not ReportType.AMENDMENT:
raise ValueError(
"supersedes_report_id is only valid on an AMENDMENT report; two concurrent "
"report types (e.g. biopsy and hysterectomy) are never in a supersedes "
"relationship with each other (issue #110)"
)
return self
def _default_report(case_barcode: str) -> Report:
"""The single auto-vivified report/specimen a legacy single-report case resolves to
(issue #110). Deterministic ids so re-persisting the same case doesn't duplicate rows."""
return Report(
report_id=f"{case_barcode}-report-1",
specimens=[Specimen(specimen_id=f"{case_barcode}-specimen-1")],
)
# Highest to lowest (issue #127): "the latest amendment supersedes," then "the hysterectomy
# report supersedes the biopsy for a resection finding" -- today's checklist is entirely
# resection-scoped, so that reduces cleanly to a case-wide tier order. A consult or ancillary
# report is never the primary diagnostic authority, so it ranks lowest.
_AUTHORITY_PRECEDENCE: tuple[ReportType, ...] = (
ReportType.AMENDMENT,
ReportType.HYSTERECTOMY,
ReportType.BIOPSY,
ReportType.EXTERNAL_CONSULT,
ReportType.ANCILLARY,
)
def _suggest_authoritative(reports: list[Report]) -> Optional[Report]:
"""A suggested authoritative report the reviewer can confirm, never applied automatically
(issue #127). Un-superseded reports (nothing else names them via `supersedes_report_id`,
transitively true by construction: a superseding report is itself un-superseded unless a
further amendment names it) are ranked by `_AUTHORITY_PRECEDENCE`; the sole report in the
highest non-empty tier is the suggestion. A tie within a tier, or a case with no reports,
returns no suggestion -- a genuine ambiguity is not the tool's call to make. This is a plain
set-membership pass, not a walk of the supersession chain, so a broken or cyclic
`supersedes_report_id` (bad data, not a state the schema forbids) can't loop it: it can only
ever leave a report out of, or in, the un-superseded set.
"""
superseded_ids = {r.supersedes_report_id for r in reports if r.supersedes_report_id}
current = [r for r in reports if r.report_id not in superseded_ids]
for report_type in _AUTHORITY_PRECEDENCE:
candidates = [r for r in current if r.report_type is report_type]
if len(candidates) == 1:
return candidates[0]
if len(candidates) > 1:
return None
return None
class Case(BaseModel):
case_barcode: str
status: CaseStatus = CaseStatus.QUEUED
reports: list[Report] = Field(default_factory=list)
@model_validator(mode="before")
@classmethod
def _normalize_legacy_constructor(cls, data: Any) -> Any:
"""Back-compat (issue #110): ~20 call sites and ~100 test assertions construct
`Case(case_barcode=..., report_date=..., status=..., checklist=...)`, the pre-grain
shape. When a caller doesn't pass `reports` directly, fold those scalar kwargs into a
single default report/specimen so every existing call site, and `case.checklist` /
`case.report_date` / `case.staging_edition`, keep working unchanged. `mode="before"`
(not a custom `__init__`) so this also covers `Case.model_validate(...)`.
"""
if not isinstance(data, dict) or "reports" in data:
return data
report_date = data.pop("report_date", None)
# This runs before pydantic's own field coercion, unlike the old mode="after"
# validator it replaces, so a raw ISO string (storage.get_case reads report_date
# straight off a TEXT column) needs parsing before for_report_date can compare it.
if isinstance(report_date, str):
report_date = date.fromisoformat(report_date)
staging_edition = data.pop("staging_edition", None)
checklist = data.pop("checklist", None)
case_barcode = data.get("case_barcode")
data["reports"] = [
Report(
report_id=f"{case_barcode}-report-1",
report_date=report_date,
staging_edition=(
staging_edition
if staging_edition is not None
else FigoStagingEdition.for_report_date(report_date)
),
specimens=[
Specimen(
specimen_id=f"{case_barcode}-specimen-1",
checklist=checklist if checklist is not None else EndometrialChecklist(),
)
],
)
]
return data
def _resolved_report(self) -> Report:
"""The report `.checklist` / `.report_date` / `.staging_edition` read and write
through. Never picks by position or type (issue #110): a single report resolves
trivially; more than one requires a reviewer's `is_authoritative` designation."""
if not self.reports:
self.reports = [_default_report(self.case_barcode)]
if len(self.reports) == 1:
return self.reports[0]
authoritative = [r for r in self.reports if r.is_authoritative is True]
if len(authoritative) == 1:
return authoritative[0]
suggestion = _suggest_authoritative(self.reports)
raise UndesignatedReportError(
f"case {self.case_barcode} carries {len(self.reports)} reports and "
f"{len(authoritative)} are designated authoritative; a reviewer must designate "
"exactly one before .checklist / .report_date / .staging_edition can resolve "
"(issue #110: no positional or type default"
+ (
f"; {suggestion.report_id} is suggested by report-type precedence, issue #127)"
if suggestion is not None
else "; no suggestion, the candidates tie)"
),
suggested_report_id=suggestion.report_id if suggestion is not None else None,
)
@property
def checklist(self) -> EndometrialChecklist:
report = self._resolved_report()
if not report.specimens:
report.specimens = [Specimen(specimen_id=f"{self.case_barcode}-specimen-1")]
return report.specimens[0].checklist
@property
def report_date(self) -> Optional[date]:
return self._resolved_report().report_date
@report_date.setter
def report_date(self, value: Optional[date]) -> None:
report = self._resolved_report()
report.report_date = value
report.staging_edition = FigoStagingEdition.for_report_date(value)
@property
def staging_edition(self) -> FigoStagingEdition:
return self._resolved_report().staging_edition
@staging_edition.setter
def staging_edition(self, value: FigoStagingEdition) -> None:
self._resolved_report().staging_edition = value
def transition_to(self, new_status: CaseStatus) -> None:
allowed = _VALID_CASE_TRANSITIONS.get(self.status, set())
if new_status not in allowed:
raise ValueError(
f"Invalid case state transition: {self.status.value} -> {new_status.value}"
)
self.status = new_status
def is_ready_for_export(self) -> bool:
# A field is settled when a reviewer confirmed it, or when a gate coded
# its absence (NOT_APPLICABLE, a state in its own right). Both are named
# explicitly rather than reading `not field.applicable`, so a field
# un-suppressed by a corrected gate (now applicable and NEEDS_REVIEW)
# correctly blocks export again (issue #43).
settled = {FieldStatus.CONFIRMED, FieldStatus.NOT_APPLICABLE}
return all(
field.status in settled for field in self.checklist.all_fields().values()
)