| """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 |
|
|
| |
| |
| |
| 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(), |
| |
| |
| 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": |
| |
| |
| if report_date is not None and report_date >= date(2023, 6, 1): |
| return cls.FIGO_2023 |
| return cls.FIGO_2009 |
|
|
|
|
| |
| |
| |
| |
| |
| _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 |
| |
| |
| source: str = "text" |
| |
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| |
| applicable: bool = True |
| not_applicable_reason: Optional[str] = None |
|
|
| |
| |
| |
| 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; " |
| |
| |
| 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 |
| |
| |
| supersedes_report_id: Optional[str] = None |
| |
| |
| is_authoritative: Optional[bool] = None |
| specimens: list[Specimen] = Field(default_factory=list) |
| |
| |
| 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")], |
| ) |
|
|
|
|
| |
| |
| |
| |
| _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) |
| |
| |
| |
| 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: |
| |
| |
| |
| |
| |
| settled = {FieldStatus.CONFIRMED, FieldStatus.NOT_APPLICABLE} |
| return all( |
| field.status in settled for field in self.checklist.all_fields().values() |
| ) |
|
|