File size: 31,180 Bytes
eea689d 2856a6c eea689d 53f6747 eea689d 53f6747 eea689d 53f6747 eea689d 2856a6c eea689d 53f6747 eea689d 2856a6c eea689d 2856a6c eea689d 2856a6c 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 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 | """Project the confirmed case records onto the canonical concept list and render the cohort export
bundle (#101, docs/prd.md sections 3, 8.1).
The column spec is the canonical concept list (`mapping.concept_list()`, #91), not the built-in CAP
checklist: the export speaks concepts, so a second laboratory targeting the same concepts re-derives the
same table. `mapping.cap_checklist_coverage()` (#95) is the bridge that says which confirmed CAP field
fills which concept column. A concept no CAP field covers, and no derivation reaches, is absent, and an
absent value is never a blank: it carries a coded reason in a parallel `<concept>__absent_reason` column,
from the field-state vocabulary docs/prd.md section 4.3 defines (`not_applicable`, `not_stated`,
`indeterminate`, `determined_by_join`, `constrained`).
The two invariants this projection serves:
1. Numbers stay numbers. The stored quantity survives into its own column, and a derived category sits
beside it in a separate column, recomputed from that quantity by the registry's named rule
(`taxonomy.recompute`, #35), never stored in its place. A confirmed `myometrial_invasion_percent` of
60.0 and the `half_or_more` category it derives to are two columns, so the export can still answer the
other convention's question.
2. No value without its evidence. The field-level provenance carries each value's verbatim evidence quote,
its page, and the pass that read it, and the bundle carries a checksum over every cited span per case,
so a second centre auditing the row reads the same source material.
The bundle carries the schema the second centre re-derives against (the concept list and its checksum),
the version of the rule set and the extraction model that touched the values, the staging edition each
stage speaks (docs/prd.md section 4.2), and a per-case source-document reference with an evidence
checksum. The rendered CSV, XLSX, and JSON come from one projection, so the three files and the on-screen
preview never disagree.
## Relates to
- #40 owns the target-schema selector and the `derive_stage` set projection: choosing an arbitrary
confirmed dictionary as the target, and rendering a `constrained` stage as the candidate set with the
variable whose absence blocks narrowing. This module names the staging edition it emitted and reserves
the `constrained` code for that projection; it does not itself enumerate the candidate set.
- #39 owns the deeper export provenance: the byte-level checksum of each scanned report and the positional
region of every evidence span. This module carries the source-document reference and a checksum over the
cited spans, the reproducible provenance available from the confirmed record alone.
- #35 owns the per-value coding-system resolution beyond the FIGO edition; this module cites each concept
to the registry via its `cited_to` in the column spec.
"""
from __future__ import annotations
import base64
import csv
import hashlib
import io
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy import Connection, text
from endopath import llm_extraction, longitudinal, mapping, projection, taxonomy
from endopath.mapping import AssertionType, CompiledConcept
from endopath.schema import ChecklistField, FieldStatus
from endopath.validation import FieldState
# The coded-absence reason applied to a concept the confirmed record does not carry. A molecular finding
# absent from a report of this era arrives from the clinical record rather than the report, so it is
# `determined_by_join` (docs/prd.md sections 4.3, 8.3); every other uncovered concept, and a derived
# concept whose input primitives are absent, is `not_stated`, the searched-and-absent code.
_JOIN_SOURCED_CATEGORY = "molecular"
ABSENT_REASON_SUFFIX = "__absent_reason"
@dataclass(frozen=True)
class _Projected:
"""One concept's value on one case: a concrete value with no reason, or a coded absence with no
value. `origin` records how the value was reached, for the column-spec descriptor."""
value: Optional[Any]
absent_reason: Optional[str]
origin: str # 'confirmed' | 'derived' | 'absent'
def _primitive(value: object) -> object:
"""An enum to its value, everything else as-is, so the export carries JSON primitives."""
return value.value if hasattr(value, "value") else value
def _concept_labels() -> dict[str, str]:
"""Concept id to human label, read from the dependency-graph nodes the concept ids key on."""
data = json.loads(mapping.DEPENDENCY_GRAPH_PATH.read_text(encoding="utf-8"))
return {node["id"]: node["label"] for node in data["nodes"]}
def _field_value_and_state(field: ChecklistField) -> tuple[Optional[Any], FieldState]:
"""Reduce one confirmed CAP field to a value and a field state. A gated field is `not_applicable`; a
flagged field was searched for and confirmed absent (`not_stated`); an applicable field with no value
is `not_stated`; a field carrying a value is present. Silence is never the response to a miss."""
if not field.applicable:
return None, FieldState.NOT_APPLICABLE
if field.status is FieldStatus.FLAGGED:
return None, FieldState.NOT_STATED
value = _primitive(field.value)
if value is None:
return None, FieldState.NOT_STATED
return value, FieldState.PRESENT
def _project_case(
checklist,
*,
concepts: list[CompiledConcept],
coverage: dict[str, str],
) -> dict[str, _Projected]:
"""Project one confirmed checklist onto the concept list: seed the covered concepts from their CAP
fields, recompute a derived concept whose inputs are all present, and code every remaining concept as
a coded absence (invariant: no value without a reason)."""
concept_to_field = {concept_id: field_name for field_name, concept_id in coverage.items()}
projected: dict[str, _Projected] = {}
# Pass 1: every covered concept, from its confirmed CAP field, so a derivation's inputs are available
# before any derived concept is filled.
for concept in concepts:
field_name = concept_to_field.get(concept.concept_id)
if field_name is None:
continue
value, state = _field_value_and_state(getattr(checklist, field_name))
if state is FieldState.PRESENT:
projected[concept.concept_id] = _Projected(value, None, "confirmed")
else:
projected[concept.concept_id] = _Projected(None, state.value, "absent")
# Pass 2: the concepts no CAP field covers.
for concept in concepts:
cid = concept.concept_id
if cid in projected:
continue
if concept.assertion_type is AssertionType.DERIVED and concept.derived is not None:
projected[cid] = _derive(concept, projected)
else:
reason = (
FieldState.DETERMINED_BY_JOIN
if concept.category == _JOIN_SOURCED_CATEGORY
else FieldState.NOT_STATED
)
projected[cid] = _Projected(None, reason.value, "absent")
return projected
def _derive(concept: CompiledConcept, projected: dict[str, _Projected]) -> _Projected:
"""Recompute a derived concept from its inputs by its registry-owned rule (`taxonomy.recompute`,
#35), the same discipline the B1 validation pass runs: recompute only when every graph input is
present, so a derived category is computed from the primitive rather than trusted as stored. A
derivation whose input a gate removed inherits `not_applicable`; one whose input is merely absent, or
that the per-case pass does not recompute, is `not_stated`."""
assert concept.derived is not None
inputs = [projected.get(input_id) for input_id in concept.derived.inputs]
if any(item is not None and item.absent_reason == FieldState.NOT_APPLICABLE.value for item in inputs):
return _Projected(None, FieldState.NOT_APPLICABLE.value, "absent")
if all(item is not None and item.value is not None for item in inputs):
recomputed = taxonomy.recompute(concept.concept_id, [item.value for item in inputs]) # type: ignore[union-attr]
if recomputed is not None:
return _Projected(recomputed, None, "derived")
return _Projected(None, FieldState.NOT_STATED.value, "absent")
def _evidence_checksum(checklist) -> str:
"""A SHA-256 over the case's cited evidence: the field, verbatim quote, page, and pass of every value
that carries a span, in a canonical order. A second centre confirming the same values against the same
reports reads the same spans and computes the same checksum (invariant 2)."""
spans = []
for field_name, field in sorted(checklist.all_fields().items()):
if field.evidence is not None:
spans.append(
{
"field": field_name,
"quote": field.evidence.quote,
"page_number": field.evidence.page_number,
"source": field.evidence.source,
}
)
canonical = json.dumps(spans, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _columns(concepts: list[CompiledConcept], labels: dict[str, str]) -> list[dict]:
"""The column spec: the case-level identifiers, then, per concept in concept-list order, a value
column and its parallel absent-reason column. The descriptor carries the concept's category,
assertion type, cardinality, and registry citation so the bundle and the screen render the spec
without re-deriving it."""
columns: list[dict] = [
{"key": "case_barcode", "label": "Case barcode", "kind": "identifier"},
{"key": "report_date", "label": "Report date", "kind": "identifier"},
{"key": "staging_edition", "label": "Staging edition", "kind": "identifier"},
{"key": "status", "label": "Case status", "kind": "identifier"},
]
for concept in concepts:
cid = concept.concept_id
label = labels.get(cid, cid)
columns.append(
{
"key": cid,
"label": label,
"kind": "value",
"concept_id": cid,
"category": concept.category,
"assertion_type": concept.assertion_type.value,
"cardinality": concept.cardinality.value,
"cited_to": concept.cited_to,
"derived": concept.derived.model_dump(mode="json") if concept.derived else None,
"absent_reason_key": f"{cid}{ABSENT_REASON_SUFFIX}",
}
)
columns.append(
{
"key": f"{cid}{ABSENT_REASON_SUFFIX}",
"label": f"{label} absent reason",
"kind": "absent_reason",
"of": cid,
}
)
columns.extend(_outcome_columns())
return columns
def _outcome_columns() -> list[dict]:
"""The join-sourced outcome columns, appended after the concept columns: the disease outcome joined from
the GDC clinical record rather than read from the pathology report (`longitudinal.py`). Each value column
is marked `sourced_via: "join"` and paired with its parallel absent-reason column, so the same renderer
and the same coded-absence discipline apply as for a concept column. The block keeps the report-concept
schema and its checksum meaning "what the report answers"."""
columns: list[dict] = []
for spec in longitudinal.OUTCOME_COLUMNS:
key = spec["key"]
columns.append(
{
"key": key,
"label": spec["label"],
"kind": "value",
"category": longitudinal.OUTCOME_CATEGORY,
"assertion_type": spec["assertion_type"],
"sourced_via": "join",
"join_source": longitudinal.JOIN_SOURCE,
"derived": spec.get("derived"),
"note": spec.get("note"),
"absent_reason_key": f"{key}{ABSENT_REASON_SUFFIX}",
}
)
columns.append(
{
"key": f"{key}{ABSENT_REASON_SUFFIX}",
"label": f"{spec['label']} absent reason",
"kind": "absent_reason",
"of": key,
}
)
return columns
def _load_longitudinal(conn: Connection) -> dict[str, list[longitudinal.Record]]:
"""Every longitudinal record grouped by participant, in one read (issue #131's batched discipline). The
outcome projection joins by participant barcode, and a per-case query would reintroduce the N+1 the
cohort export was restructured to avoid. The table holds a few thousand small rows, so one unfiltered
read is cheaper than a query per case."""
by_participant: dict[str, list[longitudinal.Record]] = {}
rows = conn.execute(
text(
"SELECT participant_id, record_id, record_type, structured_data FROM longitudinal_records"
)
).mappings().fetchall()
for row in rows:
data: dict = {}
if row["structured_data"]:
try:
data = json.loads(row["structured_data"])
except (json.JSONDecodeError, TypeError):
data = {}
by_participant.setdefault(row["participant_id"], []).append(
longitudinal.Record(
record_type=row["record_type"], record_id=row["record_id"], data=data
)
)
return by_participant
def _load_longitudinal_joins(conn: Connection) -> dict[str, list[dict]]:
"""Every reviewer join decision grouped by case barcode, in one read (issue #131's batched discipline).
#180 writes these from the Case Review panel: a per-record disposition (`confirmed` / `rejected`) with
the confirmer's identity and role. The export and the cohort endpoint read them to drop rejected records
and to attribute the confirmed linkages. A record with no row here has not been reviewed, not rejected."""
by_case: dict[str, list[dict]] = {}
rows = conn.execute(
text(
"SELECT case_barcode, record_id, disposition, confirmed_by, role, confirmed_at "
"FROM longitudinal_joins"
)
).mappings().fetchall()
for row in rows:
by_case.setdefault(row["case_barcode"], []).append(
{
"record_id": row["record_id"],
"disposition": row["disposition"],
"confirmed_by": row["confirmed_by"],
"role": row["role"],
"confirmed_at": row["confirmed_at"],
}
)
return by_case
def _rejected_record_ids(case_joins: list[dict]) -> set[str]:
"""The record ids a reviewer explicitly rejected for this case. The barcode join is deterministic, so a
record with no join row is unreviewed, not rejected: only an explicit `rejected` disposition excludes a
record from the projection, leaving the default (no decision) projecting exactly as before."""
return {j["record_id"] for j in case_joins if j.get("disposition") == "rejected"}
def _bundle(
concepts: list[CompiledConcept],
sources: list[dict],
*,
staging_edition: Optional[str],
per_case_editions: list[str],
) -> dict:
"""The provenance bundle (docs/prd.md section 3): the schema the second centre re-derives against and
its checksum, the rule-set and extraction-model versions that touched the values, the staging edition
each stage speaks, the coding systems, and the per-case source references."""
schema_checksum = hashlib.sha256(mapping.concept_list_json().encode("utf-8")).hexdigest()
rule_set_checksum = hashlib.sha256(
mapping.DEPENDENCY_GRAPH_PATH.read_bytes()
).hexdigest()
named_rules = sorted(
{
concept.derived.rule
for concept in concepts
if concept.derived is not None
}
)
return {
"target_schema": {
"name": "CAP/ICCR endometrial carcinoma synoptic, projected onto the canonical concept list",
"concept_count": len(concepts),
"schema_checksum": schema_checksum,
},
"staging_edition": {
"selected": staging_edition or "as_recorded",
"per_case_editions": sorted(set(per_case_editions)),
},
"generated_at": datetime.now(timezone.utc).isoformat(),
"rule_versions": {
"dependency_graph_checksum": rule_set_checksum,
"named_rules": named_rules,
},
"model_versions": {"extraction": llm_extraction.MODEL},
"coding_systems": {
"stage": "FIGO; the edition each stage speaks is the per-case staging_edition column",
"value_sets": (
"CAP, ICCR, WHO, and AJCC reference standards; each concept cites its registry source "
"in the column spec's cited_to"
),
},
"sources": sources,
"reproduction": (
"A laboratory holding the same reports and confirming the same values against this concept "
"list re-derives an identical table. Every absent value carries a coded reason from "
"docs/prd.md section 4.3, so a missing value is never a bare blank."
),
}
def _provenance(case_barcode: str, checklist) -> list[dict]:
"""One field-level provenance record per checklist field: the value, its confidence and status, the
coded-absence markers, and the verbatim evidence span (docs/prd.md section 3 case record)."""
records: list[dict] = []
for field_name, field in checklist.all_fields().items():
evidence = field.evidence
records.append(
{
"case_barcode": case_barcode,
"field_name": field_name,
"value": _primitive(field.value),
"confidence": field.confidence,
"status": field.status.value,
"applicable": field.applicable,
"not_applicable_reason": field.not_applicable_reason,
"evidence_quote": evidence.quote if evidence else None,
"evidence_char_start": evidence.char_start if evidence else None,
"evidence_char_end": evidence.char_end if evidence else None,
"evidence_page": evidence.page_number if evidence else None,
"evidence_source": evidence.source if evidence else None,
}
)
return records
# The export is the confirmed dataset (#231, decision D1): the API exports only cases a reviewer has
# confirmed (or already exported), so the screen's "Confirmed records" title is truthful. build_export
# still projects any set when `statuses` is None, for internal callers and the projection tests.
EXPORTABLE_STATUSES: tuple[str, ...] = ("confirmed", "exported")
def build_export(
conn: Connection,
*,
staging_edition: Optional[str] = None,
project: Optional[str] = None,
statuses: Optional[tuple[str, ...]] = None,
) -> dict:
"""Project every case in the store onto the concept list and assemble the cohort export.
Returns the column spec, one projected row per case, the field-level provenance, and the reproduction
bundle. `staging_edition` names the edition the researcher chose at export (docs/prd.md section 4.2);
the base projection carries the edition each case recorded, and #40 owns re-projecting a stage under a
chosen edition as a candidate set. `project` scopes the export to one project's cases (issue #149);
omitted, every case in the store is projected. `statuses`, when given, keeps only cases in those
statuses; the API passes EXPORTABLE_STATUSES so the served export is the confirmed dataset (#231)."""
from endopath import storage
concepts = mapping.compile_concepts()
coverage = mapping.cap_checklist_coverage()
labels = _concept_labels()
rows: list[dict] = []
provenance: list[dict] = []
sources: list[dict] = []
join_sources: list[dict] = []
per_case_editions: list[str] = []
# The joined GDC outcome records, grouped by participant, in one read so the per-case outcome projection
# stays free of the N+1 the cohort export was restructured to avoid (issue #131).
longitudinal_by_participant = _load_longitudinal(conn)
# The reviewer join decisions (#180), so a rejected record is excluded from the projection and the
# confirmed linkages are attributed in the bundle (#217).
longitudinal_joins_by_case = _load_longitudinal_joins(conn)
# One batched read of the whole cohort rather than a get_case per barcode (issue #131): a per-case
# N+1 is ~100s over remote Postgres and 500s the export; this is a few queries. Scoped to the project
# when one is named (issue #149).
for case, patient_filename in storage.load_all_cases(conn, project=project):
if statuses is not None and case.status.value not in statuses:
continue # the export is the confirmed dataset (#231): an unreviewed case is not a record
projected = _project_case(case.checklist, concepts=concepts, coverage=coverage)
row: dict[str, Any] = {
"case_barcode": case.case_barcode,
"report_date": case.report_date.isoformat() if case.report_date else None,
"staging_edition": case.staging_edition.value,
"status": case.status.value,
}
for concept in concepts:
item = projected[concept.concept_id]
row[concept.concept_id] = item.value
row[f"{concept.concept_id}{ABSENT_REASON_SUFFIX}"] = item.absent_reason
# Re-project the FIGO stage under the chosen edition (#40). derive_stage returns a determined
# code, a constrained candidate set, or an indeterminate reason, each rendered distinctly rather
# than collapsed to one confident stage. The raw quantities stay in their own columns (invariant 1).
target_edition = staging_edition or case.staging_edition.value
stage_verdict = projection.project_stage(case.checklist, target_edition)
stage_value, stage_reason = projection.stage_cell(stage_verdict)
row["figo_stage"] = stage_value
row[f"figo_stage{ABSENT_REASON_SUFFIX}"] = stage_reason
row["figo_stage__projection"] = stage_verdict
# The join-sourced outcome block: the disease outcome joined from the GDC clinical record by
# participant barcode, projected onto the outcome columns (`longitudinal.py`). Every cell is a value
# or a coded absence, never a bare blank; a participant the join did not reach reads determined_by_join.
participant_id = (
case.case_barcode[:12] if len(case.case_barcode) >= 12 else case.case_barcode
)
outcome_records = longitudinal_by_participant.get(participant_id, [])
# Drop records a reviewer rejected (#217): a rejection removes the record from the outcome columns
# and from the bundle's contributing ids, so a linkage the reviewer disowned has no effect. A record
# with no decision is unreviewed, not rejected, and still projects (see `_rejected_record_ids`).
case_joins = longitudinal_joins_by_case.get(case.case_barcode, [])
rejected = _rejected_record_ids(case_joins)
if rejected:
outcome_records = [r for r in outcome_records if r.record_id not in rejected]
outcomes = longitudinal.project_outcomes(outcome_records)
for key in longitudinal.OUTCOME_COLUMN_KEYS:
cell = outcomes[key]
row[key] = cell.value
row[f"{key}{ABSENT_REASON_SUFFIX}"] = cell.absent_reason
source_entry = {
"case_barcode": case.case_barcode,
"participant_id": participant_id,
"record_ids": longitudinal.contributing_record_ids(outcome_records),
}
# The reviewer-attributed provenance behind the join-sourced columns (#176, #217): who decided each
# linkage, in what role, with what disposition. Present only when the case carries a decision.
if case_joins:
source_entry["linkage_decisions"] = case_joins
join_sources.append(source_entry)
rows.append(row)
provenance.extend(_provenance(case.case_barcode, case.checklist))
sources.append(
{
"case_barcode": case.case_barcode,
"source_document": patient_filename,
"evidence_checksum": _evidence_checksum(case.checklist),
}
)
per_case_editions.append(case.staging_edition.value)
from endopath import provenance as provenance_mod
bundle = _bundle(
concepts,
sources,
staging_edition=staging_edition,
per_case_editions=per_case_editions,
)
# The per-call LLM ledger (#116): how many model calls the cohort cost, in tokens and mean latency,
# so a second centre sees what produced the values, not only the values.
bundle["llm_calls"] = provenance_mod.summary(conn)
# The join provenance for the outcome columns (invariant 2 for a join-sourced value): the source of the
# join, the outcome columns it fills, the named derivation rule, the survival gap this pull carries, and
# the GDC record ids that fed each case's outcomes.
bundle["join_sources"] = {
"source": longitudinal.JOIN_SOURCE,
"outcome_columns": longitudinal.OUTCOME_COLUMN_KEYS,
"derivation_rules": [longitudinal.RECURRENCE_RULE, longitudinal.OVERALL_SURVIVAL_RULE],
"data_quality": (
"therapeutic_agents reads Tamoxifen for nearly every case. This is faithful to the GDC/TCGA "
"harmonized clinical record, a known source artifact, not an error in this pipeline; it is "
"surfaced with its provenance rather than silently dropped. The received_chemotherapy, "
"received_radiation, and received_hormone_therapy flags read administration, not record "
'presence: GDC emits a treatment_<type> template record marked treatment_or_therapy = "no" on '
"cases where the therapy was considered but not given (most often Radiation Therapy, NOS), so a "
'flag is True only where a matching record is marked "yes", False on an explicit "no", and '
'not_stated where the matching records carry only "unknown". Reading presence instead reports '
"radiation on 543/546 cases (99%); administration reports 264 (48%)."
),
"per_case": join_sources,
}
return {
"columns": _columns(concepts, labels),
"rows": rows,
"provenance": provenance,
"bundle": bundle,
}
# --- rendering the three files from one projection ------------------------------------------------
def _cell(value: Any) -> Any:
"""A projected value rendered for a spreadsheet cell: None to an empty string, a bool kept as a bool,
everything else as-is. An absent value's cell is empty; its reason rides the parallel column."""
if value is None:
return ""
return value
def to_csv(export: dict) -> str:
"""The projected table as CSV: the column keys as the header, one row per case, an empty cell for an
absent value with its reason in the parallel column."""
keys = [column["key"] for column in export["columns"]]
buffer = io.StringIO()
writer = csv.DictWriter(buffer, fieldnames=keys, extrasaction="ignore")
writer.writeheader()
for row in export["rows"]:
writer.writerow({key: _cell(row.get(key)) for key in keys})
return buffer.getvalue()
def to_json(export: dict) -> str:
"""The whole export as self-describing JSON: the bundle, the column spec, the rows, and the field-level
provenance, so the file alone re-derives the table."""
return json.dumps(
{
"bundle": export["bundle"],
"columns": export["columns"],
"rows": export["rows"],
"provenance": export["provenance"],
},
indent=2,
default=str,
)
def to_xlsx_bytes(export: dict) -> bytes:
"""The projected table as an XLSX workbook: a `cases` sheet of the projected rows, a `provenance`
sheet of the field-level evidence, and a `bundle` sheet of the reproduction metadata as key/value
rows."""
from openpyxl import Workbook
workbook = Workbook()
cases = workbook.active
cases.title = "cases"
keys = [column["key"] for column in export["columns"]]
cases.append([column["label"] for column in export["columns"]])
for row in export["rows"]:
cases.append([_cell(row.get(key)) for key in keys])
provenance_sheet = workbook.create_sheet("provenance")
provenance_headers = [
"case_barcode",
"field_name",
"value",
"confidence",
"status",
"applicable",
"not_applicable_reason",
"evidence_quote",
"evidence_page",
"evidence_source",
]
provenance_sheet.append(provenance_headers)
for record in export["provenance"]:
provenance_sheet.append([_cell(record.get(header)) for header in provenance_headers])
bundle_sheet = workbook.create_sheet("bundle")
bundle_sheet.append(["key", "value"])
for key, value in _flatten(export["bundle"]):
bundle_sheet.append([key, value])
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def _flatten(value: Any, prefix: str = "") -> list[tuple[str, str]]:
"""Flatten the nested bundle into dotted key/value rows for the XLSX bundle sheet."""
rows: list[tuple[str, str]] = []
if isinstance(value, dict):
for key, inner in value.items():
rows.extend(_flatten(inner, f"{prefix}.{key}" if prefix else str(key)))
elif isinstance(value, list):
if all(not isinstance(item, (dict, list)) for item in value):
rows.append((prefix, ", ".join(str(item) for item in value)))
else:
for index, item in enumerate(value):
rows.extend(_flatten(item, f"{prefix}[{index}]"))
else:
rows.append((prefix, "" if value is None else str(value)))
return rows
def render_files(export: dict) -> dict:
"""The three download files from one projection: CSV and JSON as text, XLSX as base64, each with its
filename and media type. One projection feeds the preview and all three files, so they never diverge."""
return {
"csv": {
"filename": "endometrial_cohort.csv",
"media_type": "text/csv",
"content": to_csv(export),
},
"json": {
"filename": "endometrial_cohort.json",
"media_type": "application/json",
"content": to_json(export),
},
"xlsx": {
"filename": "endometrial_cohort.xlsx",
"media_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"content_base64": base64.b64encode(to_xlsx_bytes(export)).decode("ascii"),
},
}
|