File size: 9,313 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 | """Executors for the Intake follow-up worker (`worker.py`).
Each executor runs one enqueued job to completion and writes a `submission_artifacts` row that the
mapping surface and later projection read back. report_induction (#107) induces each ingested report's
reporting schema. dictionary_compilation (#108) drafts the submitted dictionary's crosswalk.
Both reach their model through an injectable seam (the `SchemaInducer` / `CrosswalkDrafter` Protocols),
so a test substitutes a deterministic one and the fast suite spends no key. Importing this module
registers the executors with the worker.
"""
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from typing import Any, Callable, Optional
from sqlalchemy import Connection
from endopath import crosswalk, dictionary, induction, jobs, mapping, storage, textsource, worker
from endopath.schema import CaseStatus
REPORT_INDUCTION_ARTIFACT = "report_induction"
CROSSWALK_DRAFT_ARTIFACT = "crosswalk_draft"
# The model seams, overridable for tests. None means the hosted default (the pass constructs an
# Anthropic drafter or inducer), so production spends the key and a test never does.
_inducer: Optional[induction.SchemaInducer] = None
_drafter: Optional[crosswalk.CrosswalkDrafter] = None
def configure_inducer(inducer: Optional[induction.SchemaInducer]) -> None:
"""Substitute the schema inducer, for tests. None restores the hosted default."""
global _inducer
_inducer = inducer
def configure_drafter(drafter: Optional[crosswalk.CrosswalkDrafter]) -> None:
"""Substitute the crosswalk drafter, for tests. None restores the hosted default."""
global _drafter
_drafter = drafter
def report_text_for(patient_filename: Optional[str]) -> Optional[str]:
"""The report's recognized text, looked up the way the extraction pipeline looks it up, so an
induced report and its extracted checklist read the same text. Imported lazily to keep this module's
import light (the pipeline pulls the retrieval and extraction stack)."""
from endopath import pipeline
return pipeline.report_text_for_case(patient_filename)
def report_induction(conn: Connection, job: dict) -> None:
"""Induce the reporting schema of each ingested report and persist the contracts, keyed by the
submission's dictionary id (#92, #107). A report whose text cannot be read, or whose induced
contract violates a rule, is recorded in the artifact's `errors` rather than failing the whole
family, so one bad report does not sink the job."""
dictionary_id = job["dictionary_id"]
registry = induction.load_registry()
standards = induction.known_standards()
reports: list[dict] = []
errors: list[dict] = []
for row in storage.list_cases(conn):
request = textsource.TextRequest(
case_barcode=row["case_barcode"],
patient_filename=row["patient_filename"],
corpus_text=report_text_for(row["patient_filename"]),
)
try:
report = induction.induce_report(
request,
source_id=textsource.CORPUS_OCR,
inducer=_inducer,
registry=registry,
standards=standards,
)
reports.append(report.model_dump(mode="json"))
_persist_induced_observations(conn, row["case_barcode"], report)
except induction.InductionError as exc:
errors.append({"case_barcode": row["case_barcode"], "error": str(exc)})
payload = {"source_id": textsource.CORPUS_OCR, "reports": reports, "errors": errors}
storage.upsert_submission_artifact(
conn,
dictionary_id=dictionary_id,
kind=REPORT_INDUCTION_ARTIFACT,
payload_json=json.dumps(payload),
)
def _persist_induced_observations(
conn: Connection, case_barcode: str, report: induction.InducedReport
) -> None:
"""Promote one induced contract to the durable observation atom (#111): one observations row per
InducedField, beside the checklist observations in the same table, built through the shared
`storage.Observation.from_induced_field` shape. The strict-JSON artifact stays the reviewable
contract; the table is the queryable atom. Attached to the case's resolved report; an ambiguous
multi-report case is skipped rather than guessed, so a bad grain never invents a provenance."""
report_id = storage.resolved_report_id(conn, case_barcode)
if report_id is None:
return
now = datetime.now(timezone.utc).isoformat()
storage.delete_induced_observations(conn, case_barcode)
for induced_field in report.fields:
observation = storage.Observation.from_induced_field(
induced_field, case_barcode=case_barcode, report_id=report_id, created_at=now
)
storage.insert_observation(conn, observation)
worker.register_executor(jobs.REPORT_INDUCTION, report_induction)
def dictionary_compilation(conn: Connection, job: dict) -> None:
"""Compile the submitted dictionary against the canonical concept list and draft an edge from every
variable to the concept that answers it, then persist the drafted crosswalk keyed by the dictionary
id for the mapping surface to review (#91, #93, #108).
The built-in CAP checklist already carries a committed, reviewed reference crosswalk, so it is not
re-drafted: its mapping surface serves crosswalk.reference_crosswalk() instead (#109)."""
dictionary_id = job["dictionary_id"]
if dictionary_id == dictionary.BUILTIN_DICTIONARY_ID:
return
data_dictionary = dictionary.get_registered(conn, dictionary_id)
if data_dictionary is None:
raise RuntimeError(
f"dictionary {dictionary_id!r} was not stored, so its crosswalk cannot be drafted"
)
concepts = mapping.concept_list()
concept_ids = frozenset(concept["concept_id"] for concept in concepts)
source_fields = [variable.name for variable in data_dictionary.variables]
# The drafter reads the field summaries; label and permitted values give it context beyond the name.
field_summaries = [
{"name": variable.name, "label": variable.label, "permitted_values": variable.permitted_values}
for variable in data_dictionary.variables
]
drafter = _drafter or crosswalk.AnthropicCrosswalkDrafter()
raw = drafter.draft(source_std=dictionary_id, source_fields=field_summaries, concepts=concepts)
drafted = crosswalk.build_crosswalk(
raw, source_std=dictionary_id, source_fields=source_fields, concept_ids=concept_ids
)
storage.upsert_submission_artifact(
conn,
dictionary_id=dictionary_id,
kind=CROSSWALK_DRAFT_ARTIFACT,
payload_json=crosswalk.crosswalk_json(drafted),
)
worker.register_executor(jobs.DICTIONARY_COMPILATION, dictionary_compilation)
COHORT_EXTRACTION_ARTIFACT = "cohort_extraction"
def _transient_llm_errors() -> tuple:
"""The transient failures a retry should ride out: a rate limit, a dropped connection, or a 5xx from
the hosted API or a local server. Resolved lazily so importing this module stays light."""
import anthropic
import httpx
return (
anthropic.RateLimitError,
anthropic.APIConnectionError,
anthropic.InternalServerError,
httpx.HTTPError,
)
def with_retry(
fn: Callable[[], Any],
*,
attempts: int = 3,
base_delay: float = 0.5,
transient: Optional[tuple] = None,
sleep: Callable[[float], None] = time.sleep,
) -> Any:
"""Run fn, retrying a transient LLM failure with exponential backoff and re-raising the last one when
the attempts run out. A non-transient error (a validation or logic failure) is not retried."""
transient = transient if transient is not None else _transient_llm_errors()
for attempt in range(attempts):
try:
return fn()
except transient:
if attempt == attempts - 1:
raise
sleep(base_delay * (2**attempt))
def cohort_extraction(conn: Connection, job: dict) -> None:
"""Extract every queued case through the shared pipeline (#115), retrying a transient LLM failure per
case. The pipeline advances each case queued -> ready-for-review and persists it, so a reviewer picks
it up on the worklist. A case that still fails after retries, or has no report text, is recorded in
the artifact's errors rather than failing the whole cohort."""
from endopath import pipeline
extracted: list[str] = []
errors: list[dict] = []
for row in storage.list_cases(conn, status=CaseStatus.QUEUED.value):
barcode = row["case_barcode"]
try:
with_retry(lambda: pipeline.process_case_in_store(conn, barcode))
extracted.append(barcode)
except Exception as exc: # noqa: BLE001 - one case's failure is recorded, not fatal to the cohort
errors.append({"case_barcode": barcode, "error": str(exc)})
payload = {"extracted": extracted, "errors": errors}
storage.upsert_submission_artifact(
conn, dictionary_id="(cohort)", kind=COHORT_EXTRACTION_ARTIFACT, payload_json=json.dumps(payload)
)
worker.register_executor(jobs.COHORT_EXTRACTION, cohort_extraction)
|