File size: 7,354 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
"""The CAP/ICCR endometrium checklist field list: the single source of truth.

Generated by scripts/generate_fields.py from the confirmed field-to-concept mapping
(endopath.mapping.cap_checklist_coverage, #95) and the taxonomy registry (endopath.taxonomy,
#35). Do not edit by hand: run `PYTHONPATH=src python3 scripts/generate_fields.py` and commit
the result. tests/test_field_generation.py fails when this file drifts from a fresh render.

Every consumer derives from `FIELDS` below: schema.py re-exports the enums, llm_extraction reads
FIELD_VALUE_SCHEMAS and ENUM_FIELDS, colpali_retrieval reads FIELD_QUERIES, and the frontend
renders the checklist from GET /api/fields (issue #37).

**Standard library only, deliberately.** `scripts/modal_precompute.py` runs on a Modal image
carrying only colpali-engine/transformers/torch/pillow/numpy, so it cannot import pydantic. Its
`@app.local_entrypoint` imports `FIELD_QUERIES` from here in the local process and passes it to the
GPU function as an argument. A test asserts this module stays import-clean; adding a third-party
import here silently breaks that job. (Note `endopath/__init__.py` imports schema, so the Modal
container must never import `endopath.fields` directly either.)

The value enums live here rather than in schema.py because a field's permitted values are part of
its definition, and schema.py's Pydantic models sit on top of that. schema.py re-exports them, so
`from endopath.schema import Histotype` still resolves.

Field *order* is meaningful: it is the order Case Review renders evidence cards.

Several value sets differ from how docs/taxonomy.md and the registry model the same concept. Those
differences are annotated on the fields below and reconciled separately: #81 (histotype, LVSI,
myometrial, cervical, nodal, and molecular value sets), #62 (pathologic_stage as a reported field
plus per-edition computed views), and #36 (the dedifferentiated/undifferentiated split).
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional


class Histotype(str, Enum):
    ENDOMETRIOID = "endometrioid"
    MUCINOUS = "mucinous"
    SEROUS = "serous"
    CLEAR_CELL = "clear_cell"
    CARCINOSARCOMA = "carcinosarcoma"
    DEDIFFERENTIATED_UNDIFFERENTIATED = "dedifferentiated_undifferentiated"
    MIXED = "mixed"
    OTHER_NOS = "other_nos"


class LvsiStatus(str, Enum):
    NOT_IDENTIFIED = "not_identified"
    FOCAL = "focal"
    SUBSTANTIAL = "substantial"


class MolecularClassification(str, Enum):
    POLE_ULTRAMUTATED = "pole_ultramutated"
    MMR_DEFICIENT = "mmr_deficient"
    P53_ABNORMAL = "p53_abnormal"
    NO_SPECIFIC_MOLECULAR_PROFILE = "no_specific_molecular_profile"
    MULTIPLE_CLASSIFIER = "multiple_classifier"


def _enum_schema(enum_cls: type[Enum]) -> dict[str, Any]:
    return {"type": "string", "enum": [member.value for member in enum_cls]}


@dataclass(frozen=True)
class FieldSpec:
    """One checklist field, in every form the codebase needs it.

    `query` is the ColPali retrieval phrase, and the key under which that
    phrase's precomputed vector is stored in data/embeddings/query_embeddings.npz.
    Renaming `name` therefore invalidates that file; `tests/test_fields.py`
    asserts the two agree so the break is loud rather than silent.

    `value_schema` is JSON Schema, consumed directly as the LLM tool-use
    parameter schema (llm_extraction.CHECKLIST_TOOL) and surfaced over
    /api/fields so the frontend can render a dropdown instead of a text box.
    `python_enum` is set only where the value coerces back to a Python Enum.
    """

    name: str
    label: str
    query: str
    value_schema: dict[str, Any]
    python_enum: Optional[type[Enum]] = None

    @property
    def value_type(self) -> str:
        return self.value_schema["type"]

    @property
    def enum_values(self) -> Optional[list[str]]:
        return self.value_schema.get("enum")


FIELDS: tuple[FieldSpec, ...] = (
    # Registry `histotype` splits dedifferentiated/undifferentiated, drops mucinous, and names
    # the residual other_not_listed (not other_nos): #81, #36.
    FieldSpec(
        name="histologic_type",
        label="Histologic type",
        query="histologic type",
        value_schema=_enum_schema(Histotype),
        python_enum=Histotype,
    ),
    # Registry `figo_grade` also carries the off-branch high_grade_non_endometrioid value; the
    # CAP field presents only FIGO 1/2/3: #81.
    FieldSpec(
        name="histologic_grade",
        label="Histologic grade (FIGO)",
        query="FIGO grade",
        value_schema={"type": "string", "enum": ["1", "2", "3"]},
    ),
    # Registry stores invasion_depth and myometrial_thickness in mm and derives the percent
    # (numbers stay numbers); the CAP field stores the percent directly: #81, #38.
    FieldSpec(
        name="myometrial_invasion_percent",
        label="Myometrial invasion %",
        query="myometrial invasion depth",
        value_schema={"type": "number", "minimum": 0, "maximum": 100},
    ),
    # Registry models cervical_stromal_invasion as coded present/absent/cannot_be_assessed; the
    # CAP field is a boolean: #81.
    FieldSpec(
        name="cervical_stromal_invasion",
        label="Cervical stromal invasion",
        query="cervical stromal invasion",
        value_schema={"type": "boolean"},
    ),
    # Registry separates asserted lvsi_status and foci count from derived lvsi_extent
    # (negative/focal/substantial); the CAP field stores the category with not_identified for
    # negative: #81.
    FieldSpec(
        name="lymphovascular_space_invasion",
        label="LVSI",
        query="lymphovascular space invasion",
        value_schema=_enum_schema(LvsiStatus),
        python_enum=LvsiStatus,
    ),
    # Registry models pn_category as a coded value set (pN0..pN2a); the CAP field is an open
    # string: #81.
    FieldSpec(
        name="regional_lymph_node_status",
        label="Regional lymph node status",
        query="regional lymph node status",
        value_schema={"type": "string"},
    ),
    # Registry derives figo_stage per FIGO edition (derive_stage); the CAP field is one reported
    # string. Split into a reported field plus computed views: #62.
    FieldSpec(
        name="pathologic_stage",
        label="Pathologic stage",
        query="pathologic stage",
        value_schema={"type": "string"},
    ),
    # Registry keeps promise_class and tcga_class as two dimensions; the CAP field mixes TCGA
    # and ProMisE naming in one enum: #81.
    FieldSpec(
        name="molecular_classification",
        label="Molecular classification",
        query="molecular classification",
        value_schema=_enum_schema(MolecularClassification),
        python_enum=MolecularClassification,
    ),
)

BY_NAME: dict[str, FieldSpec] = {spec.name: spec for spec in FIELDS}

FIELD_NAMES: tuple[str, ...] = tuple(spec.name for spec in FIELDS)

FIELD_LABELS: dict[str, str] = {spec.name: spec.label for spec in FIELDS}

FIELD_QUERIES: dict[str, str] = {spec.name: spec.query for spec in FIELDS}

FIELD_VALUE_SCHEMAS: dict[str, dict[str, Any]] = {spec.name: spec.value_schema for spec in FIELDS}

ENUM_FIELDS: dict[str, type[Enum]] = {
    spec.name: spec.python_enum for spec in FIELDS if spec.python_enum is not None
}