Ruurd commited on
Commit
c2b7494
·
verified ·
1 Parent(s): 6c0b239

Deploy OpenAI classification and protected terminology streaming

Browse files
Dockerfile ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+ WORKDIR /app
3
+ COPY backend/requirements.txt /app/requirements.txt
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ COPY src /app/src
6
+ COPY backend /app/backend
7
+ ENV PORT=7860
8
+ EXPOSE 7860
9
+ CMD ["sh", "-c", "exec uvicorn backend.app:app --host 0.0.0.0 --port ${PORT}"]
README.md CHANGED
@@ -1,10 +1,9 @@
1
  ---
2
- title: Semantic Harmonization Api
3
- emoji: 🚀
4
- colorFrom: green
5
- colorTo: green
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
  ---
2
+ title: Clinical Codelist API
 
 
 
3
  sdk: docker
4
+ app_port: 7860
5
  ---
6
 
7
+ Token-protected classification and GPT review through OpenAI, plus streaming access to a private terminology dataset.
8
+
9
+ Configure OPENAI_API_KEY, PIPELINE_API_TOKEN and HF_TOKEN as Space secrets. Configure PIPELINE_HF_DATASET and PIPELINE_ALLOWED_ORIGINS as variables. No terminology vectors are stored in the Space.
backend/app.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small classification API for the static browser app. No embedding dependencies.
2
+
3
+ Run: python -m uvicorn backend.app:app --host 127.0.0.1 --port 8000
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import asdict, fields
8
+ import hmac
9
+ import json
10
+ import logging
11
+ import math
12
+ import os
13
+ import re
14
+ from typing import Annotated, Literal
15
+
16
+ import httpx
17
+ from dotenv import load_dotenv
18
+ from fastapi import Depends, FastAPI, Header, HTTPException
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from fastapi.responses import StreamingResponse
21
+ from starlette.background import BackgroundTask
22
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
23
+
24
+ from src.data.loaders import EventDefinitionForm
25
+ from src.llm.classify import llm_classify
26
+ from src.llm.client import LLMCallError, default_model_for
27
+
28
+ load_dotenv()
29
+ logger = logging.getLogger(__name__)
30
+ app = FastAPI(title="Semantic Harmonization classification API", docs_url=None, redoc_url=None)
31
+ app.add_middleware(
32
+ CORSMiddleware,
33
+ allow_origins=[origin.strip() for origin in os.getenv(
34
+ "PIPELINE_ALLOWED_ORIGINS", "http://127.0.0.1:5173,http://localhost:5173,https://ruurdkuiper.github.io"
35
+ ).split(",") if origin.strip()],
36
+ allow_methods=["GET", "POST"], allow_headers=["Authorization", "Content-Type"],
37
+ )
38
+
39
+
40
+ def authorize(authorization: Annotated[str | None, Header()] = None):
41
+ token = os.getenv("PIPELINE_API_TOKEN", "")
42
+ if not token:
43
+ raise HTTPException(503, "The classification service needs an access token configured by its administrator.")
44
+ supplied = (authorization or "").removeprefix("Bearer ")
45
+ if not hmac.compare_digest(supplied.encode(), token.encode()):
46
+ raise HTTPException(401, "Enter a valid classification service access token.")
47
+
48
+
49
+ class Candidate(BaseModel):
50
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
51
+ code: str = Field(min_length=1, max_length=128)
52
+ vocabulary: Literal["ICD10CM", "ICD9CM", "ICPC", "MDR", "RCD2", "SNOMEDCT_US"]
53
+ description: str = Field(min_length=1, max_length=4096)
54
+
55
+
56
+ class Batch(BaseModel):
57
+ model_config = ConfigDict(extra="forbid")
58
+ edf: dict
59
+ candidates: list[Candidate] = Field(min_length=1, max_length=20)
60
+ use_possible_category: bool = False
61
+
62
+ @model_validator(mode="after")
63
+ def validate_edf(self):
64
+ allowed = {field.name: field for field in fields(EventDefinitionForm) if field.name != "name"}
65
+ if not set(self.edf) <= set(allowed):
66
+ raise ValueError("Unknown EDF field")
67
+ if len(json.dumps(self.edf)) > 65_536:
68
+ raise ValueError("EDF is too large")
69
+ for key, value in self.edf.items():
70
+ # Accept cleared YAML fields, matching the browser's normalization.
71
+ is_list = key in {"inclusion_criteria", "exclusion_criteria", "narrow_decision_rules", "possible_decision_rules",
72
+ "exclude_decision_rules", "synonyms", "candidate_anchor_codes", "ambiguities", "references"}
73
+ if value is None:
74
+ self.edf[key] = [] if is_list else ""
75
+ elif is_list and (not isinstance(value, list) or any(not isinstance(item, str) for item in value)):
76
+ raise ValueError(f"{key} must be a list of text values")
77
+ elif not is_list and not isinstance(value, str):
78
+ raise ValueError(f"{key} must be text")
79
+ if not self.edf.get("preferred_name", "").strip():
80
+ raise ValueError("preferred_name is required")
81
+ if len({(row.code, row.vocabulary) for row in self.candidates}) != len(self.candidates):
82
+ raise ValueError("Duplicate candidates in batch")
83
+ return self
84
+
85
+
86
+ @app.get("/health")
87
+ def health():
88
+ return {"status": "ok", "retrieval": "browser-minilm"}
89
+
90
+
91
+ _ASSET_PATH = re.compile(
92
+ r"(?:manifest\.json|(?:ICD10CM|ICD9CM|ICPC|MDR|RCD2|SNOMEDCT_US)-[0-9a-f]{20}/"
93
+ r"(?:lexical\.json\.gz|[0-9]{7}\.(?:json\.gz|f32)))\Z"
94
+ )
95
+
96
+
97
+ @app.get("/api/assets/{asset_path:path}", dependencies=[Depends(authorize)])
98
+ def terminology_asset(asset_path: str):
99
+ """Stream private HF dataset assets to authorized browsers without storing them."""
100
+ if not _ASSET_PATH.fullmatch(asset_path):
101
+ raise HTTPException(404, "Unknown terminology asset.")
102
+ repo = os.getenv("PIPELINE_HF_DATASET", "")
103
+ revision = os.getenv("PIPELINE_HF_REVISION", "main")
104
+ if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo) or not re.fullmatch(r"[A-Za-z0-9_-]+", revision):
105
+ raise HTTPException(503, "The Hugging Face terminology dataset is not configured.")
106
+ if not os.getenv("HF_TOKEN"):
107
+ raise HTTPException(503, "Private terminology downloads need a read-only HF_TOKEN configured in the Space secrets.")
108
+ headers = {"Accept-Encoding": "identity"}
109
+ if os.getenv("HF_TOKEN"):
110
+ headers["Authorization"] = "Bearer " + os.environ["HF_TOKEN"]
111
+ # HTTPX strips Authorization on cross-origin CDN redirects. Neither the HF
112
+ # token nor provider keys are forwarded to the browser.
113
+ client = httpx.Client(timeout=60, follow_redirects=True)
114
+ try:
115
+ response = client.send(client.build_request("GET",
116
+ f"https://huggingface.co/datasets/{repo}/resolve/{revision}/{asset_path}", headers=headers), stream=True)
117
+ except httpx.HTTPError:
118
+ client.close()
119
+ raise HTTPException(502, "The terminology download could not be reached. Please retry.") from None
120
+ if response.status_code != 200:
121
+ status = response.status_code
122
+ response.close()
123
+ client.close()
124
+ raise HTTPException(404 if status == 404 else 502,
125
+ "The terminology asset is unavailable. The administrator should check the dataset access settings.")
126
+
127
+ def close():
128
+ response.close()
129
+ client.close()
130
+
131
+ def chunks():
132
+ try:
133
+ yield from response.iter_raw(chunk_size=65536)
134
+ finally:
135
+ close()
136
+
137
+ # Private terminology data must not be cached by shared HTTP proxies.
138
+ # The authorized browser maintains its own content-addressed IndexedDB cache.
139
+ return StreamingResponse(chunks(), media_type="application/json" if asset_path == "manifest.json" else "application/octet-stream",
140
+ headers={"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff"},
141
+ background=BackgroundTask(close))
142
+
143
+
144
+ def classify_batch(batch: Batch, review: bool):
145
+ provider = "openai" if review else os.getenv("PIPELINE_CLASSIFIER_PROVIDER", "openai")
146
+ if provider not in {"jev", "openai", "anthropic", "google"}:
147
+ raise HTTPException(503, "The classification provider is not configured correctly.")
148
+ model = os.getenv("PIPELINE_REVIEW_MODEL" if review else "PIPELINE_CLASSIFIER_MODEL") or default_model_for(provider)
149
+ try:
150
+ records = llm_classify(
151
+ batch.candidates, EventDefinitionForm(name="browser-edf", **batch.edf),
152
+ provider=provider, model=model, max_retries=3, batch_size=10 if review else 20,
153
+ use_possible_category=batch.use_possible_category, reasoning_effort="medium", adaptive_stopping=None,
154
+ )
155
+ except (LLMCallError, ValueError, RuntimeError):
156
+ logger.exception("Classification request failed")
157
+ # Provider responses may contain sensitive diagnostic text. Keep it server-side.
158
+ raise HTTPException(502, "The model request failed. Retry the batch or ask the service administrator to check its configuration.") from None
159
+ expected = {(row.code, row.vocabulary) for row in batch.candidates}
160
+ labels = {"Narrow", "Exclude", "Possible"} if batch.use_possible_category else {"Narrow", "Exclude"}
161
+ keys = [(row.code, row.vocabulary) for row in records]
162
+ if len(keys) != len(expected) or set(keys) != expected or any(
163
+ row.label not in labels or not math.isfinite(row.confidence) or not 0 <= row.confidence <= 1
164
+ or row.explanation == "No classification returned by LLM; routed for manual review."
165
+ for row in records
166
+ ):
167
+ raise HTTPException(502, "The model returned an incomplete or invalid batch. Retry to continue.")
168
+ return {"decisions": [asdict(row) for row in records], "provider": provider, "model": model}
169
+
170
+
171
+ @app.post("/api/classify", dependencies=[Depends(authorize)])
172
+ def classify(batch: Batch):
173
+ return classify_batch(batch, review=False)
174
+
175
+
176
+ @app.post("/api/review", dependencies=[Depends(authorize)])
177
+ def review(batch: Batch):
178
+ if len(batch.candidates) > 10:
179
+ raise HTTPException(422, "Review accepts at most ten candidates per batch.")
180
+ return classify_batch(batch, review=True)
backend/requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115,<1
2
+ uvicorn>=0.30,<1
3
+ openai>=1.50,<3
4
+ anthropic>=0.40,<1
5
+ google-genai>=1,<3
6
+ pandas>=2,<4
7
+ pyyaml>=6,<7
8
+ python-dotenv>=1,<2
9
+ httpx>=0.27,<1
src/__init__.py ADDED
File without changes
src/data/__init__.py ADDED
File without changes
src/data/code_systems.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Loaders for full clinical coding-system reference lists (ICD10, SNOMED CT,
2
+ MedDRA, ...), used as the retrieval universe for concept retrieval.
3
+
4
+ Unlike the phenotype-specific AESI review exports (`load_aesi_dataset`), these
5
+ represent the *complete* set of codes in a vocabulary, independent of any
6
+ phenotype. Since they can be large (tens of thousands of rows) and slow to
7
+ load/parse from their native format, the processed corpus for each vocabulary
8
+ is cached to disk as Parquet so the source file only needs to be parsed once.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import csv
14
+ from pathlib import Path
15
+
16
+ import pandas as pd
17
+
18
+
19
+ DEFAULT_CODE_SYSTEM_PATHS = {
20
+ "ICD10CM": "data/codes/csv/ICD10CM@2026-codes.csv",
21
+ "ICD9CM": "data/codes/csv/ICD9CM@2014-codes.csv",
22
+ "ICPC": "data/codes/csv/ICPC@1993-codes.csv",
23
+ "MDR": "data/codes/csv/MDR@28.0-codes.csv",
24
+ "RCD2": "data/codes/csv/RCD2@20200401-codes.csv",
25
+ "SNOMEDCT_US": "data/codes/csv/SNOMEDCT_US@2025_09_01-codes definitive.csv",
26
+ }
27
+
28
+
29
+ def _load_codelist_csv(
30
+ path: str | Path,
31
+ *,
32
+ vocabulary: str,
33
+ delimiter: str = ",",
34
+ ) -> pd.DataFrame:
35
+ """Load a code list CSV into the canonical retrieval schema.
36
+
37
+ The source files in ``data/codes`` are plain text CSV exports, but the
38
+ description field may contain commas wrapped in quotes. We therefore rely
39
+ on the CSV parser rather than manual splitting so those commas stay inside
40
+ the description instead of being treated as extra columns.
41
+ """
42
+ path = Path(path)
43
+ if not path.exists():
44
+ raise FileNotFoundError(f"Full {vocabulary} code list not found: {path}")
45
+
46
+ raw = pd.read_csv(
47
+ path,
48
+ dtype=str,
49
+ sep=delimiter,
50
+ engine="python",
51
+ quotechar='"',
52
+ escapechar="\\",
53
+ on_bad_lines="skip",
54
+ )
55
+
56
+ if raw.empty:
57
+ raise ValueError(f"No rows loaded from {path}")
58
+
59
+ # Normalize the first two columns into code/description regardless of the
60
+ # exact header names used in the export.
61
+ columns = list(raw.columns)
62
+ if len(columns) < 2:
63
+ raise ValueError(f"Unexpected {vocabulary} CSV schema in {path}: columns={columns}")
64
+
65
+ code_col = columns[0]
66
+ desc_col = columns[1]
67
+ df = raw[[code_col, desc_col]].rename(columns={code_col: "code", desc_col: "description"})
68
+ df = df.dropna(subset=["code", "description"]).copy()
69
+ df["code"] = df["code"].astype(str).str.strip().str.strip('"')
70
+ df["description"] = df["description"].astype(str).str.strip().str.strip('"')
71
+ df["vocabulary"] = vocabulary
72
+ df = df[df["code"] != ""]
73
+ df = df[df["description"] != ""]
74
+ df = df.drop_duplicates(subset=["code", "vocabulary"], keep="first").reset_index(drop=True)
75
+ return df
76
+
77
+
78
+ def _load_two_column_text(
79
+ path: str | Path,
80
+ *,
81
+ vocabulary: str,
82
+ delimiter: str,
83
+ ) -> pd.DataFrame:
84
+ """Load a very loose delimited export by splitting only on the first separator.
85
+
86
+ Some vendor exports include trailing separators or extra empty columns. This
87
+ parser keeps the first field as the code and the remainder of the line as the
88
+ description so embedded punctuation in the description is preserved.
89
+ """
90
+ path = Path(path)
91
+ if not path.exists():
92
+ raise FileNotFoundError(f"Full {vocabulary} code list not found: {path}")
93
+
94
+ rows: list[tuple[str, str]] = []
95
+ for line in path.read_text().splitlines():
96
+ line = line.strip()
97
+ if not line or line.lower().startswith("code"):
98
+ continue
99
+ if delimiter not in line:
100
+ continue
101
+ code, description = line.split(delimiter, 1)
102
+ code = code.strip().strip('"')
103
+ description = description.strip().strip(";").strip(delimiter).strip().strip('"')
104
+ if code and description:
105
+ rows.append((code, description))
106
+
107
+ if not rows:
108
+ raise ValueError(f"No rows loaded from {path}")
109
+
110
+ df = pd.DataFrame(rows, columns=["code", "description"])
111
+ df["vocabulary"] = vocabulary
112
+ df = df.drop_duplicates(subset=["code", "vocabulary"], keep="first").reset_index(drop=True)
113
+ return df
114
+
115
+
116
+ def load_icd10cm_full(csv_path: str | Path = DEFAULT_CODE_SYSTEM_PATHS["ICD10CM"]) -> pd.DataFrame:
117
+ return _load_codelist_csv(csv_path, vocabulary="ICD10CM", delimiter=",")
118
+
119
+
120
+ def load_icd9cm_full(csv_path: str | Path = DEFAULT_CODE_SYSTEM_PATHS["ICD9CM"]) -> pd.DataFrame:
121
+ return _load_codelist_csv(csv_path, vocabulary="ICD9CM", delimiter=",")
122
+
123
+
124
+ def load_icpc_full(csv_path: str | Path = DEFAULT_CODE_SYSTEM_PATHS["ICPC"]) -> pd.DataFrame:
125
+ return _load_codelist_csv(csv_path, vocabulary="ICPC", delimiter=",")
126
+
127
+
128
+ def load_mdr_full(csv_path: str | Path = DEFAULT_CODE_SYSTEM_PATHS["MDR"]) -> pd.DataFrame:
129
+ return _load_codelist_csv(csv_path, vocabulary="MDR", delimiter=",")
130
+
131
+
132
+ def load_rcd2_full(csv_path: str | Path = DEFAULT_CODE_SYSTEM_PATHS["RCD2"]) -> pd.DataFrame:
133
+ return _load_two_column_text(csv_path, vocabulary="RCD2", delimiter=",")
134
+
135
+
136
+ def load_snomedct_full(csv_path: str | Path = DEFAULT_CODE_SYSTEM_PATHS["SNOMEDCT_US"]) -> pd.DataFrame:
137
+ path = Path(csv_path)
138
+ if not path.exists():
139
+ raise FileNotFoundError(f"Full SNOMEDCT_US code list not found: {path}")
140
+
141
+ # Excel can wrap each original comma-delimited record in a semicolon-
142
+ # delimited sheet. Decode the outer quoting before the original CSV quoting.
143
+ # Terms containing semicolons may occupy more than one outer cell, so join
144
+ # those cells back together rather than truncating the term at the first one.
145
+ with path.open(encoding="utf-8-sig", newline="") as handle:
146
+ reader = csv.reader(handle, delimiter=";")
147
+ header = next(reader, [])
148
+ inner_header = next(csv.reader([header[0]]), []) if header else []
149
+ wrapped = (
150
+ len(header) > 1
151
+ and len(inner_header) == 2
152
+ and inner_header[0].strip().lower() == "code"
153
+ and inner_header[1].strip().lower() in {"term", "description"}
154
+ and not any(cell.strip() for cell in header[1:])
155
+ )
156
+ if wrapped:
157
+ rows = []
158
+ for row in reader:
159
+ if not row or not any(cell.strip() for cell in row):
160
+ continue
161
+ record = ";".join(row).rstrip(";")
162
+ try:
163
+ fields = next(csv.reader([record], strict=True))
164
+ except csv.Error as exc:
165
+ raise ValueError(
166
+ f"Malformed nested SNOMED CSV at source line {reader.line_num}"
167
+ ) from exc
168
+ if len(fields) != 2:
169
+ raise ValueError(
170
+ f"Expected code and term at SNOMED source line {reader.line_num}"
171
+ )
172
+ code, description = (value.strip() for value in fields)
173
+ if code and description:
174
+ rows.append((code, description))
175
+ if not rows:
176
+ raise ValueError(f"No rows loaded from {path}")
177
+ frame = pd.DataFrame(rows, columns=["code", "description"])
178
+ frame["vocabulary"] = "SNOMEDCT_US"
179
+ return frame.drop_duplicates(subset=["code", "vocabulary"], keep="first").reset_index(drop=True)
180
+
181
+ return _load_two_column_text(csv_path, vocabulary="SNOMEDCT_US", delimiter=",")
182
+
183
+
184
+ CODE_SYSTEM_LOADERS = {
185
+ "ICD10CM": load_icd10cm_full,
186
+ "ICD9CM": load_icd9cm_full,
187
+ "ICPC": load_icpc_full,
188
+ "MDR": load_mdr_full,
189
+ "RCD2": load_rcd2_full,
190
+ "SNOMEDCT_US": load_snomedct_full,
191
+ }
192
+
193
+
194
+ def load_code_system_corpus(
195
+ vocabularies: list[str],
196
+ source_paths: dict[str, str] | None = None,
197
+ cache_dir: str | Path = "data/codes/parquet/snomed_definitive_20261030",
198
+ ) -> pd.DataFrame:
199
+ """Load (and cache) the full reference code corpus for the given vocabularies.
200
+
201
+ Each vocabulary is parsed from its source file at most once; the processed
202
+ result (``code``, ``description``, ``vocabulary``) is cached to
203
+ ``<cache_dir>/codes_<vocabulary>.parquet`` and reused on subsequent calls,
204
+ so the (potentially slow) source loader only runs once per vocabulary.
205
+
206
+ Parameters
207
+ ----------
208
+ vocabularies : list[str]
209
+ Coding systems to include, e.g. ``["ICD10CM"]``.
210
+ source_paths : dict[str, str], optional
211
+ Override source file path per vocabulary (defaults to each loader's
212
+ built-in default path).
213
+ cache_dir : str or Path
214
+ Directory to store/read the cached per-vocabulary Parquet files.
215
+
216
+ Returns
217
+ -------
218
+ pd.DataFrame
219
+ Concatenated corpus across all requested vocabularies with columns
220
+ ``code``, ``description``, ``vocabulary``.
221
+ """
222
+ if not vocabularies:
223
+ raise ValueError("At least one vocabulary must be specified.")
224
+
225
+ source_paths = source_paths or {}
226
+ cache_dir = Path(cache_dir)
227
+ cache_dir.mkdir(parents=True, exist_ok=True)
228
+
229
+ frames: list[pd.DataFrame] = []
230
+ for vocab in vocabularies:
231
+ if vocab not in CODE_SYSTEM_LOADERS:
232
+ raise ValueError(
233
+ f"No loader registered for coding system '{vocab}'. "
234
+ f"Available: {sorted(CODE_SYSTEM_LOADERS)}"
235
+ )
236
+
237
+ cache_path = cache_dir / f"codes_{vocab}.parquet"
238
+ source_path = Path(source_paths.get(vocab, DEFAULT_CODE_SYSTEM_PATHS[vocab]))
239
+ cache_is_fresh = (
240
+ cache_path.exists()
241
+ and source_path.exists()
242
+ and cache_path.stat().st_mtime_ns >= source_path.stat().st_mtime_ns
243
+ )
244
+ if cache_is_fresh:
245
+ frames.append(pd.read_parquet(cache_path))
246
+ continue
247
+
248
+ loader = CODE_SYSTEM_LOADERS[vocab]
249
+ df = loader(source_path)
250
+ df.to_parquet(cache_path, index=False)
251
+ frames.append(df)
252
+
253
+ return pd.concat(frames, ignore_index=True) if len(frames) > 1 else frames[0]
src/data/loaders.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+ import yaml
8
+
9
+
10
+ @dataclass
11
+ class EventDefinitionForm:
12
+ """Structured phenotype definition (EDF) providing semantic context for
13
+ LLM-assisted concept retrieval and code classification."""
14
+
15
+ name: str
16
+ preferred_name: str
17
+ definition: str = ""
18
+ inclusion_criteria: list[str] = field(default_factory=list)
19
+ narrow_definition: str = ""
20
+ exclusion_criteria: list[str] = field(default_factory=list)
21
+ narrow_decision_rules: list[str] = field(default_factory=list)
22
+ possible_decision_rules: list[str] = field(default_factory=list)
23
+ exclude_decision_rules: list[str] = field(default_factory=list)
24
+ synonyms: list[str] = field(default_factory=list)
25
+ anatomical_location: str = ""
26
+ population_restrictions: str = ""
27
+ temporal_characteristics: str = ""
28
+ etiology: str = ""
29
+ morphology: str = ""
30
+ diagnostic_certainty: str = ""
31
+ administrative_context: str = ""
32
+ candidate_anchor_codes: list[str] = field(default_factory=list)
33
+ ambiguities: list[str] = field(default_factory=list)
34
+ references: list[str] = field(default_factory=list)
35
+
36
+ def to_prompt_context(self, include_possible: bool = True) -> str:
37
+ """Render the EDF as structured text suitable for retrieval or an LLM prompt.
38
+
39
+ Possible rules are never discarded. In binary mode they define cases
40
+ that are insufficient for Narrow and therefore map to Exclude.
41
+ """
42
+ lines = [f"Preferred name: {self.preferred_name}"]
43
+ if self.definition:
44
+ lines.append(f"Definition: {self.definition}")
45
+ if self.narrow_definition:
46
+ lines.append(f"Narrow definition: {self.narrow_definition}")
47
+ if self.inclusion_criteria:
48
+ lines.append("Inclusion criteria:")
49
+ lines += [f" - {c}" for c in self.inclusion_criteria]
50
+ if self.exclusion_criteria:
51
+ lines.append("Exclusion criteria:")
52
+ lines += [f" - {c}" for c in self.exclusion_criteria]
53
+ if self.narrow_decision_rules:
54
+ lines.append("Criteria for Narrow classification:")
55
+ lines += [f" - {c}" for c in self.narrow_decision_rules]
56
+ if self.possible_decision_rules:
57
+ if include_possible:
58
+ lines.append("Criteria for Possible classification (when enabled):")
59
+ else:
60
+ lines.append(
61
+ "Criteria that are insufficient for Narrow classification "
62
+ "(classify as Exclude because Possible is disabled):"
63
+ )
64
+ lines += [f" - {c}" for c in self.possible_decision_rules]
65
+ if self.exclude_decision_rules:
66
+ lines.append("Criteria for Exclude classification:")
67
+ lines += [f" - {c}" for c in self.exclude_decision_rules]
68
+ if self.synonyms:
69
+ lines.append(f"Synonyms: {', '.join(self.synonyms)}")
70
+ if self.anatomical_location:
71
+ lines.append(f"Anatomical location: {self.anatomical_location}")
72
+ if self.population_restrictions:
73
+ lines.append(f"Population restrictions: {self.population_restrictions}")
74
+ if self.temporal_characteristics:
75
+ lines.append(f"Temporal characteristics: {self.temporal_characteristics}")
76
+ if self.etiology:
77
+ lines.append(f"Etiology: {self.etiology}")
78
+ if self.morphology:
79
+ lines.append(f"Morphology/pathology: {self.morphology}")
80
+ if self.diagnostic_certainty:
81
+ lines.append(f"Diagnostic certainty: {self.diagnostic_certainty}")
82
+ if self.administrative_context:
83
+ lines.append(f"Administrative/historical context: {self.administrative_context}")
84
+ if self.candidate_anchor_codes:
85
+ lines.append("Candidate anchor codes:")
86
+ lines += [f" - {c}" for c in self.candidate_anchor_codes]
87
+ if self.ambiguities:
88
+ lines.append("Ambiguities:")
89
+ lines += [f" - {c}" for c in self.ambiguities]
90
+ if self.references:
91
+ lines.append("References:")
92
+ lines += [f" - {c}" for c in self.references]
93
+ return "\n".join(lines)
94
+
95
+ def to_lexical_query(self) -> str:
96
+ """Render the phenotype name and its synonyms for lexical matching."""
97
+ terms = [self.preferred_name.strip(), *(term.strip() for term in self.synonyms)]
98
+ return " ".join(dict.fromkeys(term for term in terms if term))
99
+
100
+ def to_embedding_query(self) -> str:
101
+ """Render the EDF subset used for semantic embedding matching."""
102
+ lines = [f"Preferred name: {self.preferred_name}"]
103
+ if self.narrow_definition:
104
+ lines.append(f"Narrow definition: {self.narrow_definition}")
105
+ if self.synonyms:
106
+ lines.append(f"Synonyms: {', '.join(self.synonyms)}")
107
+ if self.narrow_decision_rules:
108
+ lines.append("Narrow decision rules:")
109
+ lines += [f" - {c}" for c in self.narrow_decision_rules]
110
+ if self.candidate_anchor_codes:
111
+ lines.append("Candidate anchor codes:")
112
+ lines += [f" - {c}" for c in self.candidate_anchor_codes]
113
+ if self.morphology:
114
+ lines.append(f"Morphology/pathology: {self.morphology}")
115
+ if self.anatomical_location:
116
+ lines.append(f"Anatomical location: {self.anatomical_location}")
117
+ return "\n".join(lines)
118
+
119
+
120
+ def load_edf(name: str, edf_dir: str | Path = "data/edfs/yamls") -> EventDefinitionForm:
121
+ """Load an Event Definition Form by phenotype name from a YAML file."""
122
+ path = Path(edf_dir) / f"{name}.yaml"
123
+ if not path.exists():
124
+ raise FileNotFoundError(f"EDF not found for phenotype '{name}': {path}")
125
+ raw = yaml.safe_load(path.read_text()) or {}
126
+ return EventDefinitionForm(name=name, **raw)
127
+
128
+
129
+ def load_aesi_dataset(csv_path: str | Path) -> tuple[pd.DataFrame, pd.DataFrame]:
130
+ """Load a raw AESI (Adverse Event of Special Interest) review export and
131
+ split it into a candidate code corpus and expert-curated gold labels.
132
+
133
+ These CSV exports (e.g. ``C_MYOCARD_AESI_filtered.csv``) contain the full
134
+ set of candidate codes considered during expert phenotype curation, with
135
+ one row per (code, vocabulary) pair and a ``tags`` column holding the
136
+ reviewer's final classification (``narrow``/``possible``/``exclude``).
137
+
138
+ Parameters
139
+ ----------
140
+ csv_path : str or Path
141
+ Path to the raw AESI CSV export.
142
+
143
+ Returns
144
+ -------
145
+ tuple[pd.DataFrame, pd.DataFrame]
146
+ ``(codes, gold_labels)`` where ``codes`` has columns
147
+ ``code``, ``description``, ``vocabulary`` and ``gold_labels`` has
148
+ columns ``code``, ``code_name``, ``vocabulary``, ``label``
149
+ (Narrow/Possible/Exclude).
150
+ """
151
+ path = Path(csv_path)
152
+ if not path.exists():
153
+ raise FileNotFoundError(f"AESI dataset not found: {path}")
154
+
155
+ raw = pd.read_csv(path, dtype=str)
156
+ required = {"code", "code_name", "coding_system", "tags"}
157
+ missing = required - set(raw.columns)
158
+ if missing:
159
+ raise ValueError(f"AESI dataset missing required columns: {missing}")
160
+
161
+ df = raw.dropna(subset=["tags"]).copy()
162
+ df["label"] = df["tags"].str.strip().str.title()
163
+
164
+ valid_labels = {"Narrow", "Possible", "Exclude"}
165
+ invalid = set(df["label"]) - valid_labels
166
+ if invalid:
167
+ raise ValueError(f"AESI dataset contains invalid tag values: {invalid}")
168
+
169
+ codes = (
170
+ df[["code", "code_name", "coding_system"]]
171
+ .rename(columns={"code_name": "description", "coding_system": "vocabulary"})
172
+ .drop_duplicates(subset=["code", "vocabulary"], keep="first")
173
+ .reset_index(drop=True)
174
+ )
175
+ gold_labels = (
176
+ df[["code", "code_name", "coding_system", "label"]]
177
+ .rename(columns={"coding_system": "vocabulary"})
178
+ .drop_duplicates(subset=["code", "vocabulary"], keep="first")
179
+ .reset_index(drop=True)
180
+ )
181
+ return codes, gold_labels
src/data/preprocessing.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import unicodedata
5
+
6
+ import pandas as pd
7
+
8
+ _WHITESPACE_RE = re.compile(r"\s+")
9
+ _PUNCT_RE = re.compile(r"[^\w\s]")
10
+ _CODE_RANGE_RE = re.compile(
11
+ r"^[A-Z]*\d+[A-Z0-9.]*\s*[-\u2010-\u2015\u2212]\s*[A-Z]*\d+[A-Z0-9.]*$",
12
+ re.IGNORECASE,
13
+ )
14
+
15
+
16
+ def normalize_text(text: str) -> str:
17
+ """Lowercase, strip accents/punctuation, and collapse whitespace for
18
+ lexical matching and deduplication."""
19
+ if not isinstance(text, str):
20
+ return ""
21
+ text = unicodedata.normalize("NFKD", text)
22
+ text = "".join(ch for ch in text if not unicodedata.combining(ch))
23
+ text = text.lower()
24
+ text = _PUNCT_RE.sub(" ", text)
25
+ text = _WHITESPACE_RE.sub(" ", text).strip()
26
+ return text
27
+
28
+
29
+ def standardize_code(code: str, vocabulary: str) -> str:
30
+ """Standardize a code's surface form per vocabulary conventions
31
+ (uppercase, no internal whitespace; ICD-style codes keep their dot)."""
32
+ if not isinstance(code, str):
33
+ return ""
34
+ code = code.strip().upper()
35
+ code = re.sub(r"\s+", "", code)
36
+ vocabulary = (vocabulary or "").strip().upper()
37
+ if vocabulary in {"ICD10", "ICD10CM"} and "." not in code and len(code) > 3:
38
+ code = f"{code[:3]}.{code[3:]}"
39
+ return code
40
+
41
+
42
+ def is_code_range(code: object) -> bool:
43
+ """Return whether *code* is a dictionary grouping range, not a billable code.
44
+
45
+ Examples include ``N17-N19``, ``N17–N19``, and ``001-009.99``. Both
46
+ endpoints must contain a digit, which avoids treating leading-hyphen ICPC
47
+ process codes such as ``-30`` or ordinary hyphenated text as a range.
48
+ """
49
+ return isinstance(code, str) and _CODE_RANGE_RE.fullmatch(code.strip()) is not None
50
+
51
+
52
+ def filter_code_ranges(df: pd.DataFrame) -> pd.DataFrame:
53
+ """Remove non-codable dictionary range rows from a terminology corpus."""
54
+ if df.empty or "code" not in df.columns:
55
+ return df.copy()
56
+ range_mask = df["code"].map(is_code_range)
57
+ return df.loc[~range_mask].reset_index(drop=True)
58
+
59
+
60
+ def deduplicate_codes(df: pd.DataFrame) -> pd.DataFrame:
61
+ """Remove duplicate (code, vocabulary) pairs, keeping the first occurrence."""
62
+ if df.empty:
63
+ return df
64
+ working = df.copy()
65
+ working["_code_key"] = working.apply(
66
+ lambda r: standardize_code(r["code"], r.get("vocabulary", "")), axis=1
67
+ )
68
+ working = working.drop_duplicates(subset=["_code_key", "vocabulary"], keep="first")
69
+ return working.drop(columns=["_code_key"]).reset_index(drop=True)
70
+
71
+
72
+ def preprocess_corpus(df: pd.DataFrame) -> pd.DataFrame:
73
+ """Filter ranges, standardize codes/descriptions, and drop duplicates."""
74
+ df = filter_code_ranges(df)
75
+ df = deduplicate_codes(df)
76
+ df = df.copy()
77
+ df["code"] = [standardize_code(c, v) for c, v in zip(df["code"], df["vocabulary"])]
78
+ df["normalized_description"] = df["description"].map(normalize_text)
79
+ return df
src/evaluation/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Evaluation metrics against gold-standard phenotype codelists."""
src/evaluation/metrics.py ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation metrics for concept retrieval and code classification against
2
+ expert-curated gold-standard phenotype codelists."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import asdict, dataclass
7
+ import json
8
+
9
+ import pandas as pd
10
+
11
+ from src.data.preprocessing import standardize_code
12
+
13
+ LABELS = ("Narrow", "Possible", "Exclude")
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class RetrievalMetrics:
18
+ true_positives: int
19
+ false_positives: int
20
+ false_negatives: int
21
+ sensitivity: float
22
+ precision: float
23
+ f1: float
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class ClassificationMetrics:
28
+ accuracy: float
29
+ cohens_kappa: float
30
+ true_negatives: int
31
+ sensitivity: float
32
+ precision: float
33
+ f1: float
34
+ macro_sensitivity: float
35
+ macro_precision: float
36
+ macro_f1: float
37
+ per_label: dict[str, dict[str, float]]
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class NarrowLossBreakdown:
42
+ """Where available gold-Narrow codes were lost end to end."""
43
+
44
+ gold_narrow_available: int
45
+ correctly_classified: int
46
+ misclassified: int
47
+ not_classified: int
48
+ total_missed: int
49
+ end_to_end_recall: float
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class GPTReviewEvaluation:
54
+ """Gold-standard impact of the final low-confidence GPT review."""
55
+
56
+ candidates_reviewed: int
57
+ labels_changed: int
58
+ corrected_changes: int
59
+ harmful_changes: int
60
+ unchanged: int
61
+ correct_before: int
62
+ correct_after: int
63
+ net_correct_change: int
64
+ reviewed_accuracy_before: float
65
+ reviewed_accuracy_after: float
66
+
67
+
68
+ def _standardized_key(code: str, vocabulary: str) -> tuple[str, str]:
69
+ return standardize_code(code, vocabulary), (vocabulary or "").strip().upper()
70
+
71
+
72
+ def filter_records_by_vocabulary(records: list[dict], vocabularies: list[str]) -> list[dict]:
73
+ """Keep only records whose ``vocabulary`` is in *vocabularies* (case-insensitive)."""
74
+ vocab_set = {v.strip().upper() for v in vocabularies}
75
+ return [r for r in records if (r.get("vocabulary") or "").strip().upper() in vocab_set]
76
+
77
+
78
+ def filter_gold_by_vocabulary(gold_labels: pd.DataFrame, vocabularies: list[str]) -> pd.DataFrame:
79
+ """Keep only gold-label rows whose ``vocabulary`` is in *vocabularies* (case-insensitive)."""
80
+ vocab_set = {v.strip().upper() for v in vocabularies}
81
+ mask = gold_labels["vocabulary"].str.strip().str.upper().isin(vocab_set)
82
+ return gold_labels[mask].reset_index(drop=True)
83
+
84
+
85
+ def filter_gold_by_available_codes(
86
+ gold_labels: pd.DataFrame,
87
+ available_codes: pd.DataFrame,
88
+ ) -> pd.DataFrame:
89
+ """Restrict gold labels to codes present in the loaded terminology corpus.
90
+
91
+ This prevents codes from older or otherwise unavailable terminology
92
+ versions from being counted as false negatives.
93
+ """
94
+ required = {"code", "vocabulary"}
95
+ missing = required - set(available_codes.columns)
96
+ if missing:
97
+ raise ValueError(f"Available code corpus missing required columns: {missing}")
98
+
99
+ available_keys = {
100
+ _standardized_key(row["code"], row["vocabulary"])
101
+ for _, row in available_codes.iterrows()
102
+ }
103
+ mask = gold_labels.apply(
104
+ lambda row: _standardized_key(row["code"], row["vocabulary"]) in available_keys,
105
+ axis=1,
106
+ )
107
+ return gold_labels[mask].reset_index(drop=True)
108
+
109
+
110
+ def narrow_loss_breakdown(
111
+ classified: list[dict],
112
+ gold_labels: pd.DataFrame,
113
+ ) -> NarrowLossBreakdown:
114
+ """Separate gold-Narrow losses before and during LLM classification.
115
+
116
+ ``not_classified`` covers gold-Narrow codes that never reached the model
117
+ (for example because adaptive stopping or a hard top-k limit ended the
118
+ scan). ``misclassified`` covers codes that did reach the model but were
119
+ assigned a label other than Narrow.
120
+ """
121
+ gold_narrow_keys = {
122
+ _standardized_key(row["code"], row["vocabulary"])
123
+ for _, row in gold_labels.iterrows()
124
+ if str(row["label"]).strip().title() == "Narrow"
125
+ }
126
+ predictions_by_key = {
127
+ _standardized_key(item["code"], item["vocabulary"]):
128
+ str(item.get("label", "")).strip().title()
129
+ for item in classified
130
+ }
131
+
132
+ correctly_classified = sum(
133
+ predictions_by_key.get(key) == "Narrow" for key in gold_narrow_keys
134
+ )
135
+ misclassified = sum(
136
+ key in predictions_by_key and predictions_by_key[key] != "Narrow"
137
+ for key in gold_narrow_keys
138
+ )
139
+ not_classified = len(gold_narrow_keys - predictions_by_key.keys())
140
+ total_missed = misclassified + not_classified
141
+ recall = correctly_classified / len(gold_narrow_keys) if gold_narrow_keys else 0.0
142
+
143
+ return NarrowLossBreakdown(
144
+ gold_narrow_available=len(gold_narrow_keys),
145
+ correctly_classified=correctly_classified,
146
+ misclassified=misclassified,
147
+ not_classified=not_classified,
148
+ total_missed=total_missed,
149
+ end_to_end_recall=round(recall, 4),
150
+ )
151
+
152
+
153
+ def evaluate_gpt_review(
154
+ stage2_classified: list[dict],
155
+ final_classified: list[dict],
156
+ gold_labels: pd.DataFrame,
157
+ *,
158
+ confidence_threshold: float,
159
+ max_candidates: int | None = None,
160
+ include_possible: bool = False,
161
+ ) -> GPTReviewEvaluation:
162
+ """Measure whether GPT's reviewed labels improved stage-2 decisions."""
163
+ gold_by_key = {
164
+ _standardized_key(row["code"], row["vocabulary"]): (
165
+ row["label"]
166
+ if include_possible or row["label"] != "Possible"
167
+ else "Exclude"
168
+ )
169
+ for _, row in gold_labels.iterrows()
170
+ }
171
+ final_by_key = {
172
+ _standardized_key(item["code"], item["vocabulary"]):
173
+ str(item.get("label", "")).strip().title()
174
+ for item in final_classified
175
+ }
176
+
177
+ eligible = [
178
+ item
179
+ for item in stage2_classified
180
+ if float(item.get("confidence", 0.0)) < confidence_threshold
181
+ ]
182
+ eligible.sort(key=lambda item: float(item.get("confidence", 0.0)))
183
+ selected = eligible[:max_candidates] if max_candidates is not None else eligible
184
+ selected_keys = {
185
+ _standardized_key(item["code"], item["vocabulary"]) for item in selected
186
+ }
187
+
188
+ reviewed = 0
189
+ changed = 0
190
+ corrected = 0
191
+ harmful = 0
192
+ correct_before = 0
193
+ correct_after = 0
194
+ for original in stage2_classified:
195
+ key = _standardized_key(original["code"], original["vocabulary"])
196
+ if key not in selected_keys:
197
+ continue
198
+ if key not in final_by_key:
199
+ continue
200
+ reviewed += 1
201
+ original_label = str(original.get("label", "")).strip().title()
202
+ final_label = final_by_key[key]
203
+ if not include_possible:
204
+ original_label = "Exclude" if original_label == "Possible" else original_label
205
+ final_label = "Exclude" if final_label == "Possible" else final_label
206
+ gold_label = gold_by_key.get(key, "Exclude")
207
+ was_correct = original_label == gold_label
208
+ is_correct = final_label == gold_label
209
+ correct_before += was_correct
210
+ correct_after += is_correct
211
+ if original_label != final_label:
212
+ changed += 1
213
+ corrected += is_correct and not was_correct
214
+ harmful += was_correct and not is_correct
215
+
216
+ return GPTReviewEvaluation(
217
+ candidates_reviewed=reviewed,
218
+ labels_changed=changed,
219
+ corrected_changes=corrected,
220
+ harmful_changes=harmful,
221
+ unchanged=reviewed - changed,
222
+ correct_before=correct_before,
223
+ correct_after=correct_after,
224
+ net_correct_change=correct_after - correct_before,
225
+ reviewed_accuracy_before=round(correct_before / reviewed, 4) if reviewed else 0.0,
226
+ reviewed_accuracy_after=round(correct_after / reviewed, 4) if reviewed else 0.0,
227
+ )
228
+
229
+
230
+ def to_predicted_codelist(classified: list[dict]) -> pd.DataFrame:
231
+ """Render final classifications in the same schema as the ground-truth AESI
232
+ export (``coding_system``, ``code``, ``code_name``, ``concept``,
233
+ ``concept_name``, ``tags``) so predicted and reference codelists are
234
+ directly comparable/exportable side by side.
235
+
236
+ Parameters
237
+ ----------
238
+ classified : list[dict]
239
+ Each dict must contain ``code``, ``vocabulary``, ``description``, and
240
+ ``label`` keys (as produced by :func:`src.llm.classify.llm_classify`).
241
+
242
+ Returns
243
+ -------
244
+ pd.DataFrame
245
+ AESI-compatible columns. When classification annotations are supplied,
246
+ confidence, explanation, manual-review flag, and review reason are
247
+ appended. ``concept`` is blank because the corpus has no UMLS mapping.
248
+ """
249
+ base_columns = ["coding_system", "code", "code_name", "concept", "concept_name", "tags"]
250
+ annotation_columns = ["confidence", "explanation", "manual_review", "review_reason"]
251
+ include_annotations = any(any(column in item for column in annotation_columns) for item in classified)
252
+ columns = base_columns + annotation_columns if include_annotations else base_columns
253
+ mapping_columns = ["decision_origin", "mapping_provenance", "prior_label", "prior_confidence"]
254
+ include_mapping = any(c.get("decision_origin") == "codemapper" for c in classified)
255
+ if include_mapping:
256
+ columns += mapping_columns
257
+ return pd.DataFrame(
258
+ [
259
+ {
260
+ "coding_system": c["vocabulary"],
261
+ "code": c["code"],
262
+ "code_name": c.get("description", ""),
263
+ "concept": "",
264
+ "concept_name": c.get("description", ""),
265
+ "tags": (c.get("label") or "").lower(),
266
+ **(
267
+ {
268
+ "confidence": c.get("confidence", ""),
269
+ "explanation": c.get("explanation", ""),
270
+ "manual_review": bool(c.get("manual_review", False) or c.get("decision_origin") == "codemapper"),
271
+ "review_reason": c.get("review_reason") or (
272
+ "included by CodeMapper mapping; EDF scope not independently assessed"
273
+ if c.get("decision_origin") == "codemapper" else ""
274
+ ),
275
+ }
276
+ if include_annotations
277
+ else {}
278
+ ),
279
+ **({"decision_origin": c.get("decision_origin", "llm"),
280
+ "mapping_provenance": json.dumps(c.get("mapping_provenance", [])),
281
+ "prior_label": c.get("prior_label"),
282
+ "prior_confidence": c.get("prior_confidence")} if include_mapping else {}),
283
+ }
284
+ for c in classified
285
+ ],
286
+ columns=columns,
287
+ )
288
+
289
+
290
+ def evaluate_retrieval(
291
+ retrieved_codes: list[dict],
292
+ gold_labels: pd.DataFrame,
293
+ narrow_only_as_positive: bool = False,
294
+ ) -> RetrievalMetrics:
295
+ """Evaluate concept retrieval as an information-retrieval task.
296
+
297
+ By default, true positives are retrieved codes labeled 'Narrow' or
298
+ 'Possible' in the gold standard. With ``narrow_only_as_positive=True``, only
299
+ 'Narrow' is positive.
300
+ False positives are retrieved codes labeled 'Exclude' in the gold standard,
301
+ or codes absent from it entirely.
302
+ False negatives are positive gold-standard codes that were not retrieved.
303
+
304
+ Parameters
305
+ ----------
306
+ retrieved_codes : list[dict]
307
+ Each dict must contain ``code`` and ``vocabulary`` keys.
308
+ gold_labels : pd.DataFrame
309
+ Reference standard with ``code``, ``vocabulary``, ``label`` columns.
310
+
311
+ Returns
312
+ -------
313
+ RetrievalMetrics
314
+ """
315
+ gold_by_key = {
316
+ _standardized_key(row["code"], row["vocabulary"]): row["label"]
317
+ for _, row in gold_labels.iterrows()
318
+ }
319
+ positive_labels = {"Narrow"} if narrow_only_as_positive else {"Narrow", "Possible"}
320
+ positive_keys = {k for k, label in gold_by_key.items() if label in positive_labels}
321
+
322
+ retrieved_keys = {
323
+ _standardized_key(r["code"], r["vocabulary"]) for r in retrieved_codes
324
+ }
325
+
326
+ tp = len(retrieved_keys & positive_keys)
327
+ fp = len(retrieved_keys - positive_keys)
328
+ fn = len(positive_keys - retrieved_keys)
329
+
330
+ sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0.0
331
+ precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
332
+ f1 = (
333
+ 2 * precision * sensitivity / (precision + sensitivity)
334
+ if (precision + sensitivity) > 0
335
+ else 0.0
336
+ )
337
+
338
+ return RetrievalMetrics(
339
+ true_positives=tp,
340
+ false_positives=fp,
341
+ false_negatives=fn,
342
+ sensitivity=round(sensitivity, 4),
343
+ precision=round(precision, 4),
344
+ f1=round(f1, 4),
345
+ )
346
+
347
+
348
+ def _cohens_kappa(confusion: dict[tuple[str, str], int], labels: tuple[str, ...], n: int) -> float:
349
+ if n == 0:
350
+ return 0.0
351
+ observed_agreement = sum(confusion.get((lbl, lbl), 0) for lbl in labels) / n
352
+
353
+ row_totals = {lbl: sum(confusion.get((lbl, o), 0) for o in labels) for lbl in labels}
354
+ col_totals = {lbl: sum(confusion.get((o, lbl), 0) for o in labels) for lbl in labels}
355
+ expected_agreement = sum((row_totals[lbl] * col_totals[lbl]) / n for lbl in labels) / n
356
+
357
+ if expected_agreement >= 1.0:
358
+ return 1.0
359
+ return (observed_agreement - expected_agreement) / (1 - expected_agreement)
360
+
361
+
362
+ def evaluate_classification(
363
+ classified: list[dict],
364
+ gold_labels: pd.DataFrame,
365
+ include_possible: bool = True,
366
+ ) -> ClassificationMetrics:
367
+ """Evaluate code classification (Narrow/Possible/Exclude) against gold labels.
368
+
369
+ Only codes present in both the classified set and gold standard are compared
370
+ (codes absent from either side cannot be scored for classification agreement).
371
+
372
+ Parameters
373
+ ----------
374
+ classified : list[dict]
375
+ Each dict must contain ``code``, ``vocabulary``, ``label`` keys.
376
+ gold_labels : pd.DataFrame
377
+ Reference standard with ``code``, ``vocabulary``, ``label`` columns.
378
+
379
+ Returns
380
+ -------
381
+ ClassificationMetrics
382
+ """
383
+ labels = LABELS if include_possible else ("Narrow", "Exclude")
384
+ gold_by_key = {
385
+ _standardized_key(row["code"], row["vocabulary"]): (
386
+ row["label"] if include_possible or row["label"] != "Possible" else "Exclude"
387
+ )
388
+ for _, row in gold_labels.iterrows()
389
+ }
390
+
391
+ confusion: dict[tuple[str, str], int] = {}
392
+ correct = 0
393
+ total = 0
394
+ per_label_counts = {lbl: {"tp": 0, "tn": 0, "fp": 0, "fn": 0} for lbl in labels}
395
+
396
+ for item in classified:
397
+ key = _standardized_key(item["code"], item["vocabulary"])
398
+ gold_label = gold_by_key.get(key)
399
+ pred_label = item["label"]
400
+ if not include_possible and pred_label == "Possible":
401
+ pred_label = "Exclude"
402
+ if gold_label is None:
403
+ # A retrieved code absent from the curated positive codelist is an
404
+ # implicit Exclude. Apply this consistently to both correct
405
+ # Exclude predictions and false-positive Narrow/Possible results.
406
+ gold_label = "Exclude"
407
+
408
+ total += 1
409
+ confusion[(gold_label, pred_label)] = confusion.get((gold_label, pred_label), 0) + 1
410
+ if pred_label == gold_label:
411
+ correct += 1
412
+ for label in labels:
413
+ gold_positive = gold_label == label
414
+ predicted_positive = pred_label == label
415
+ if gold_positive and predicted_positive:
416
+ per_label_counts[label]["tp"] += 1
417
+ elif not gold_positive and not predicted_positive:
418
+ per_label_counts[label]["tn"] += 1
419
+ elif predicted_positive:
420
+ per_label_counts[label]["fp"] += 1
421
+ else:
422
+ per_label_counts[label]["fn"] += 1
423
+
424
+ accuracy = correct / total if total > 0 else 0.0
425
+ kappa = _cohens_kappa(confusion, labels, total)
426
+
427
+ per_label: dict[str, dict[str, float]] = {}
428
+ for lbl in labels:
429
+ tp = per_label_counts[lbl]["tp"]
430
+ tn = per_label_counts[lbl]["tn"]
431
+ fp = per_label_counts[lbl]["fp"]
432
+ fn = per_label_counts[lbl]["fn"]
433
+ precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
434
+ recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
435
+ f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
436
+ per_label[lbl] = {
437
+ "tp": tp,
438
+ "tn": tn,
439
+ "fp": fp,
440
+ "fn": fn,
441
+ "precision": round(precision, 4),
442
+ "recall": round(recall, 4),
443
+ "f1": round(f1, 4),
444
+ }
445
+
446
+ macro_labels = [
447
+ label
448
+ for label, counts in per_label_counts.items()
449
+ if counts["tp"] + counts["fn"] > 0
450
+ ] or list(labels)
451
+
452
+ return ClassificationMetrics(
453
+ accuracy=round(accuracy, 4),
454
+ cohens_kappa=round(kappa, 4),
455
+ true_negatives=per_label["Narrow"]["tn"],
456
+ sensitivity=per_label["Narrow"]["recall"],
457
+ precision=per_label["Narrow"]["precision"],
458
+ f1=per_label["Narrow"]["f1"],
459
+ macro_sensitivity=round(sum(per_label[label]["recall"] for label in macro_labels) / len(macro_labels), 4),
460
+ macro_precision=round(sum(per_label[label]["precision"] for label in macro_labels) / len(macro_labels), 4),
461
+ macro_f1=round(sum(per_label[label]["f1"] for label in macro_labels) / len(macro_labels), 4),
462
+ per_label=per_label,
463
+ )
464
+
465
+
466
+ def evaluate(
467
+ classified: list[dict],
468
+ gold_labels: pd.DataFrame,
469
+ narrow_only_as_positive: bool = False,
470
+ include_possible: bool = True,
471
+ ) -> dict:
472
+ """Compute both retrieval and classification metrics for the full pipeline output.
473
+
474
+ Parameters
475
+ ----------
476
+ classified : list[dict]
477
+ Final classified candidates, each with ``code``, ``vocabulary``, ``label`` keys.
478
+ gold_labels : pd.DataFrame
479
+ Reference standard with ``code``, ``vocabulary``, ``label`` columns.
480
+
481
+ Returns
482
+ -------
483
+ dict
484
+ Dictionary with 'retrieval' and 'classification' metric sub-dictionaries.
485
+ """
486
+ retrieval_metrics = evaluate_retrieval(
487
+ classified,
488
+ gold_labels,
489
+ narrow_only_as_positive=narrow_only_as_positive,
490
+ )
491
+ classification_metrics = evaluate_classification(
492
+ classified,
493
+ gold_labels,
494
+ include_possible=include_possible,
495
+ )
496
+ return {
497
+ "retrieval": asdict(retrieval_metrics),
498
+ "classification": {
499
+ "accuracy": classification_metrics.accuracy,
500
+ "cohens_kappa": classification_metrics.cohens_kappa,
501
+ "true_negatives": classification_metrics.true_negatives,
502
+ "sensitivity": classification_metrics.sensitivity,
503
+ "precision": classification_metrics.precision,
504
+ "f1": classification_metrics.f1,
505
+ "macro_sensitivity": classification_metrics.macro_sensitivity,
506
+ "macro_precision": classification_metrics.macro_precision,
507
+ "macro_f1": classification_metrics.macro_f1,
508
+ "per_label": classification_metrics.per_label,
509
+ },
510
+ }
src/llm/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """LLM-based ranking and classification of candidate codes."""
src/llm/classify.py ADDED
@@ -0,0 +1,531 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM classification into Narrow/Exclude with an optional Possible category."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from collections.abc import Callable, Sequence
7
+ import json
8
+ from typing import Protocol
9
+
10
+ from src.data.loaders import EventDefinitionForm
11
+ from src.llm.client import call_jev_decisions, call_llm_json, resolve_provider
12
+ from src.llm.prompts import build_classify_prompt, classify_system_prompt
13
+ from src.utils.logging import get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+ VALID_LABELS = {"Narrow", "Possible", "Exclude"}
18
+
19
+
20
+ class ClassificationCandidate(Protocol):
21
+ """Minimal candidate interface consumed by the classifier."""
22
+
23
+ code: str
24
+ description: str
25
+ vocabulary: str
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class AdaptiveStoppingConfig:
30
+ """Stop classification after Narrow results remain sparse for several batches."""
31
+
32
+ sparse_narrow_threshold: int = 1
33
+ consecutive_sparse_batches: int = 3
34
+ minimum_batches: int = 3
35
+
36
+ def __post_init__(self) -> None:
37
+ if self.sparse_narrow_threshold < 0:
38
+ raise ValueError("sparse_narrow_threshold must be at least 0")
39
+ if self.consecutive_sparse_batches < 1:
40
+ raise ValueError("consecutive_sparse_batches must be at least 1")
41
+ if self.minimum_batches < 1:
42
+ raise ValueError("minimum_batches must be at least 1")
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class ClassificationRunInfo:
47
+ """Summary of how much of the ranked candidate list was classified."""
48
+
49
+ candidates_available: int
50
+ candidates_classified: int
51
+ batches_completed: int
52
+ narrow_counts_by_batch: tuple[int, ...]
53
+ stopped_early: bool
54
+ stop_reason: str
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class ClassifiedCandidate:
59
+ code: str
60
+ description: str
61
+ vocabulary: str
62
+ label: str
63
+ confidence: float
64
+ explanation: str
65
+ relevance_score: float = 0.0
66
+ retrieval_score: float = 0.0
67
+ decision_origin: str = "llm"
68
+ mapping_provenance: list[dict] = field(default_factory=list)
69
+ prior_label: str | None = None
70
+ prior_confidence: float | None = None
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class GPTReviewRunInfo:
75
+ """Summary of final-stage GPT adjudication of uncertain classifications."""
76
+
77
+ confidence_threshold: float
78
+ max_candidates: int | None
79
+ eligible_candidates: int
80
+ candidates_reviewed: int
81
+ labels_changed: int
82
+ narrow_to_exclude: int
83
+ exclude_to_narrow: int
84
+ reasoning_effort: str
85
+
86
+
87
+ def select_gpt_review_candidates(
88
+ classified: Sequence[ClassifiedCandidate],
89
+ *,
90
+ confidence_threshold: float,
91
+ max_candidates: int | None = None,
92
+ ) -> list[ClassifiedCandidate]:
93
+ """Select the lowest-confidence eligible decisions for GPT review."""
94
+ if not 0.0 <= confidence_threshold <= 1.0:
95
+ raise ValueError("confidence_threshold must be between 0 and 1")
96
+ if max_candidates is not None and max_candidates < 1:
97
+ raise ValueError("max_candidates must be at least 1 when provided")
98
+
99
+ eligible = [item for item in classified if item.confidence < confidence_threshold]
100
+ eligible.sort(key=lambda item: item.confidence)
101
+ return eligible[:max_candidates] if max_candidates is not None else eligible
102
+
103
+
104
+ def gpt_review_low_confidence(
105
+ classified: Sequence[ClassifiedCandidate],
106
+ edf: EventDefinitionForm,
107
+ *,
108
+ confidence_threshold: float = 0.7,
109
+ max_candidates: int | None = None,
110
+ model: str | None = None,
111
+ max_retries: int = 3,
112
+ batch_size: int = 10,
113
+ progress_callback: Callable[[int, int], None] | None = None,
114
+ use_possible_category: bool = False,
115
+ reasoning_effort: str = "medium",
116
+ provider: str = "openai",
117
+ ) -> tuple[list[ClassifiedCandidate], GPTReviewRunInfo]:
118
+ """Use GPT to adjudicate only classifications below the confidence cutoff.
119
+
120
+ Results at or above the threshold are preserved exactly. Reviewed results
121
+ replace the corresponding stage-2 labels while retaining their original
122
+ list positions and recording the prior label/confidence in the explanation.
123
+ """
124
+ eligible_count = sum(item.confidence < confidence_threshold for item in classified)
125
+ uncertain = select_gpt_review_candidates(
126
+ classified,
127
+ confidence_threshold=confidence_threshold,
128
+ max_candidates=max_candidates,
129
+ )
130
+ if not uncertain:
131
+ return list(classified), GPTReviewRunInfo(
132
+ confidence_threshold=confidence_threshold,
133
+ max_candidates=max_candidates,
134
+ eligible_candidates=eligible_count,
135
+ candidates_reviewed=0,
136
+ labels_changed=0,
137
+ narrow_to_exclude=0,
138
+ exclude_to_narrow=0,
139
+ reasoning_effort=reasoning_effort,
140
+ )
141
+
142
+ reviewed = llm_classify(
143
+ uncertain,
144
+ edf,
145
+ provider=provider,
146
+ model=model,
147
+ max_retries=max_retries,
148
+ batch_size=batch_size,
149
+ progress_callback=progress_callback,
150
+ use_possible_category=use_possible_category,
151
+ adaptive_stopping=None,
152
+ reasoning_effort=reasoning_effort,
153
+ )
154
+ reviewed_by_key = {(item.code, item.vocabulary): item for item in reviewed}
155
+
156
+ merged: list[ClassifiedCandidate] = []
157
+ labels_changed = 0
158
+ narrow_to_exclude = 0
159
+ exclude_to_narrow = 0
160
+ for original in classified:
161
+ replacement = reviewed_by_key.get((original.code, original.vocabulary))
162
+ if replacement is None:
163
+ merged.append(original)
164
+ continue
165
+
166
+ if replacement.label != original.label:
167
+ labels_changed += 1
168
+ if original.label == "Narrow" and replacement.label == "Exclude":
169
+ narrow_to_exclude += 1
170
+ elif original.label == "Exclude" and replacement.label == "Narrow":
171
+ exclude_to_narrow += 1
172
+ merged.append(
173
+ ClassifiedCandidate(
174
+ code=original.code,
175
+ description=original.description,
176
+ vocabulary=original.vocabulary,
177
+ label=replacement.label,
178
+ confidence=replacement.confidence,
179
+ explanation=(
180
+ "GPT review of low-confidence stage-2 result "
181
+ f"(previous label={original.label}, confidence={original.confidence:.2f}). "
182
+ + replacement.explanation
183
+ ),
184
+ relevance_score=original.relevance_score,
185
+ retrieval_score=original.retrieval_score,
186
+ )
187
+ )
188
+
189
+ return merged, GPTReviewRunInfo(
190
+ confidence_threshold=confidence_threshold,
191
+ max_candidates=max_candidates,
192
+ eligible_candidates=eligible_count,
193
+ candidates_reviewed=len(uncertain),
194
+ labels_changed=labels_changed,
195
+ narrow_to_exclude=narrow_to_exclude,
196
+ exclude_to_narrow=exclude_to_narrow,
197
+ reasoning_effort=reasoning_effort,
198
+ )
199
+
200
+
201
+ def llm_classify(
202
+ candidates: Sequence[ClassificationCandidate],
203
+ edf: EventDefinitionForm,
204
+ provider: str = "auto",
205
+ model: str | None = None,
206
+ max_retries: int = 3,
207
+ batch_size: int = 10,
208
+ progress_callback: Callable[[int, int], None] | None = None,
209
+ use_possible_category: bool = False,
210
+ adaptive_stopping: AdaptiveStoppingConfig | None = None,
211
+ run_info_callback: Callable[[ClassificationRunInfo], None] | None = None,
212
+ reasoning_effort: str | None = None,
213
+ thinking_budget: int | None = None,
214
+ ) -> list[ClassifiedCandidate]:
215
+ """Classify candidates as Narrow/Exclude, optionally allowing Possible.
216
+
217
+ Parameters
218
+ ----------
219
+ candidates : sequence
220
+ Similarity-ranked hybrid or LLM-ranked candidates.
221
+ edf : EventDefinitionForm
222
+ Structured phenotype definition providing semantic context.
223
+ provider : str
224
+ "auto", "anthropic", or "openai".
225
+ model : str, optional
226
+ Model identifier. Defaults to a provider-specific default.
227
+ max_retries : int
228
+ Number of LLM call attempts before failing.
229
+ batch_size : int
230
+ Maximum number of candidates to include in a single LLM request.
231
+ progress_callback : callable, optional
232
+ Callback invoked after each completed batch as ``callback(completed, total)``.
233
+ use_possible_category : bool
234
+ Enable three-way Narrow/Possible/Exclude labeling. Defaults to False.
235
+ adaptive_stopping : AdaptiveStoppingConfig, optional
236
+ Stop after Narrow results remain at or below the configured threshold
237
+ for the configured number of consecutive batches.
238
+ run_info_callback : callable, optional
239
+ Invoked once with the completed/stopped run summary.
240
+ reasoning_effort : str, optional
241
+ OpenAI reasoning effort, such as ``"none"`` or ``"medium"``.
242
+
243
+ Returns
244
+ -------
245
+ list[ClassifiedCandidate]
246
+ """
247
+ if not candidates:
248
+ if run_info_callback is not None:
249
+ run_info_callback(
250
+ ClassificationRunInfo(0, 0, 0, (), False, "No candidates to classify.")
251
+ )
252
+ return []
253
+
254
+ if batch_size < 1:
255
+ raise ValueError("batch_size must be at least 1")
256
+
257
+ resolved_provider = resolve_provider(provider)
258
+ if resolved_provider in {"jev", "openai_decisions", "laya", "jpt"}:
259
+ from src.llm.decisions import DEFAULT_DECISION_MODELS
260
+ return _jev_classify(
261
+ candidates,
262
+ edf,
263
+ model=model or DEFAULT_DECISION_MODELS[resolved_provider],
264
+ max_retries=max_retries,
265
+ batch_size=batch_size,
266
+ progress_callback=progress_callback,
267
+ use_possible_category=use_possible_category,
268
+ adaptive_stopping=adaptive_stopping,
269
+ run_info_callback=run_info_callback,
270
+ provider=resolved_provider,
271
+ )
272
+
273
+ results: list[ClassifiedCandidate] = []
274
+ narrow_counts: list[int] = []
275
+ sparse_streak = 0
276
+
277
+ total = len(candidates)
278
+ for start in range(0, total, batch_size):
279
+ batch = candidates[start : start + batch_size]
280
+ batch_result_start = len(results)
281
+ lookup = {(c.code, c.vocabulary): c for c in batch}
282
+ payload = [
283
+ {"code": c.code, "vocabulary": c.vocabulary, "description": c.description}
284
+ for c in batch
285
+ ]
286
+ prompt = build_classify_prompt(edf, payload, use_possible_category=use_possible_category)
287
+ response = call_llm_json(
288
+ system_prompt=classify_system_prompt(use_possible_category),
289
+ user_prompt=prompt,
290
+ provider=resolved_provider,
291
+ model=model,
292
+ max_retries=max_retries,
293
+ reasoning_effort=reasoning_effort,
294
+ **({'thinking_budget':thinking_budget} if thinking_budget is not None else {}),
295
+ )
296
+
297
+ classifications_raw = response.get("classifications", [])
298
+ batch_seen: set[tuple[str, str]] = set()
299
+ for item in classifications_raw:
300
+ key = (item.get("code", ""), item.get("vocabulary", ""))
301
+ source = lookup.get(key)
302
+ if source is None or key in batch_seen:
303
+ continue
304
+ fallback_label = "Possible" if use_possible_category else "Exclude"
305
+ valid_labels = VALID_LABELS if use_possible_category else {"Narrow", "Exclude"}
306
+ label = item.get("label", fallback_label)
307
+ if label not in valid_labels:
308
+ logger.warning(
309
+ "LLM returned invalid label %r for code %s; defaulting to %r",
310
+ label,
311
+ key,
312
+ fallback_label,
313
+ )
314
+ label = fallback_label
315
+ batch_seen.add(key)
316
+ results.append(
317
+ ClassifiedCandidate(
318
+ code=source.code,
319
+ description=source.description,
320
+ vocabulary=source.vocabulary,
321
+ label=label,
322
+ confidence=float(item.get("confidence", 0.0)),
323
+ explanation=str(item.get("explanation", "")),
324
+ relevance_score=float(getattr(source, "relevance_score", 0.0)),
325
+ retrieval_score=float(
326
+ getattr(source, "retrieval_score", getattr(source, "score", 0.0))
327
+ ),
328
+ )
329
+ )
330
+
331
+ # Keep every candidate in the result. A missing response receives the
332
+ # least-committal enabled label and zero confidence so review selection
333
+ # can annotate it without filtering or altering any other result.
334
+ for c in batch:
335
+ key = (c.code, c.vocabulary)
336
+ if key not in batch_seen:
337
+ results.append(
338
+ ClassifiedCandidate(
339
+ code=c.code,
340
+ description=c.description,
341
+ vocabulary=c.vocabulary,
342
+ label="Possible" if use_possible_category else "Exclude",
343
+ confidence=0.0,
344
+ explanation="No classification returned by LLM; routed for manual review.",
345
+ relevance_score=float(getattr(c, "relevance_score", 0.0)),
346
+ retrieval_score=float(
347
+ getattr(c, "retrieval_score", getattr(c, "score", 0.0))
348
+ ),
349
+ )
350
+ )
351
+
352
+ if progress_callback is not None:
353
+ progress_callback(min(start + len(batch), total), total)
354
+
355
+ narrow_count = sum(
356
+ item.label == "Narrow" for item in results[batch_result_start:]
357
+ )
358
+ narrow_counts.append(narrow_count)
359
+ sparse_streak = (
360
+ sparse_streak + 1
361
+ if adaptive_stopping is not None
362
+ and narrow_count <= adaptive_stopping.sparse_narrow_threshold
363
+ else 0
364
+ )
365
+ if _adaptive_stop_reached(adaptive_stopping, len(narrow_counts), sparse_streak):
366
+ break
367
+
368
+ _report_run_info(
369
+ run_info_callback,
370
+ total=total,
371
+ results=results,
372
+ narrow_counts=narrow_counts,
373
+ adaptive_stopping=adaptive_stopping,
374
+ )
375
+ return results
376
+
377
+
378
+ def _jev_classify(
379
+ candidates: Sequence[ClassificationCandidate],
380
+ edf: EventDefinitionForm,
381
+ model: str,
382
+ max_retries: int,
383
+ batch_size: int,
384
+ progress_callback: Callable[[int, int], None] | None,
385
+ use_possible_category: bool,
386
+ adaptive_stopping: AdaptiveStoppingConfig | None,
387
+ run_info_callback: Callable[[ClassificationRunInfo], None] | None,
388
+ provider: str = "jev",
389
+ ) -> list[ClassifiedCandidate]:
390
+ """Classify candidates with Jev typed choice questions."""
391
+ effective_batch_size = min(batch_size, 20)
392
+ labels = {
393
+ "Narrow": "The code is sufficiently specific to the phenotype and meets the Narrow criteria.",
394
+ "Exclude": "The code is irrelevant, insufficiently specific, historical, or explicitly excluded.",
395
+ }
396
+ if use_possible_category:
397
+ labels = {
398
+ "Narrow": labels["Narrow"],
399
+ "Possible": "The code is related but not sufficiently specific in every context.",
400
+ "Exclude": labels["Exclude"],
401
+ }
402
+
403
+ results: list[ClassifiedCandidate] = []
404
+ narrow_counts: list[int] = []
405
+ sparse_streak = 0
406
+ total = len(candidates)
407
+ state = "Phenotype definition:\n" + edf.to_prompt_context(
408
+ include_possible=use_possible_category
409
+ )
410
+ for start in range(0, total, effective_batch_size):
411
+ batch = candidates[start : start + effective_batch_size]
412
+ batch_result_start = len(results)
413
+ questions = {
414
+ f"candidate_{offset}": {
415
+ "type": "choice",
416
+ "instructions": (
417
+ "Classify this clinical terminology candidate for the phenotype: "
418
+ f"code={candidate.code}; vocabulary={candidate.vocabulary}; "
419
+ f"description={candidate.description}"
420
+ ),
421
+ "criteria": labels,
422
+ }
423
+ for offset, candidate in enumerate(batch)
424
+ }
425
+ if provider == "jev":
426
+ decision_call = call_jev_decisions
427
+ else:
428
+ from src.llm.decisions import call_decisions
429
+ decision_call = lambda **kwargs: call_decisions(provider=provider, **kwargs)
430
+ response = decision_call(
431
+ state=state,
432
+ questions=questions,
433
+ model=model,
434
+ max_retries=max_retries,
435
+ )
436
+ answers = response.get("answers", {})
437
+ for offset, candidate in enumerate(batch):
438
+ answer = answers.get(f"candidate_{offset}", {})
439
+ probabilities = answer.get("probabilities", {})
440
+ fallback_label = "Possible" if use_possible_category else "Exclude"
441
+ label = answer.get("choice", fallback_label)
442
+ if label not in labels:
443
+ label = fallback_label
444
+ confidence = answer.get("confidence")
445
+ if confidence is None:
446
+ confidence = max((float(value) for value in probabilities.values()), default=0.0)
447
+ results.append(
448
+ ClassifiedCandidate(
449
+ code=candidate.code,
450
+ description=candidate.description,
451
+ vocabulary=candidate.vocabulary,
452
+ label=label,
453
+ confidence=float(confidence),
454
+ explanation=f"{provider} choice probabilities: " + json.dumps(probabilities, sort_keys=True)
455
+ + "; confidence source: " + answer.get("confidence_source", "provider" if answer.get("confidence") is not None else "max_probability"),
456
+ relevance_score=float(getattr(candidate, "relevance_score", 0.0)),
457
+ retrieval_score=float(
458
+ getattr(
459
+ candidate,
460
+ "retrieval_score",
461
+ getattr(candidate, "score", 0.0),
462
+ )
463
+ ),
464
+ )
465
+ )
466
+ if progress_callback is not None:
467
+ progress_callback(min(start + len(batch), total), total)
468
+ narrow_count = sum(
469
+ item.label == "Narrow" for item in results[batch_result_start:]
470
+ )
471
+ narrow_counts.append(narrow_count)
472
+ sparse_streak = (
473
+ sparse_streak + 1
474
+ if adaptive_stopping is not None
475
+ and narrow_count <= adaptive_stopping.sparse_narrow_threshold
476
+ else 0
477
+ )
478
+ if _adaptive_stop_reached(adaptive_stopping, len(narrow_counts), sparse_streak):
479
+ break
480
+
481
+ _report_run_info(
482
+ run_info_callback,
483
+ total=total,
484
+ results=results,
485
+ narrow_counts=narrow_counts,
486
+ adaptive_stopping=adaptive_stopping,
487
+ )
488
+ return results
489
+
490
+
491
+ def _adaptive_stop_reached(
492
+ config: AdaptiveStoppingConfig | None,
493
+ batches_completed: int,
494
+ sparse_streak: int,
495
+ ) -> bool:
496
+ return bool(
497
+ config is not None
498
+ and batches_completed >= config.minimum_batches
499
+ and sparse_streak >= config.consecutive_sparse_batches
500
+ )
501
+
502
+
503
+ def _report_run_info(
504
+ callback: Callable[[ClassificationRunInfo], None] | None,
505
+ *,
506
+ total: int,
507
+ results: list[ClassifiedCandidate],
508
+ narrow_counts: list[int],
509
+ adaptive_stopping: AdaptiveStoppingConfig | None,
510
+ ) -> None:
511
+ if callback is None:
512
+ return
513
+ stopped_early = len(results) < total
514
+ if stopped_early and adaptive_stopping is not None:
515
+ reason = (
516
+ f"Stopped after {adaptive_stopping.consecutive_sparse_batches} consecutive "
517
+ f"batches with at most {adaptive_stopping.sparse_narrow_threshold} Narrow "
518
+ "result(s) per batch."
519
+ )
520
+ else:
521
+ reason = "All available candidates were classified."
522
+ callback(
523
+ ClassificationRunInfo(
524
+ candidates_available=total,
525
+ candidates_classified=len(results),
526
+ batches_completed=len(narrow_counts),
527
+ narrow_counts_by_batch=tuple(narrow_counts),
528
+ stopped_early=stopped_early,
529
+ stop_reason=reason,
530
+ )
531
+ )
src/llm/client.py ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared model clients for generative JSON calls and Jev typed decisions.
2
+
3
+ Automatic provider selection preserves the original Anthropic-then-OpenAI
4
+ precedence; Google is selected explicitly by experiments that require it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import time
13
+ import uuid
14
+ from typing import Any
15
+ from urllib.error import HTTPError, URLError
16
+ from urllib.request import Request, urlopen
17
+
18
+ from src.utils.logging import get_logger
19
+
20
+ logger = get_logger(__name__)
21
+
22
+ _JSON_BLOCK_RE = re.compile(r"\{.*\}", re.DOTALL)
23
+
24
+ DEFAULT_ANTHROPIC_MODEL = "claude-haiku-4-8"
25
+ DEFAULT_OPENAI_MODEL = "gpt-6-luna"
26
+ DEFAULT_GOOGLE_MODEL = "gemini-3-flash-preview"
27
+ DEFAULT_JEV_MODEL = "jev-latest"
28
+ DEFAULT_JEV_API_URL = "https://api.typesafe.ai/v1/systemone"
29
+
30
+ _PLACEHOLDER_KEY_VALUES = {"", "your-api-key-here"}
31
+
32
+
33
+ class LLMCallError(RuntimeError):
34
+ """Raised when the LLM call fails or returns unparsable output after retries."""
35
+
36
+
37
+ def _has_real_key(env_var: str) -> bool:
38
+ """Return True if *env_var* is set to a non-empty, non-placeholder value.
39
+
40
+ Guards against `.env` files copied from `.env.example` where a key is
41
+ present but still holds the literal placeholder text.
42
+ """
43
+ value = os.environ.get(env_var, "").strip()
44
+ return value.lower() not in _PLACEHOLDER_KEY_VALUES
45
+
46
+
47
+ def resolve_provider(provider: str = "auto") -> str:
48
+ """Resolve the LLM provider to use, based on explicit choice or available API keys.
49
+
50
+ Parameters
51
+ ----------
52
+ provider : str
53
+ One of ``"auto"``, ``"anthropic"``, ``"openai"``, ``"google"``, or ``"jev"``.
54
+
55
+ Returns
56
+ -------
57
+ str
58
+ The resolved provider name.
59
+ """
60
+ provider = (provider or "auto").lower()
61
+ if provider in {"anthropic", "openai", "google", "jev", "openai_decisions", "laya", "jpt", "mlx"}:
62
+ return provider
63
+ if provider != "auto":
64
+ raise LLMCallError(
65
+ f"Unknown LLM provider: {provider!r}. "
66
+ "Use 'auto', 'anthropic', 'openai', 'google', or 'jev'."
67
+ )
68
+
69
+ if _has_real_key("ANTHROPIC_API_KEY"):
70
+ return "anthropic"
71
+ if _has_real_key("OPENAI_API_KEY"):
72
+ return "openai"
73
+ if _has_real_key("JEV_API_KEY"):
74
+ return "jev"
75
+ raise LLMCallError(
76
+ "No LLM API key found. Set ANTHROPIC_API_KEY and/or OPENAI_API_KEY in your .env file."
77
+ )
78
+
79
+
80
+ def default_model_for(provider: str) -> str:
81
+ """Return the default model identifier for the given provider."""
82
+ if provider == "anthropic":
83
+ return DEFAULT_ANTHROPIC_MODEL
84
+ if provider == "google":
85
+ return DEFAULT_GOOGLE_MODEL
86
+ if provider == "jev":
87
+ return DEFAULT_JEV_MODEL
88
+ if provider in {"laya", "jpt", "mlx"}:
89
+ return {"laya": "multilingual", "jpt": "kirp/jpt-4b",
90
+ "mlx": "mlx-community/Qwen3.5-9B-8bit"}[provider]
91
+ return DEFAULT_OPENAI_MODEL
92
+
93
+
94
+ def _get_anthropic_client():
95
+ """Lazily construct the Anthropic client so importing this module does not
96
+ require an API key to be set (e.g. for unit tests that mock calls)."""
97
+ import anthropic
98
+
99
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
100
+ if not api_key:
101
+ raise LLMCallError("ANTHROPIC_API_KEY environment variable is not set.")
102
+ return anthropic.Anthropic(api_key=api_key)
103
+
104
+
105
+ def _get_openai_client():
106
+ """Lazily construct the OpenAI client so importing this module does not
107
+ require an API key to be set (e.g. for unit tests that mock calls)."""
108
+ import openai
109
+
110
+ api_key = os.environ.get("OPENAI_API_KEY")
111
+ if not api_key:
112
+ raise LLMCallError("OPENAI_API_KEY environment variable is not set.")
113
+ return openai.OpenAI(api_key=api_key)
114
+
115
+
116
+ def _get_google_client():
117
+ """Lazily construct the Google Gen AI client."""
118
+ from google import genai
119
+
120
+ api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
121
+ if not api_key:
122
+ raise LLMCallError("GOOGLE_API_KEY or GEMINI_API_KEY environment variable is not set.")
123
+ return genai.Client(api_key=api_key)
124
+
125
+
126
+ def extract_json(text: str) -> dict[str, Any]:
127
+ """Extract and parse the first JSON object found in *text*.
128
+
129
+ Handles cases where the model wraps JSON in markdown code fences or adds
130
+ surrounding commentary despite instructions not to.
131
+ """
132
+ text = text.strip()
133
+ try:
134
+ return json.loads(text)
135
+ except json.JSONDecodeError:
136
+ pass
137
+
138
+ match = _JSON_BLOCK_RE.search(text)
139
+ if match:
140
+ return json.loads(match.group(0))
141
+
142
+ raise ValueError(f"Could not extract JSON from LLM response: {text[:200]!r}")
143
+
144
+
145
+ def _call_anthropic(system_prompt: str, user_prompt: str, model: str, max_tokens: int) -> str:
146
+ client = _get_anthropic_client()
147
+ response = client.messages.create(
148
+ model=model,
149
+ max_tokens=max_tokens,
150
+ system=system_prompt,
151
+ messages=[{"role": "user", "content": user_prompt}],
152
+ )
153
+ return "".join(
154
+ block.text for block in response.content if getattr(block, "type", None) == "text"
155
+ )
156
+
157
+
158
+ _REASONING_MODEL_PREFIXES = ("gpt-5", "gpt-6", "o1", "o3", "o4")
159
+
160
+
161
+ def _is_reasoning_model(model: str) -> bool:
162
+ """Whether *model* is an OpenAI reasoning model (GPT-5/6 and o-series).
163
+
164
+ Reasoning models spend part of their completion token budget on hidden
165
+ reasoning tokens before producing visible output, and support a
166
+ ``reasoning_effort`` parameter to control that trade-off. Non-reasoning
167
+ models (e.g. gpt-4o) don't accept that parameter, so it must only be sent
168
+ for models in this family.
169
+ """
170
+ return model.lower().startswith(_REASONING_MODEL_PREFIXES)
171
+
172
+
173
+ def _call_openai(
174
+ system_prompt: str,
175
+ user_prompt: str,
176
+ model: str,
177
+ max_tokens: int,
178
+ reasoning_effort: str | None = None,
179
+ ) -> str:
180
+ client = _get_openai_client()
181
+ kwargs: dict[str, Any] = {}
182
+ if reasoning_effort and _is_reasoning_model(model):
183
+ kwargs["reasoning_effort"] = reasoning_effort
184
+
185
+ response = client.chat.completions.create(
186
+ model=model,
187
+ max_completion_tokens=max_tokens,
188
+ response_format={"type": "json_object"},
189
+ messages=[
190
+ {"role": "system", "content": system_prompt},
191
+ {"role": "user", "content": user_prompt},
192
+ ],
193
+ **kwargs,
194
+ )
195
+ choice = response.choices[0]
196
+ content = choice.message.content or ""
197
+ if not content.strip():
198
+ # Most commonly caused by a reasoning model spending its entire
199
+ # max_completion_tokens budget on hidden reasoning, leaving nothing
200
+ # for the visible answer (finish_reason == "length" in that case).
201
+ raise ValueError(
202
+ f"Empty response content from model '{model}' "
203
+ f"(finish_reason={choice.finish_reason!r}, usage={response.usage!r}). "
204
+ "Consider increasing max_tokens or lowering reasoning_effort."
205
+ )
206
+ return content
207
+
208
+
209
+ def _call_google(system_prompt: str, user_prompt: str, model: str, max_tokens: int) -> str:
210
+ from google.genai import types
211
+
212
+ client = _get_google_client()
213
+ response = client.models.generate_content(
214
+ model=model,
215
+ contents=user_prompt,
216
+ config=types.GenerateContentConfig(
217
+ system_instruction=system_prompt,
218
+ response_mime_type="application/json",
219
+ max_output_tokens=max_tokens,
220
+ ),
221
+ )
222
+ text = response.text or ""
223
+ if not text.strip():
224
+ raise ValueError(f"Empty response content from Google model '{model}'.")
225
+ return text
226
+
227
+
228
+ def call_jev_decisions(
229
+ state: str,
230
+ questions: dict[str, dict[str, Any]],
231
+ model: str = DEFAULT_JEV_MODEL,
232
+ max_retries: int = 3,
233
+ ) -> dict[str, Any]:
234
+ """Submit typed questions to Jev and return its structured response.
235
+
236
+ Jev is not a generative chat model, so it does not use
237
+ :func:`call_llm_json`. A request shares one phenotype state across up to 20
238
+ candidate-classification questions and receives declared labels plus
239
+ probability distributions.
240
+ """
241
+ api_key = (
242
+ os.environ.get("TYPESAFE_API_KEY", "").strip()
243
+ or os.environ.get("JEV_API_KEY", "").strip()
244
+ )
245
+ if not api_key:
246
+ raise LLMCallError(
247
+ "TYPESAFE_API_KEY or JEV_API_KEY environment variable is not set."
248
+ )
249
+ if not questions or len(questions) > 20:
250
+ raise ValueError("Jev requests must contain between 1 and 20 questions.")
251
+
252
+ url = os.environ.get("JEV_API_URL", DEFAULT_JEV_API_URL).strip() or DEFAULT_JEV_API_URL
253
+ payload = json.dumps(
254
+ {"model": model, "state": state, "questions": questions},
255
+ separators=(",", ":"),
256
+ ).encode("utf-8")
257
+ idempotency_key = str(uuid.uuid5(uuid.NAMESPACE_URL, payload.decode("utf-8")))
258
+ retryable_statuses = {429, 502, 503, 504, 529}
259
+ last_error: Exception | None = None
260
+
261
+ for attempt in range(1, max_retries + 1):
262
+ request = Request(
263
+ url,
264
+ data=payload,
265
+ method="POST",
266
+ headers={
267
+ "Authorization": f"Bearer {api_key}",
268
+ "Content-Type": "application/json",
269
+ "Accept": "application/json",
270
+ "Idempotency-Key": idempotency_key,
271
+ },
272
+ )
273
+ try:
274
+ with urlopen(request, timeout=60) as response:
275
+ result = json.loads(response.read().decode("utf-8"))
276
+ if not isinstance(result.get("answers"), dict):
277
+ raise ValueError("Jev response is missing an 'answers' object.")
278
+ return result
279
+ except HTTPError as exc:
280
+ try:
281
+ error_body = exc.read().decode("utf-8", errors="replace")[:500]
282
+ except Exception: # noqa: BLE001 - preserve the original HTTP status
283
+ error_body = ""
284
+ last_error = RuntimeError(
285
+ f"Jev HTTP {exc.code} from {url}"
286
+ + (f": {error_body}" if error_body else "")
287
+ )
288
+ if exc.code not in retryable_statuses or attempt >= max_retries:
289
+ break
290
+ retry_after = exc.headers.get("Retry-After") if exc.headers else None
291
+ delay = min(float(retry_after), 30.0) if retry_after else min(2 ** (attempt - 1), 8)
292
+ time.sleep(delay)
293
+ except (URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
294
+ last_error = exc
295
+ if attempt < max_retries:
296
+ time.sleep(min(2 ** (attempt - 1), 8))
297
+
298
+ raise LLMCallError(
299
+ f"Jev decision call failed after {max_retries} attempts: {last_error}"
300
+ ) from last_error
301
+
302
+
303
+ def call_llm_json(
304
+ system_prompt: str,
305
+ user_prompt: str,
306
+ provider: str = "auto",
307
+ model: str | None = None,
308
+ max_retries: int = 3,
309
+ max_tokens: int = 8192,
310
+ reasoning_effort: str | None = None,
311
+ thinking_budget: int | None = None,
312
+ ) -> dict[str, Any]:
313
+ """Call the configured LLM provider and parse a JSON object from the response,
314
+ retrying on transient failures or malformed JSON output.
315
+
316
+ Parameters
317
+ ----------
318
+ system_prompt : str
319
+ System prompt establishing the LLM's role and output format.
320
+ user_prompt : str
321
+ The task-specific user prompt.
322
+ provider : str
323
+ ``"auto"``, ``"anthropic"``, ``"openai"``, or ``"google"``. ``"auto"`` selects
324
+ Anthropic if ``ANTHROPIC_API_KEY`` is set, otherwise OpenAI.
325
+ model : str, optional
326
+ Model identifier. Defaults to a sensible default for the resolved provider.
327
+ max_retries : int
328
+ Number of attempts before raising ``LLMCallError``.
329
+ max_tokens : int
330
+ Maximum completion tokens to generate. For OpenAI reasoning models
331
+ (gpt-5/o1/o3/o4), this budget is shared with hidden reasoning tokens,
332
+ so it should be generous enough to leave room for the visible answer.
333
+ reasoning_effort : str, optional
334
+ Passed as ``reasoning_effort`` to OpenAI reasoning models only (e.g.
335
+ ``"low"``/``"medium"``/``"high"``); ignored for Anthropic and for
336
+ non-reasoning OpenAI models.
337
+
338
+ Returns
339
+ -------
340
+ dict
341
+ Parsed JSON response body.
342
+ """
343
+ resolved_provider = resolve_provider(provider)
344
+ if resolved_provider in {"jev", "openai_decisions", "laya", "jpt"}:
345
+ raise LLMCallError(
346
+ "Jev uses typed decisions rather than generative JSON. "
347
+ "Call call_jev_decisions(), or use llm_classify(provider='jev')."
348
+ )
349
+ resolved_model = model or default_model_for(resolved_provider)
350
+
351
+ last_error: Exception | None = None
352
+ for attempt in range(1, max_retries + 1):
353
+ try:
354
+ if resolved_provider == "anthropic":
355
+ text = _call_anthropic(system_prompt, user_prompt, resolved_model, max_tokens)
356
+ elif resolved_provider == "openai":
357
+ text = _call_openai(
358
+ system_prompt, user_prompt, resolved_model, max_tokens, reasoning_effort
359
+ )
360
+ elif resolved_provider == "mlx":
361
+ from src.llm.local import call_mlx_json
362
+ return call_mlx_json(system_prompt, user_prompt, resolved_model, max_tokens, reasoning_effort,
363
+ thinking_budget if thinking_budget is not None else 1024)
364
+ else:
365
+ text = _call_google(system_prompt, user_prompt, resolved_model, max_tokens)
366
+ return extract_json(text)
367
+ except Exception as exc: # noqa: BLE001 - broad by design, retried below
368
+ last_error = exc
369
+ logger.warning(
370
+ "LLM call attempt %d/%d (provider=%s) failed: %s",
371
+ attempt, max_retries, resolved_provider, exc,
372
+ )
373
+
374
+ raise LLMCallError(
375
+ f"LLM call failed after {max_retries} attempts (provider={resolved_provider}): {last_error}"
376
+ ) from last_error
src/llm/decisions.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adapters for typed decision providers using the shared Jev question schema.
2
+
3
+ Keep provider confidence separate from option probabilities. A fallback to the
4
+ maximum option probability is recorded explicitly; it cannot be below 0.5 for
5
+ a normalized binary choice. No domain calibration is inferred from that value.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from functools import lru_cache
10
+ import json
11
+ import os
12
+ from urllib.request import Request, urlopen
13
+ from typing import Any
14
+
15
+ from src.llm.client import LLMCallError, _get_openai_client, call_jev_decisions
16
+
17
+ DECISION_PROVIDERS = frozenset({'jev', 'openai_decisions', 'laya', 'jpt'})
18
+ DEFAULT_DECISION_MODELS = {
19
+ 'jev': 'jev-latest', 'openai_decisions': 'gpt-6-luna',
20
+ 'laya': 'multilingual', 'jpt': 'kirp/jpt-4b',
21
+ }
22
+
23
+
24
+ def normalize_answers(response, names):
25
+ """Validate and normalize typed answers without inventing missing decisions."""
26
+ raw = response.get('answers')
27
+ if isinstance(raw, list):
28
+ keys = [answer.get('name') for answer in raw]
29
+ if len(set(keys)) != len(keys):
30
+ raise LLMCallError('Decision provider returned duplicate answer names.')
31
+ raw = {answer['name']: answer for answer in raw}
32
+ if not isinstance(raw, dict) or any(name not in raw for name in names):
33
+ raise LLMCallError('Decision provider omitted one or more requested answers.')
34
+ answers = {}
35
+ for name in names:
36
+ answer = dict(raw[name])
37
+ if answer.get('type') == 'refusal' or not isinstance(answer.get('choice'), str):
38
+ raise LLMCallError(f'Decision provider did not answer question {name}.')
39
+ probabilities = answer.get('probabilities', {})
40
+ if isinstance(probabilities, list):
41
+ probabilities = {item['value']: item['probability'] for item in probabilities}
42
+ probabilities = {key: float(value) for key, value in probabilities.items()}
43
+ if not probabilities or any(not 0 <= p <= 1 for p in probabilities.values()):
44
+ raise LLMCallError(f'Invalid choice probabilities for question {name}.')
45
+ if abs(sum(probabilities.values())-1) > .02 or answer['choice'] not in probabilities:
46
+ raise LLMCallError(f'Invalid choice distribution for question {name}.')
47
+ if isinstance(names, dict) and set(probabilities) != set(names[name]['criteria']):
48
+ raise LLMCallError(f'Unexpected choices for question {name}.')
49
+ confidence = answer.get('confidence')
50
+ answer['confidence_source'] = 'provider' if confidence is not None else 'max_probability'
51
+ confidence = float(confidence) if confidence is not None else max(probabilities.values())
52
+ if not 0 <= confidence <= 1:
53
+ raise LLMCallError(f'Invalid confidence for question {name}.')
54
+ answers[name] = {**answer, 'confidence': confidence, 'probabilities': probabilities}
55
+ return {**response, 'answers': answers}
56
+
57
+
58
+ def call_openai_decisions(state, questions, model='gpt-6-luna', max_retries=3):
59
+ """POST /v1/decisions, including SDKs predating the typed resource."""
60
+ client = _get_openai_client().with_options(max_retries=max_retries-1)
61
+ payload = {'model': model, 'input': state, 'questions': [
62
+ {'name': name, 'type': 'choice', 'instructions': question['instructions'],
63
+ 'choices': [{'value': label, 'description': description}
64
+ for label, description in question['criteria'].items()]}
65
+ for name, question in questions.items()
66
+ ]}
67
+ response = client.post('/decisions', cast_to=dict[str, Any], body=payload)
68
+ return normalize_answers(response, questions)
69
+
70
+
71
+ @lru_cache(maxsize=1)
72
+ def _laya_router():
73
+ try:
74
+ from laya import Router
75
+ except ImportError as exc:
76
+ raise LLMCallError('Install requirements-decision-models.txt to use Laya.') from exc
77
+ from src.llm.model_paths import prepared_model_path
78
+ path=prepared_model_path('convaiinnovations/laya')
79
+ return Router(models={'multilingual':(path,'multilingual')})
80
+
81
+
82
+ def call_decisions(state, questions, provider='jev', model=None, max_retries=3):
83
+ """Dispatch an existing typed choice request to the selected backend."""
84
+ if provider not in DECISION_PROVIDERS:
85
+ raise ValueError(f'Unsupported decision provider: {provider}')
86
+ model = model or DEFAULT_DECISION_MODELS[provider]
87
+ if provider == 'jev':
88
+ return call_jev_decisions(state, questions, model=model, max_retries=max_retries)
89
+ if provider == 'openai_decisions':
90
+ return call_openai_decisions(state, questions, model=model, max_retries=max_retries)
91
+ if provider == 'laya':
92
+ # Explicit multilingual checkpoint avoids the English router's shorter
93
+ # input limit. The model/card documents 8192-token inference support.
94
+ result = _laya_router().predict(state, questions, model=model, max_len=8192)
95
+ return normalize_answers(result, questions)
96
+ if not os.environ.get('JPT_API_URL'):
97
+ from src.llm.jpt_runtime import predict_jpt
98
+ return normalize_answers(predict_jpt(state, questions, model), questions)
99
+ url = os.environ.get('JPT_API_URL', 'http://127.0.0.1:8080/v1/systemone')
100
+ payload = json.dumps({'state': state, 'questions': questions}).encode()
101
+ headers = {'Content-Type': 'application/json'}
102
+ token = os.environ.get('JPT_API_KEY')
103
+ if token:
104
+ headers['Authorization'] = f'Bearer {token}'
105
+ request = Request(url, data=payload, headers=headers, method='POST')
106
+ try:
107
+ with urlopen(request, timeout=300) as response:
108
+ result = json.load(response)
109
+ except Exception as exc:
110
+ raise LLMCallError('JPT decision endpoint failed. Start llm2jev before running.') from exc
111
+ return normalize_answers(result, questions)
src/llm/jpt_runtime.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """JPT-4B through its published llm2jev scoring interface on a local GPU."""
2
+ from functools import lru_cache
3
+ import platform
4
+
5
+
6
+ class AppleJPT:
7
+ """Published llm2jev prompts and probabilities with MLX model execution."""
8
+ def __init__(self, model_name):
9
+ from mlx_vlm import load
10
+ from src.llm.model_paths import prepared_model_path
11
+ print(f'[JPT] Loading {model_name} with MLX (original BF16 weights)',flush=True)
12
+ self.model,self.processor=load(prepared_model_path(model_name))
13
+ self.prefix_tokens=[];self.prefix_cache=None
14
+
15
+ def warm(self,prefix,images):
16
+ if images:raise ValueError('The study uses text-only questions.')
17
+ import mlx.core as mx
18
+ # Leave a boundary margin so tokenization cannot merge the final prefix
19
+ # token with the question. Every scoring request verifies this prefix.
20
+ tokens=self.processor.tokenizer.encode(prefix,add_special_tokens=False)[:-2]
21
+ if tokens==self.prefix_tokens:return
22
+ self.prefix_tokens=tokens;lm=self.model.language_model
23
+ self.prefix_cache=lm.make_cache()
24
+ if tokens:
25
+ lm(mx.array([tokens]),cache=self.prefix_cache,skip_logits=True)
26
+ mx.eval([c.state for c in self.prefix_cache])
27
+
28
+ def score(self,text,images,ids):
29
+ if images:raise ValueError('The study uses text-only questions.')
30
+ rows,count=self._score_texts([text],ids)
31
+ return rows[0],count
32
+
33
+ def _score_texts(self,texts,ids):
34
+ import mlx.core as mx
35
+ tokens=[self.processor.tokenizer.encode(text,add_special_tokens=False) for text in texts]
36
+ lm=self.model.language_model
37
+ cache=lm.make_cache();suffixes=tokens
38
+ if self.prefix_tokens and all(t[:len(self.prefix_tokens)]==self.prefix_tokens for t in tokens):
39
+ from mlx.utils import tree_map
40
+ for fresh,saved in zip(cache,self.prefix_cache):
41
+ snapshot=tree_map(lambda x:mx.repeat(x,len(texts),axis=0) if isinstance(x,mx.array) else x,
42
+ saved.prefix_cache_snapshot())
43
+ fresh.prefix_cache_restore(snapshot)
44
+ suffixes=[t[len(self.prefix_tokens):] for t in tokens]
45
+ lengths=[len(s) for s in suffixes];width=max(lengths)
46
+ # Future padding cannot affect earlier positions in this causal model.
47
+ # Select each row's final real token before projecting to vocabulary.
48
+ padded=[s+[0]*(width-len(s)) for s in suffixes]
49
+ out=lm(mx.array(padded),cache=cache,skip_logits=True,return_hidden=True)
50
+ hidden=out.hidden_states[-1][mx.arange(len(texts)),mx.array(lengths)-1][:,None,:]
51
+ logits=lm.speculative_logits_from_hidden(hidden)[:,0].astype(mx.float32)
52
+ values=(logits-mx.logsumexp(logits,axis=-1,keepdims=True))[:,mx.array(ids)].tolist()
53
+ return values,sum(map(len,tokens))
54
+
55
+ def score_many(self,texts,ids):
56
+ rows=[];count=0
57
+ for i in range(0,len(texts),4):
58
+ batch,n=self._score_texts(texts[i:i+4],ids);rows.extend(batch);count+=n
59
+ print(f'[JPT] {min(i+4,len(texts))}/{len(texts)} questions scored',flush=True)
60
+ return rows,count
61
+
62
+
63
+ @lru_cache(maxsize=1)
64
+ def load_jpt(model_name):
65
+ from llm2jev.engine import LLM2Jev
66
+ if platform.system()=='Darwin' and platform.machine()=='arm64':
67
+ backend=AppleJPT(model_name)
68
+ return LLM2Jev(backend.processor,backend,temperature=1.036,workers=1)
69
+ import torch
70
+ from llm2jev.backends import HF
71
+
72
+ class LastTokenHF(HF):
73
+ def __init__(self, model, device=None, **kwargs):
74
+ # Loading safetensors directly onto MPS starts concurrent transfer
75
+ # threads. Load on CPU and transfer serially to avoid a native
76
+ # allocator/Metal crash in that loading path on this Mac.
77
+ super().__init__(model, device='cpu' if device=='mps' else device, **kwargs)
78
+ if device=='mps':self.model.to(device)
79
+ # Restrict the LM projection to the last position. The upstream HF
80
+ # reference projects every EDF token, wasting substantial GPU memory.
81
+ def score(self, text, images, ids):
82
+ if images:
83
+ raise ValueError('This experiment uses text-only JPT inputs.')
84
+ rows, count = self.score_many([text], ids)
85
+ return rows[0], count
86
+
87
+ def score_many(self, texts, ids):
88
+ self.tok.padding_side = 'left'
89
+ rows = []; count = 0
90
+ for start in range(0, len(texts), 4):
91
+ inputs = self.tok(texts[start:start+4],padding=True,return_tensors='pt',
92
+ add_special_tokens=False)
93
+ count += int(inputs['attention_mask'].sum())
94
+ inputs = {k:v.to(self.model.device) for k,v in inputs.items()}
95
+ with self.lock, torch.inference_mode():
96
+ logits = self.model(**inputs,logits_to_keep=1).logits[:,-1].float().log_softmax(-1)
97
+ rows.extend(logits[:,ids].cpu().tolist())
98
+ return rows,count
99
+
100
+ device = 'mps' if torch.backends.mps.is_available() else 'cuda' if torch.cuda.is_available() else 'cpu'
101
+ print(f'[JPT] Loading {model_name} on {device}',flush=True)
102
+ from src.llm.model_paths import prepared_model_path
103
+ backend = LastTokenHF(prepared_model_path(model_name),device=device,dtype='bfloat16')
104
+ return LLM2Jev(backend.processor or backend.tok,backend,temperature=1.036,workers=1)
105
+
106
+
107
+ def predict_jpt(state, questions, model_name):
108
+ answers, usage = load_jpt(model_name).run(state, questions)
109
+ return {'answers':answers,'model':model_name,'usage':usage,
110
+ 'runtime':'llm2jev/mlx' if platform.system()=='Darwin' and platform.machine()=='arm64' else 'llm2jev/hf',
111
+ 'temperature':1.036}
src/llm/local.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local MLX reviewer for Apple silicon; no API calls or model substitution."""
2
+ from functools import lru_cache
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ from time import perf_counter
9
+ from uuid import uuid4
10
+
11
+
12
+ def visible_response(text):
13
+ """Exclude reasoning, including a thinking block preopened by the template."""
14
+ text = re.sub(r'<think>.*?</think>', '', text, flags=re.S)
15
+ if '</think>' in text:
16
+ text = text.split('</think>',1)[1]
17
+ if '<think>' in text:
18
+ text = text.split('<think>',1)[0]
19
+ return text.strip()
20
+
21
+
22
+ def first_response(text):
23
+ """Read a complete JSON envelope, allowing subsequent prose or repeated output.
24
+
25
+ Never repair incomplete JSON or extract a nested classification from a broken
26
+ envelope. Generation and classification settings remain unchanged.
27
+ """
28
+ text=visible_response(text)
29
+ start=text.find('{')
30
+ if start<0:raise ValueError('No complete JSON envelope in local response')
31
+ result,_=json.JSONDecoder().raw_decode(text[start:])
32
+ if not isinstance(result,dict):raise ValueError('Local response must be a JSON object')
33
+ if not isinstance(result.get('classifications'),list):
34
+ raise ValueError('Local response must contain a classifications array')
35
+ return result
36
+
37
+
38
+ def candidate_parts(user_prompt):
39
+ marker='Classify each of the following candidate codes:\n'
40
+ if marker not in user_prompt:return None
41
+ prefix,body=user_prompt.split(marker,1)
42
+ lines,suffix=body.split('\n\nReturn a JSON object',1)
43
+ candidates=lines.splitlines()
44
+ if not candidates or any(not line.startswith('- code: ') for line in candidates):return None
45
+ return prefix+marker,candidates,'\n\nReturn a JSON object'+suffix
46
+
47
+
48
+ def validate_candidates(response,user_prompt):
49
+ parts=candidate_parts(user_prompt)
50
+ if parts is None:return
51
+ expected=[]
52
+ for line in parts[1]:
53
+ code,vocabulary,_=line.removeprefix('- code: ').split(' | ',2)
54
+ expected.append((code,vocabulary.removeprefix('vocabulary: ')))
55
+ actual=[(r.get('code'),r.get('vocabulary')) for r in response['classifications']]
56
+ if len(actual)!=len(expected) or set(actual)!=set(expected):
57
+ raise ValueError('Local answer does not cover every requested code exactly once')
58
+
59
+
60
+ @lru_cache(maxsize=1)
61
+ def load_mlx_reviewer(model_name):
62
+ from mlx_vlm import load
63
+ from src.llm.model_paths import prepared_model_path
64
+ return load(prepared_model_path(model_name))
65
+
66
+
67
+ def call_mlx_json(system_prompt, user_prompt, model_name, max_tokens=8192, reasoning_effort='medium',
68
+ thinking_budget=1024):
69
+ from mlx_vlm import stream_generate
70
+ model, processor = load_mlx_reviewer(model_name)
71
+ messages = [{'role':'system','content':system_prompt},{'role':'user','content':user_prompt}]
72
+ prompt = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True,
73
+ enable_thinking=reasoning_effort not in (None,'none'))
74
+ folder=Path(os.environ.get('LOCAL_RESPONSE_DIR',Path(__file__).resolve().parents[2]/'results/local_responses'))
75
+ folder.mkdir(parents=True,exist_ok=True)
76
+ prompt_hash=hashlib.sha256(prompt.encode()).hexdigest()
77
+ failure=folder/'failed'/f'{prompt_hash}.json'
78
+ replay=json.loads(failure.read_text()) if failure.exists() else None
79
+ if replay and (replay.get('model'),replay.get('max_tokens'),replay.get('thinking_budget'))!=(model_name,max_tokens,thinking_budget):
80
+ replay=None
81
+ if replay:
82
+ print('[Qwen reviewer] Reusing audited failed output; subdividing its candidate batch.',flush=True)
83
+ chunks = []; started = perf_counter(); reported = 0; generated_tokens=0
84
+ finish_reason='model_stop_or_limit'
85
+ outputs=() if replay else stream_generate(model, processor, prompt=prompt, max_tokens=max_tokens,
86
+ temperature=.6, top_p=.95, top_k=20, seed=42,
87
+ enable_thinking=reasoning_effort not in (None,'none'),
88
+ thinking_budget=thinking_budget)
89
+ for output in outputs:
90
+ chunks.append(output.text)
91
+ generated_tokens=output.generation_tokens
92
+ if output.generation_tokens >= reported+256:
93
+ reported = output.generation_tokens
94
+ print(f'[Qwen reviewer] {reported} tokens generated, {perf_counter()-started:.1f}s',flush=True)
95
+ current=''.join(chunks)
96
+ if '</think>' in current or reasoning_effort in (None,'none'):
97
+ try:
98
+ parsed=first_response(current)
99
+ validate_candidates(parsed,user_prompt)
100
+ except json.JSONDecodeError as exc:
101
+ # An unescaped control character inside a JSON string cannot
102
+ # become valid by generating more tokens. Other incomplete
103
+ # prefixes remain eligible to finish normally.
104
+ if exc.msg.startswith('Invalid control character'):
105
+ finish_reason='invalid_json_syntax'
106
+ if hasattr(outputs,'close'):outputs.close()
107
+ break
108
+ except (ValueError,TypeError):pass
109
+ else:
110
+ finish_reason='complete_json'
111
+ if hasattr(outputs,'close'):outputs.close()
112
+ break
113
+ text = replay['raw_response'] if replay else ''.join(chunks)
114
+ path=folder/(uuid4().hex+'.json')
115
+ audit={'model':model_name,'raw_response':text,'generation_tokens':generated_tokens,
116
+ 'max_tokens':max_tokens,'thinking_budget':thinking_budget,'seconds':perf_counter()-started,
117
+ 'prompt_sha256':prompt_hash,'replayed_failed_response':bool(replay),'finish_reason':finish_reason,
118
+ 'implementation_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
119
+ try:
120
+ result=first_response(text)
121
+ validate_candidates(result,user_prompt)
122
+ audit['status']='parsed'
123
+ return result
124
+ except (ValueError,TypeError) as exc:
125
+ audit.update(status='parse_error',error=str(exc))
126
+ failure.parent.mkdir(exist_ok=True)
127
+ failure.write_text(json.dumps(audit,ensure_ascii=False,indent=2))
128
+ print(f'[Qwen reviewer] Raw failed response saved: {path}',flush=True)
129
+ parts=candidate_parts(user_prompt)
130
+ if parts and len(parts[1])>1:
131
+ prefix,lines,suffix=parts;middle=len(lines)//2
132
+ audit['recovery']='subdivide_batch'
133
+ # Persist the failure before recovery, including if recovery is interrupted.
134
+ path.write_text(json.dumps(audit,ensure_ascii=False,indent=2))
135
+ print(f'[Qwen reviewer] Retrying failed batch as {middle} + {len(lines)-middle} candidates.',flush=True)
136
+ recovered=[]
137
+ for subset in (lines[:middle],lines[middle:]):
138
+ answer=call_mlx_json(system_prompt,prefix+'\n'.join(subset)+suffix,model_name,
139
+ max_tokens,reasoning_effort,thinking_budget)
140
+ recovered.extend(answer['classifications'])
141
+ result={'classifications':recovered}
142
+ validate_candidates(result,user_prompt)
143
+ audit['status']='recovered_by_subdivision'
144
+ return result
145
+ raise
146
+ finally:
147
+ path.write_text(json.dumps(audit,ensure_ascii=False,indent=2))
src/llm/model_paths.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Use prepared local snapshots when available, retaining exact model revisions."""
2
+ import json
3
+ import gc
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ def prepared_model_path(repo):
9
+ root=Path(__file__).resolve().parents[2]
10
+ receipt=root/'.model-cache/model_revisions.json'
11
+ if receipt.exists():
12
+ info=json.loads(receipt.read_text()).get(repo)
13
+ if info and Path(info['path']).is_dir():return info['path']
14
+ return repo
15
+
16
+
17
+ def release_local_models():
18
+ """Release cached weights before another local model or phase is loaded."""
19
+ for module_name,function in (('src.llm.decisions','_laya_router'),
20
+ ('src.llm.jpt_runtime','load_jpt'),
21
+ ('src.llm.local','load_mlx_reviewer')):
22
+ module=sys.modules.get(module_name)
23
+ if module is not None:getattr(module,function).cache_clear()
24
+ gc.collect()
25
+ torch=sys.modules.get('torch')
26
+ if torch is not None and torch.backends.mps.is_available():torch.mps.empty_cache()
27
+ mx=sys.modules.get('mlx.core')
28
+ if mx is not None:mx.clear_cache()
src/llm/prompts.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reusable prompt templates for LLM-assisted ranking and classification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from src.data.loaders import EventDefinitionForm
6
+
7
+ RANK_SYSTEM_PROMPT = (
8
+ "You are a clinical terminology expert supporting semantic harmonization for "
9
+ "real-world evidence studies. You review candidate medical codes retrieved for "
10
+ "a phenotype and re-rank them by clinical relevance, using the structured "
11
+ "phenotype definition (Event Definition Form) provided by the user. "
12
+ "You must respond only with valid JSON, no additional commentary."
13
+ )
14
+
15
+ CLASSIFY_SYSTEM_PROMPT = (
16
+ "You are a clinical terminology expert supporting semantic harmonization for "
17
+ "real-world evidence studies. You review candidate medical codes for a phenotype "
18
+ "and classify each one as 'Narrow' (highly specific to the phenotype), "
19
+ "'Possible' (related but insufficiently specific, or specific in some but not "
20
+ "all contexts), or 'Exclude' (not relevant, or explicitly an exclusion "
21
+ "criterion). Base your decision on the structured phenotype definition (Event "
22
+ "Definition Form) provided by the user. For every code, provide a brief "
23
+ "explanation followed by the label and a confidence score between 0 and 1. "
24
+ "You must respond only with valid JSON, no additional commentary."
25
+ )
26
+
27
+
28
+ def classify_system_prompt(use_possible_category: bool = False) -> str:
29
+ """Return classification instructions for binary or three-way labeling."""
30
+ if use_possible_category:
31
+ return CLASSIFY_SYSTEM_PROMPT
32
+ return (
33
+ "You are a clinical terminology expert supporting semantic harmonization "
34
+ "for real-world evidence studies. Classify every candidate as 'Narrow' "
35
+ "when it is sufficiently specific to the phenotype, or 'Exclude' otherwise. "
36
+ "Do not return a 'Possible' label. Any EDF rule describing a Possible, "
37
+ "ambiguous, or insufficiently specific case must be treated as Exclude in "
38
+ "this binary mode. Base decisions on the supplied Event Definition Form. "
39
+ "For every code, request the output fields in this order: code, vocabulary, "
40
+ "explanation, label, confidence. Confidence must be from 0 to 1. Respond "
41
+ "only with valid JSON."
42
+ )
43
+
44
+
45
+ def build_rank_prompt(edf: EventDefinitionForm, candidates: list[dict]) -> str:
46
+ """Build the user prompt for LLM-based re-ranking of candidate codes."""
47
+ candidate_lines = "\n".join(
48
+ f"- code: {c['code']} | vocabulary: {c['vocabulary']} | description: {c['description']}"
49
+ for c in candidates
50
+ )
51
+ return (
52
+ f"Phenotype definition:\n{edf.to_prompt_context()}\n\n"
53
+ f"Candidate codes to rank by clinical relevance to this phenotype "
54
+ f"(most relevant first):\n{candidate_lines}\n\n"
55
+ "Return a JSON object with a single key 'ranked_codes', containing a list of "
56
+ "objects with keys 'code', 'vocabulary', and 'relevance_score' (float 0-1, "
57
+ "higher = more relevant), sorted descending by relevance_score."
58
+ )
59
+
60
+
61
+ def build_classify_prompt(
62
+ edf: EventDefinitionForm,
63
+ candidates: list[dict],
64
+ use_possible_category: bool = False,
65
+ ) -> str:
66
+ """Build the user prompt for LLM-based classification of candidate codes."""
67
+ candidate_lines = "\n".join(
68
+ f"- code: {c['code']} | vocabulary: {c['vocabulary']} | description: {c['description']}"
69
+ for c in candidates
70
+ )
71
+ labels = "'Narrow', 'Possible', or 'Exclude'" if use_possible_category else "'Narrow' or 'Exclude'"
72
+ return (
73
+ f"Phenotype definition:\n{edf.to_prompt_context(include_possible=use_possible_category)}\n\n"
74
+ f"Classify each of the following candidate codes:\n{candidate_lines}\n\n"
75
+ "Return a JSON object with a single key 'classifications', containing a "
76
+ "list of objects using this requested key order: 'code', 'vocabulary', "
77
+ "'explanation' (brief string), 'label' "
78
+ f"(one of {labels}), and 'confidence' (float 0-1)."
79
+ )
src/llm/rank.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM-based re-ranking of candidate codes using full EDF semantic context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from src.data.loaders import EventDefinitionForm
8
+ from src.llm.client import call_llm_json
9
+ from src.llm.prompts import RANK_SYSTEM_PROMPT, build_rank_prompt
10
+ from src.retrieval.hybrid import HybridCandidate
11
+ from src.utils.logging import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class RankedCandidate:
18
+ rank: int
19
+ code: str
20
+ description: str
21
+ vocabulary: str
22
+ relevance_score: float
23
+ retrieval_score: float
24
+
25
+ def __lt__(self, other: object) -> bool:
26
+ if not isinstance(other, RankedCandidate):
27
+ return NotImplemented
28
+ return self.relevance_score > other.relevance_score
29
+
30
+
31
+ def skip_rank(candidates: list[HybridCandidate]) -> list[RankedCandidate]:
32
+ """Wrap hybrid retrieval candidates into ``RankedCandidate`` objects without
33
+ calling the LLM, preserving their original hybrid retrieval order.
34
+
35
+ The LLM ranking step (:func:`llm_rank`) is currently disabled in the
36
+ pipeline/UI (an extra LLM call that duplicates work also done by
37
+ classification) but is kept available for future use. This helper lets
38
+ downstream code (``llm_classify``) keep consuming ``RankedCandidate``
39
+ objects uniformly regardless of whether ranking actually ran.
40
+ """
41
+ results = [
42
+ RankedCandidate(
43
+ rank=c.rank,
44
+ code=c.code,
45
+ description=c.description,
46
+ vocabulary=c.vocabulary,
47
+ relevance_score=0.0,
48
+ retrieval_score=c.score,
49
+ )
50
+ for c in candidates
51
+ ]
52
+ return results
53
+
54
+
55
+ def llm_rank(
56
+ candidates: list[HybridCandidate],
57
+ edf: EventDefinitionForm,
58
+ provider: str = "auto",
59
+ model: str | None = None,
60
+ max_retries: int = 3,
61
+ ) -> list[RankedCandidate]:
62
+ """Re-rank hybrid retrieval candidates using an LLM given the full EDF context.
63
+
64
+ Parameters
65
+ ----------
66
+ candidates : list[HybridCandidate]
67
+ Candidates produced by :func:`src.retrieval.hybrid.hybrid_retrieval`.
68
+ edf : EventDefinitionForm
69
+ Structured phenotype definition providing semantic context.
70
+ provider : str
71
+ "auto", "anthropic", or "openai".
72
+ model : str, optional
73
+ Model identifier. Defaults to a provider-specific default.
74
+ max_retries : int
75
+ Number of LLM call attempts before failing.
76
+
77
+ Returns
78
+ -------
79
+ list[RankedCandidate]
80
+ Candidates ordered by descending LLM-assigned relevance score.
81
+ """
82
+ if not candidates:
83
+ return []
84
+
85
+ lookup = {(c.code, c.vocabulary): c for c in candidates}
86
+ payload = [
87
+ {"code": c.code, "vocabulary": c.vocabulary, "description": c.description}
88
+ for c in candidates
89
+ ]
90
+ prompt = build_rank_prompt(edf, payload)
91
+ response = call_llm_json(
92
+ system_prompt=RANK_SYSTEM_PROMPT,
93
+ user_prompt=prompt,
94
+ provider=provider,
95
+ model=model,
96
+ max_retries=max_retries,
97
+ )
98
+
99
+ ranked_raw = response.get("ranked_codes", [])
100
+ results: list[RankedCandidate] = []
101
+ seen: set[tuple[str, str]] = set()
102
+
103
+ for item in ranked_raw:
104
+ key = (item.get("code", ""), item.get("vocabulary", ""))
105
+ source = lookup.get(key)
106
+ if source is None:
107
+ continue
108
+ seen.add(key)
109
+ results.append(
110
+ RankedCandidate(
111
+ rank=0,
112
+ code=source.code,
113
+ description=source.description,
114
+ vocabulary=source.vocabulary,
115
+ relevance_score=float(item.get("relevance_score", 0.0)),
116
+ retrieval_score=source.score,
117
+ )
118
+ )
119
+
120
+ # Append any candidates the LLM omitted, ordered by original retrieval score,
121
+ # so no candidate is silently dropped from downstream classification.
122
+ for c in candidates:
123
+ key = (c.code, c.vocabulary)
124
+ if key not in seen:
125
+ results.append(
126
+ RankedCandidate(
127
+ rank=0,
128
+ code=c.code,
129
+ description=c.description,
130
+ vocabulary=c.vocabulary,
131
+ relevance_score=0.0,
132
+ retrieval_score=c.score,
133
+ )
134
+ )
135
+
136
+ results.sort(key=lambda r: (-r.relevance_score, -r.retrieval_score))
137
+ for r, item in enumerate(results):
138
+ item.rank = r + 1
139
+ return results
src/mapping/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Optional terminology mapping stages."""
src/mapping/codemapper.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-round cross-vocabulary CUI expansion, with caching and paced sessions."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import replace
5
+ try:
6
+ import fcntl
7
+ except ImportError: # Keep the disabled optional stage importable on Windows.
8
+ fcntl = None
9
+ import hashlib
10
+ from http.cookiejar import CookieJar
11
+ import json
12
+ import logging
13
+ import os
14
+ from pathlib import Path
15
+ import time
16
+ from urllib.error import HTTPError, URLError
17
+ from urllib.parse import urlencode
18
+ from urllib.request import HTTPCookieProcessor, Request, build_opener
19
+
20
+ import pandas as pd
21
+
22
+ from src.data.preprocessing import standardize_code
23
+ from src.llm.classify import ClassifiedCandidate
24
+ from src.utils.config import CodeMapperConfig
25
+
26
+
27
+ class CodeMapperError(RuntimeError):
28
+ """Sanitized failure: never include credentials, cookies, or server bodies."""
29
+
30
+
31
+ def normalize_code_versions(response,vocabulary):
32
+ """Select the pinned release from the current all-version API response."""
33
+ _,separator,version=vocabulary.partition('@')
34
+ if not separator:raise CodeMapperError('CodeMapper requires a pinned vocabulary version')
35
+ result={}
36
+ for code,value in response.items():
37
+ if isinstance(value,list):
38
+ matching=[item for item in value if isinstance(item,dict) and str(item.get('version'))==version]
39
+ if len(matching)>1:raise CodeMapperError('Ambiguous CodeMapper code version response')
40
+ if matching:result[code]=matching[0]
41
+ elif isinstance(value,dict):
42
+ result[code]=value # Older cached/API responses were already release-specific.
43
+ else:raise CodeMapperError('Unexpected CodeMapper code version response')
44
+ return result
45
+
46
+
47
+ def normalize_concept_versions(response,targets):
48
+ """Project series-level codes to requested releases, never to latest by default."""
49
+ versions={target.partition('@')[0]:target.partition('@')[2] for target in targets}
50
+ result=[]
51
+ for concept in response:
52
+ sources=[]
53
+ for source in concept.get('sourceConcepts',[]):
54
+ if 'codingSystem' in source:
55
+ if source['codingSystem'] in targets:sources.append(source)
56
+ continue
57
+ series=source.get('vocId');version=versions.get(series)
58
+ if version is None:continue
59
+ matching=[item for item in source.get('versions',[]) if str(item.get('version'))==version]
60
+ if matching:
61
+ sources.append({**source,'codingSystem':series+'@'+version,
62
+ 'preferredTerm':matching[0].get('term',source.get('preferredTerm'))})
63
+ result.append({**concept,'sourceConcepts':sources})
64
+ return result
65
+
66
+
67
+ class CodeMapperClient:
68
+ def __init__(self, config: CodeMapperConfig):
69
+ self.config = config
70
+ self.cache_dir = Path(config.cache_dir)
71
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
72
+ self.opener = build_opener(HTTPCookieProcessor(CookieJar()))
73
+ self.logged_in = False
74
+ self.calls: list[dict] = []
75
+ self.cache_hits = 0
76
+
77
+ def _pace(self):
78
+ if fcntl is None:
79
+ raise CodeMapperError("Shared CodeMapper pacing currently requires macOS or Linux")
80
+ # Share the limiter across client instances/processes using this cache.
81
+ with (self.cache_dir / "rate.lock").open("a+") as handle:
82
+ fcntl.flock(handle, fcntl.LOCK_EX)
83
+ handle.seek(0)
84
+ previous = float(handle.read() or 0)
85
+ delay = previous + 60 / self.config.requests_per_minute - time.time()
86
+ if delay > 0:
87
+ time.sleep(delay)
88
+ handle.seek(0)
89
+ handle.truncate()
90
+ handle.write(str(time.time()))
91
+ handle.flush()
92
+
93
+ def _request(self, path, method="POST", form=None):
94
+ fields = [(key, item) for key, value in (form or {}).items()
95
+ for item in (value if isinstance(value, list) else [value])]
96
+ request = Request(self.config.base_url.rstrip("/") + path,
97
+ data=urlencode(fields).encode() if form is not None else None,
98
+ method=method, headers={"Accept": "application/json"})
99
+ for attempt in range(self.config.max_retries):
100
+ self._pace()
101
+ logging.getLogger(__name__).info('CodeMapper %s %s (attempt %s)',method,path,attempt+1)
102
+ started = time.perf_counter()
103
+ try:
104
+ with self.opener.open(request, timeout=self.config.timeout_seconds) as response:
105
+ raw = response.read()
106
+ status = response.status
107
+ self.calls.append({"path": path, "status": status,
108
+ "seconds": round(time.perf_counter() - started, 4),
109
+ "bytes": len(raw)})
110
+ return json.loads(raw) if raw else None
111
+ except HTTPError as exc:
112
+ self.calls.append({"path": path, "status": exc.code,
113
+ "seconds": round(time.perf_counter() - started, 4)})
114
+ if exc.code not in {429, 500, 502, 503, 504}:
115
+ raise CodeMapperError(f"CodeMapper {path}: HTTP {exc.code}") from None
116
+ try:
117
+ retry_after = float(exc.headers.get("Retry-After", 0))
118
+ except ValueError:
119
+ retry_after = 0
120
+ if attempt + 1 < self.config.max_retries:
121
+ time.sleep(max(retry_after, 2 ** attempt))
122
+ except (URLError, TimeoutError, OSError):
123
+ if attempt + 1 < self.config.max_retries:
124
+ time.sleep(2 ** attempt)
125
+ except (ValueError, UnicodeDecodeError):
126
+ raise CodeMapperError(f"CodeMapper {path}: invalid JSON") from None
127
+ raise CodeMapperError(f"CodeMapper {path}: failed after retries")
128
+
129
+ def _login(self):
130
+ if self.logged_in:
131
+ return
132
+ username, password = os.getenv("CODEMAPPER_USERNAME"), os.getenv("CODEMAPPER_PASSWORD")
133
+ if not username or not password:
134
+ raise CodeMapperError("Set CODEMAPPER_USERNAME and CODEMAPPER_PASSWORD in .env")
135
+ response = self._request("/session", form={"username": username, "password": password})
136
+ if not isinstance(response, dict) or response.get("success") is not True:
137
+ raise CodeMapperError("CodeMapper login unsuccessful")
138
+ self.logged_in = True
139
+
140
+ def close(self):
141
+ if self.logged_in:
142
+ try:
143
+ self._request("/session", "DELETE")
144
+ finally:
145
+ self.logged_in = False
146
+
147
+ def _cache_path(self, kind, identity):
148
+ key = hashlib.sha256(json.dumps([self.config.base_url, kind, identity],
149
+ sort_keys=True).encode()).hexdigest()
150
+ return self.cache_dir / f"{key}.json"
151
+
152
+ def _load(self, kind, identity):
153
+ path = self._cache_path(kind, identity)
154
+ if path.exists():
155
+ self.cache_hits += 1
156
+ return json.loads(path.read_text())
157
+ return None
158
+
159
+ def _save(self, kind, identity, value):
160
+ path = self._cache_path(kind, identity)
161
+ temporary = path.with_suffix(f".{os.getpid()}.tmp")
162
+ temporary.write_text(json.dumps(value))
163
+ temporary.replace(path)
164
+
165
+ def cuis_for_codes(self, vocabulary, codes):
166
+ result, missing = {}, []
167
+ for code in sorted(set(codes)):
168
+ cached = self._load("code_cuis", [vocabulary, code])
169
+ if cached is None:
170
+ missing.append(code)
171
+ elif cached:
172
+ result[code] = cached
173
+ for start in range(0, len(missing), self.config.batch_size):
174
+ batch = missing[start:start + self.config.batch_size]
175
+ self._login()
176
+ response = self._request("/legacy/code-mapper/cuis-for-codes",
177
+ form={"vocId": vocabulary.partition('@')[0], "codes": batch})
178
+ if not isinstance(response, dict):
179
+ raise CodeMapperError("Unexpected code-to-CUI response")
180
+ response=normalize_code_versions(response,vocabulary)
181
+ for code in batch:
182
+ value = response.get(code, {})
183
+ self._save("code_cuis", [vocabulary, code], value)
184
+ if value:
185
+ result[code] = value
186
+ return result
187
+
188
+ def concepts(self, cuis, targets):
189
+ result, missing = [], []
190
+ targets = sorted(set(targets))
191
+ for cui in sorted(set(cuis)):
192
+ cached = self._load("concept", [targets, cui])
193
+ if cached is None:
194
+ missing.append(cui)
195
+ else:
196
+ result.extend(cached)
197
+ for start in range(0, len(missing), self.config.batch_size):
198
+ batch = missing[start:start + self.config.batch_size]
199
+ self._login()
200
+ response = self._request("/legacy/code-mapper/umls-concepts",
201
+ form={"cuis": batch, "vocIds": sorted({target.partition('@')[0] for target in targets})})
202
+ if not isinstance(response, list):
203
+ raise CodeMapperError("Unexpected concept response")
204
+ response=normalize_concept_versions(response,targets)
205
+ for cui in batch:
206
+ values = [c for c in response if c.get("cui") == cui]
207
+ self._save("concept", [targets, cui], values)
208
+ result.extend(values)
209
+ return result
210
+
211
+
212
+ def expand_narrow_matches(classified: list[ClassifiedCandidate], corpus: pd.DataFrame,
213
+ vocabularies: list[str], config: CodeMapperConfig,
214
+ *, client=None):
215
+ """Promote/add direct cross-vocabulary matches from a frozen Narrow seed set.
216
+
217
+ Only codes in the loaded/preprocessed corpus are eligible. Same-vocabulary
218
+ links and descendants are excluded. No new additions become seeds this round.
219
+ Mapping confidence is 0 (unassessed), not an invented model probability.
220
+ """
221
+ seeds = [c for c in classified if c.label == "Narrow" and c.vocabulary in vocabularies]
222
+ versions = {v: f"{v}@{config.vocabulary_versions[v]}" for v in vocabularies}
223
+ reverse = {version: voc for voc, version in versions.items()}
224
+ by_cui = {}
225
+ client = client or CodeMapperClient(config)
226
+ started = time.perf_counter()
227
+ seed_counts, unmapped = {}, []
228
+ try:
229
+ for voc in vocabularies:
230
+ group = [c for c in seeds if c.vocabulary == voc]
231
+ if not group:
232
+ continue
233
+ seed_counts[voc] = len(group)
234
+ response = client.cuis_for_codes(versions[voc], [c.code for c in group])
235
+ for seed in group:
236
+ cuis = response.get(seed.code, {}).get("cuis", [])
237
+ if not cuis:
238
+ unmapped.append({"code": seed.code, "vocabulary": voc})
239
+ for cui in cuis:
240
+ by_cui.setdefault(cui, []).append(seed)
241
+ concepts = client.concepts(by_cui, list(versions.values())) if by_cui else []
242
+ finally:
243
+ client.close()
244
+ available = {(standardize_code(c, v), v): (c, d)
245
+ for c, v, d in corpus[["code", "vocabulary", "description"]].itertuples(index=False, name=None)}
246
+ links, unavailable = {}, {}
247
+ for concept in concepts:
248
+ for source in concept.get("sourceConcepts", []):
249
+ voc = reverse.get(source["codingSystem"])
250
+ if voc is None:
251
+ continue
252
+ key = (standardize_code(source["id"], voc), voc)
253
+ provenance = [{"seed_code": s.code, "seed_vocabulary": s.vocabulary,
254
+ "seed_label": s.label, "cui": concept["cui"],
255
+ "source_version": versions[s.vocabulary],
256
+ "target_version": source["codingSystem"]}
257
+ for s in by_cui.get(concept["cui"], []) if s.vocabulary != voc]
258
+ if not provenance:
259
+ continue
260
+ target = links if key in available else unavailable
261
+ target.setdefault(key, []).extend(provenance)
262
+ merged = list(classified)
263
+ positions = {(standardize_code(c.code, c.vocabulary), c.vocabulary): i
264
+ for i, c in enumerate(merged)}
265
+ added, promoted, already_narrow = [], [], 0
266
+ for key, provenance in sorted(links.items()):
267
+ index = positions.get(key)
268
+ previous = merged[index] if index is not None else None
269
+ if previous is not None and previous.label == "Narrow":
270
+ already_narrow += 1
271
+ continue
272
+ code, description = available[key]
273
+ item = ClassifiedCandidate(code, description, key[1], "Narrow", 0.0,
274
+ "Included by direct cross-vocabulary CodeMapper CUI mapping; "
275
+ "not independently classified for EDF scope.",
276
+ decision_origin="codemapper", mapping_provenance=provenance,
277
+ prior_label=previous.label if previous else None,
278
+ prior_confidence=previous.confidence if previous else None)
279
+ if previous is None:
280
+ merged.append(item)
281
+ added.append({"code": code, "vocabulary": key[1]})
282
+ else:
283
+ merged[index] = replace(item, retrieval_score=previous.retrieval_score)
284
+ promoted.append({"code": code, "vocabulary": key[1], "prior_label": previous.label})
285
+ info = {"enabled": True, "rounds": 1, "descendants_used": False,
286
+ "direct_links": [{"code": available[key][0], "vocabulary": key[1],
287
+ "provenance": provenance} for key, provenance in sorted(links.items())],
288
+ "seeds": len(seeds), "seed_counts": seed_counts, "unique_cuis": len(by_cui),
289
+ "unmapped_seeds": unmapped, "new_codes": added, "promoted_codes": promoted,
290
+ "already_narrow": already_narrow,
291
+ "unavailable_codes": [{"code": c, "vocabulary": v, "provenance": p}
292
+ for (c, v), p in sorted(unavailable.items())],
293
+ "calls": client.calls, "cache_hits": client.cache_hits,
294
+ "seconds": round(time.perf_counter() - started, 3)}
295
+ return merged, info
src/retrieval/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Retrieval components: lexical, embedding, and hybrid candidate generation."""
src/retrieval/embeddings.py ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Embedding-based retrieval using local or API-backed encoders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ from typing import Protocol, Sequence
11
+ from urllib.error import URLError
12
+ from urllib.request import urlopen
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+ DEFAULT_LOCAL_EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
18
+ DEFAULT_OPENAI_EMBEDDING_MODEL = "text-embedding-3-large"
19
+
20
+
21
+ def _create_local_embedding_model(model_name: str):
22
+ """Import the optional local stack only when that backend is selected."""
23
+ from sentence_transformers import SentenceTransformer
24
+
25
+ return SentenceTransformer(model_name)
26
+
27
+
28
+ class EmbeddingEncoder(Protocol):
29
+ """Common interface for local and API-backed embedding models."""
30
+
31
+ def encode(self, sentences, **kwargs): ...
32
+
33
+
34
+ class OpenAIEmbeddingModel:
35
+ """SentenceTransformer-compatible adapter for OpenAI embeddings."""
36
+
37
+ def __init__(
38
+ self,
39
+ model_name: str = DEFAULT_OPENAI_EMBEDDING_MODEL,
40
+ *,
41
+ dimensions: int | None = 3072,
42
+ batch_size: int = 512,
43
+ ) -> None:
44
+ if dimensions is not None and dimensions < 1:
45
+ raise ValueError("Embedding dimensions must be at least 1")
46
+ if batch_size < 1:
47
+ raise ValueError("Embedding batch size must be at least 1")
48
+ self.model_name = model_name
49
+ self.dimensions = dimensions
50
+ self.batch_size = batch_size
51
+ self._client = None
52
+
53
+ def _get_client(self):
54
+ if self._client is None:
55
+ import openai
56
+
57
+ self._client = openai.OpenAI()
58
+ return self._client
59
+
60
+ def encode(
61
+ self,
62
+ sentences,
63
+ *,
64
+ normalize_embeddings: bool = True,
65
+ show_progress_bar: bool = False,
66
+ **_kwargs,
67
+ ) -> np.ndarray:
68
+ del show_progress_bar
69
+ single = isinstance(sentences, str)
70
+ inputs = [sentences] if single else [str(item) for item in sentences]
71
+ if not inputs:
72
+ width = self.dimensions or 0
73
+ return np.empty((0, width), dtype=np.float32)
74
+
75
+ # Convert each response to float32 immediately. Keeping hundreds of
76
+ # thousands of embeddings as Python float lists can require many times
77
+ # more memory than the final NumPy matrix.
78
+ chunks: list[np.ndarray] = []
79
+ client = self._get_client()
80
+ for start in range(0, len(inputs), self.batch_size):
81
+ request = {
82
+ "model": self.model_name,
83
+ "input": inputs[start : start + self.batch_size],
84
+ "encoding_format": "float",
85
+ }
86
+ if self.dimensions is not None:
87
+ request["dimensions"] = self.dimensions
88
+ response = client.embeddings.create(**request)
89
+ chunks.append(
90
+ np.asarray(
91
+ [
92
+ item.embedding
93
+ for item in sorted(response.data, key=lambda item: item.index)
94
+ ],
95
+ dtype=np.float32,
96
+ )
97
+ )
98
+
99
+ result = np.vstack(chunks)
100
+ if normalize_embeddings:
101
+ norms = np.linalg.norm(result, axis=1, keepdims=True)
102
+ result = result / np.maximum(norms, 1e-12)
103
+ return result[0] if single else result
104
+
105
+
106
+ def create_embedding_model(
107
+ provider: str,
108
+ model_name: str,
109
+ *,
110
+ dimensions: int | None = None,
111
+ batch_size: int = 512,
112
+ ) -> EmbeddingEncoder:
113
+ """Construct a local SentenceTransformer or OpenAI embedding adapter."""
114
+ provider = provider.strip().lower()
115
+ if provider == "local":
116
+ return _create_local_embedding_model(model_name)
117
+ if provider == "openai":
118
+ return OpenAIEmbeddingModel(
119
+ model_name,
120
+ dimensions=dimensions,
121
+ batch_size=batch_size,
122
+ )
123
+ raise ValueError(f"Unsupported embedding provider: {provider!r}")
124
+
125
+
126
+ def embedding_cache_key(
127
+ provider: str,
128
+ model_name: str,
129
+ dimensions: int | None = None,
130
+ ) -> str:
131
+ """Return a filesystem-safe cache identity without mixing providers/models."""
132
+ model_key = re.sub(r"[^A-Za-z0-9_.-]+", "_", model_name).strip("_")
133
+ if provider.strip().lower() == "local":
134
+ return model_key
135
+ dimension_key = f"_{dimensions}" if dimensions is not None else ""
136
+ return f"{provider.strip().lower()}_{model_key}{dimension_key}"
137
+
138
+
139
+ @dataclass(slots=True)
140
+ class EmbeddingCandidate:
141
+ rank: int
142
+ code: str
143
+ description: str
144
+ vocabulary: str
145
+ score: float
146
+
147
+ def __lt__(self, other: object) -> bool:
148
+ if not isinstance(other, EmbeddingCandidate):
149
+ return NotImplemented
150
+ return self.score > other.score
151
+
152
+
153
+ @dataclass
154
+ class EmbeddingIndex:
155
+ """Wraps embedding model + corpus embeddings for fast cosine-sim search."""
156
+
157
+ model: EmbeddingEncoder | None
158
+ descriptions: list[str]
159
+ codes: list[str]
160
+ vocabularies: list[str]
161
+ embeddings: np.ndarray # shape (n, dim)
162
+
163
+ @classmethod
164
+ def from_codes(
165
+ cls,
166
+ codes: pd.DataFrame,
167
+ model_name: str = DEFAULT_LOCAL_EMBEDDING_MODEL,
168
+ model: EmbeddingEncoder | None = None,
169
+ show_progress_bar: bool = False,
170
+ ) -> "EmbeddingIndex":
171
+ df = codes.copy()
172
+ for col in ("code", "vocabulary"):
173
+ if col not in df.columns:
174
+ df[col] = ""
175
+ embedding_model = model or _create_local_embedding_model(model_name)
176
+ descs = df["description"].fillna("").astype(str).tolist()
177
+ embs = embedding_model.encode(
178
+ descs,
179
+ show_progress_bar=show_progress_bar,
180
+ normalize_embeddings=True,
181
+ )
182
+ return cls(
183
+ model=embedding_model,
184
+ descriptions=descs,
185
+ codes=df["code"].astype(str).tolist(),
186
+ vocabularies=df["vocabulary"].astype(str).tolist(),
187
+ embeddings=np.asarray(embs, dtype=np.float32),
188
+ )
189
+
190
+ def save(self, path: str | Path) -> None:
191
+ """Persist the corpus embeddings + metadata to disk (npz) so they can
192
+ be reloaded without re-running the embedding model."""
193
+ path = Path(path)
194
+ path.parent.mkdir(parents=True, exist_ok=True)
195
+ np.savez_compressed(
196
+ path,
197
+ embeddings=np.asarray(self.embeddings, dtype=np.float32),
198
+ codes=np.array(self.codes, dtype=object),
199
+ descriptions=np.array(self.descriptions, dtype=object),
200
+ vocabularies=np.array(self.vocabularies, dtype=object),
201
+ )
202
+
203
+ @classmethod
204
+ def load(
205
+ cls,
206
+ path: str | Path,
207
+ model_name: str = DEFAULT_LOCAL_EMBEDDING_MODEL,
208
+ model: EmbeddingEncoder | None = None,
209
+ load_model: bool = True,
210
+ ) -> "EmbeddingIndex":
211
+ """Load a previously-saved embedding index from disk."""
212
+ with np.load(path, allow_pickle=True) as data:
213
+ return cls(
214
+ model=model or (_create_local_embedding_model(model_name) if load_model else None),
215
+ descriptions=list(data["descriptions"]),
216
+ codes=list(data["codes"]),
217
+ vocabularies=list(data["vocabularies"]),
218
+ embeddings=np.asarray(data["embeddings"], dtype=np.float32),
219
+ )
220
+
221
+ @classmethod
222
+ def from_cache_or_build(
223
+ cls,
224
+ codes: pd.DataFrame,
225
+ cache_path: str | Path,
226
+ model_name: str = DEFAULT_LOCAL_EMBEDDING_MODEL,
227
+ model: EmbeddingEncoder | None = None,
228
+ load_model: bool = True,
229
+ show_progress_bar: bool = False,
230
+ status_callback: Callable[[str], None] | None = None,
231
+ ) -> "EmbeddingIndex":
232
+ """Load a cached embedding index if present, otherwise build it from
233
+ *codes* and cache the result for subsequent runs.
234
+
235
+ This is the key mechanism that avoids re-embedding large reference
236
+ code systems (e.g. the ~74k-row full ICD-10-CM corpus) on every run.
237
+ """
238
+ cache_path = Path(cache_path)
239
+ if cache_path.exists():
240
+ cached = cls.load(
241
+ cache_path,
242
+ model_name=model_name,
243
+ model=model,
244
+ load_model=load_model,
245
+ )
246
+ expected_codes = codes.get("code", pd.Series(dtype=str)).astype(str).tolist()
247
+ expected_vocabularies = (
248
+ codes.get("vocabulary", pd.Series(dtype=str)).astype(str).tolist()
249
+ )
250
+ expected_descriptions = codes["description"].fillna("").astype(str).tolist()
251
+ if (
252
+ cached.codes == expected_codes
253
+ and cached.vocabularies == expected_vocabularies
254
+ and cached.descriptions == expected_descriptions
255
+ ):
256
+ return cached
257
+ subset_positions = _ordered_subset_positions(
258
+ cached.codes,
259
+ cached.vocabularies,
260
+ expected_codes,
261
+ expected_vocabularies,
262
+ )
263
+ if subset_positions is not None and all(
264
+ cached.descriptions[position] == description
265
+ for position, description in zip(subset_positions, expected_descriptions)
266
+ ):
267
+ # Corpus preprocessing can intentionally remove non-codable
268
+ # dictionary rows (for example ICD range headers). Reuse the
269
+ # matching cached vectors instead of re-embedding every code.
270
+ subset = cls(
271
+ model=cached.model,
272
+ descriptions=expected_descriptions,
273
+ codes=expected_codes,
274
+ vocabularies=expected_vocabularies,
275
+ embeddings=np.asarray(cached.embeddings[subset_positions], dtype=np.float32),
276
+ )
277
+ subset.save(cache_path)
278
+ return subset
279
+ if status_callback is not None:
280
+ status_callback(
281
+ f"Creating embeddings for {len(codes):,} descriptions; no matching saved index. "
282
+ "The first run can take several minutes."
283
+ )
284
+ index = cls.from_codes(
285
+ codes,
286
+ model_name=model_name,
287
+ model=model,
288
+ show_progress_bar=show_progress_bar,
289
+ )
290
+ index.save(cache_path)
291
+ return index
292
+
293
+ @classmethod
294
+ def combine(cls, indexes: Sequence["EmbeddingIndex"]) -> "EmbeddingIndex":
295
+ """Concatenate multiple embedding indexes into a single searchable index."""
296
+ if not indexes:
297
+ raise ValueError("At least one embedding index must be provided.")
298
+
299
+ descriptions: list[str] = []
300
+ codes: list[str] = []
301
+ vocabularies: list[str] = []
302
+ embeddings: list[np.ndarray] = []
303
+
304
+ for index in indexes:
305
+ descriptions.extend(index.descriptions)
306
+ codes.extend(index.codes)
307
+ vocabularies.extend(index.vocabularies)
308
+ embeddings.append(np.asarray(index.embeddings))
309
+
310
+ return cls(
311
+ model=indexes[0].model,
312
+ descriptions=descriptions,
313
+ codes=codes,
314
+ vocabularies=vocabularies,
315
+ embeddings=(embeddings[0] if len(embeddings) == 1 else np.vstack(embeddings)).astype(np.float32, copy=False)
316
+ if embeddings
317
+ else np.empty((0, 0), dtype=np.float32),
318
+ )
319
+
320
+ @classmethod
321
+ def load_for_vocabularies(
322
+ cls,
323
+ codes: pd.DataFrame,
324
+ vocabularies: Sequence[str],
325
+ cache_dir: str | Path,
326
+ model_name: str = DEFAULT_LOCAL_EMBEDDING_MODEL,
327
+ model: EmbeddingEncoder | None = None,
328
+ provider: str = "local",
329
+ dimensions: int | None = None,
330
+ progress_callback: Callable[[int, int], None] | None = None,
331
+ status_callback: Callable[[str], None] | None = None,
332
+ ) -> "EmbeddingIndex":
333
+ """Load per-vocabulary embedding caches and concatenate them.
334
+
335
+ Each vocabulary is cached independently by vocabulary, provider,
336
+ model, and output dimensions.
337
+ Selected vocabularies are then combined in memory for retrieval.
338
+ """
339
+ cache_dir = Path(cache_dir)
340
+ indexes: list[EmbeddingIndex] = []
341
+ total = len(vocabularies)
342
+ if progress_callback is not None:
343
+ progress_callback(0, total)
344
+ for position, vocabulary in enumerate(vocabularies, start=1):
345
+ vocab_df = codes[
346
+ codes["vocabulary"].astype(str).str.upper() == vocabulary.upper()
347
+ ].reset_index(drop=True)
348
+ if vocab_df.empty:
349
+ continue
350
+ cache_identity = embedding_cache_key(provider, model_name, dimensions)
351
+ cache_path = cache_dir / f"embeddings_{vocabulary}_{cache_identity}.npz"
352
+ if status_callback is not None:
353
+ status_callback(f"Preparing embeddings for {vocabulary} ({len(vocab_df):,} descriptions)...")
354
+ # Existing release assets contain the legacy local MiniLM indexes.
355
+ # OpenAI indexes are private, paid-to-generate local artifacts and
356
+ # must not be mistaken for downloadable release assets.
357
+ if provider.strip().lower() == "local":
358
+ _ensure_embedding_asset(cache_path)
359
+ if status_callback is not None:
360
+ status_callback(
361
+ f"Loading and checking saved embeddings for {vocabulary}..."
362
+ if cache_path.exists()
363
+ else f"Creating embeddings for {vocabulary} ({len(vocab_df):,} descriptions). "
364
+ "The first run can take several minutes."
365
+ )
366
+ indexes.append(
367
+ cls.from_cache_or_build(
368
+ vocab_df,
369
+ cache_path=cache_path,
370
+ model_name=model_name,
371
+ model=model,
372
+ load_model=False,
373
+ show_progress_bar=progress_callback is not None,
374
+ status_callback=(
375
+ (lambda message, vocabulary=vocabulary: status_callback(f"{vocabulary}: {message}"))
376
+ if status_callback is not None else None
377
+ ),
378
+ )
379
+ )
380
+ if progress_callback is not None:
381
+ progress_callback(position, total)
382
+ if status_callback is not None:
383
+ status_callback("Combining terminology embeddings...")
384
+ matrices = [np.asarray(index.embeddings, dtype=np.float32) for index in indexes]
385
+ return cls(
386
+ model=None,
387
+ descriptions=[d for index in indexes for d in index.descriptions],
388
+ codes=[c for index in indexes for c in index.codes],
389
+ vocabularies=[v for index in indexes for v in index.vocabularies],
390
+ embeddings=(matrices[0] if len(matrices) == 1 else np.vstack(matrices))
391
+ if indexes
392
+ else np.empty((0, 0), dtype=np.float32),
393
+ )
394
+
395
+ def retrieve(
396
+ self,
397
+ query: str,
398
+ top_k: int | None = None,
399
+ model: EmbeddingEncoder | None = None,
400
+ ) -> list[EmbeddingCandidate]:
401
+ query_model = model or self.model
402
+ if query_model is None:
403
+ raise ValueError("An embedding model is required to embed the query.")
404
+ q_emb = query_model.encode(query, show_progress_bar=False, normalize_embeddings=True)
405
+ scores = self.embeddings @ q_emb # cosine similarity (both normalised)
406
+ if top_k is not None and len(scores) > top_k:
407
+ top_indices = np.argpartition(scores, -top_k)[-top_k:]
408
+ sorted_local = sorted(top_indices, key=lambda i: -scores[i])[:top_k]
409
+ else:
410
+ sorted_local = np.argsort(-scores)
411
+
412
+ return [
413
+ EmbeddingCandidate(
414
+ rank=r + 1,
415
+ code=self.codes[idx],
416
+ description=self.descriptions[idx],
417
+ vocabulary=self.vocabularies[idx],
418
+ score=float(round(scores[idx], 6)),
419
+ )
420
+ for r, idx in enumerate(sorted_local)
421
+ ]
422
+
423
+ def score_all(
424
+ self,
425
+ query: str,
426
+ model: EmbeddingEncoder | None = None,
427
+ ) -> list[float]:
428
+ """Return corpus-order cosine scores without ranking or object creation.
429
+
430
+ Hybrid retrieval needs every embedding score for global min-max
431
+ normalization, but it does not need the embedding-only ranking. This
432
+ path preserves the same six-decimal scores as :meth:`retrieve` while
433
+ avoiding a full sort and hundreds of thousands of temporary
434
+ ``EmbeddingCandidate`` objects.
435
+ """
436
+ query_model = model or self.model
437
+ if query_model is None:
438
+ raise ValueError("An embedding model is required to embed the query.")
439
+ q_emb = query_model.encode(query, show_progress_bar=False, normalize_embeddings=True)
440
+ scores = self.embeddings @ q_emb
441
+ return [float(round(score, 6)) for score in scores]
442
+
443
+
444
+ def _ordered_subset_positions(
445
+ cached_codes: Sequence[str],
446
+ cached_vocabularies: Sequence[str],
447
+ expected_codes: Sequence[str],
448
+ expected_vocabularies: Sequence[str],
449
+ ) -> list[int] | None:
450
+ """Locate an order-preserving expected corpus inside a cached corpus."""
451
+ if (
452
+ len(expected_codes) != len(expected_vocabularies)
453
+ or len(expected_codes) > len(cached_codes)
454
+ ):
455
+ return None
456
+ positions: list[int] = []
457
+ expected_index = 0
458
+ for cached_index, (code, vocabulary) in enumerate(
459
+ zip(cached_codes, cached_vocabularies)
460
+ ):
461
+ if expected_index >= len(expected_codes):
462
+ break
463
+ if (
464
+ code == expected_codes[expected_index]
465
+ and vocabulary == expected_vocabularies[expected_index]
466
+ ):
467
+ positions.append(cached_index)
468
+ expected_index += 1
469
+ return positions if expected_index == len(expected_codes) else None
470
+
471
+
472
+ def _release_asset_url(asset_name: str) -> str | None:
473
+ owner = os.getenv("GITHUB_REPOSITORY_OWNER")
474
+ repo = os.getenv("GITHUB_REPOSITORY_NAME")
475
+ tag = os.getenv("GITHUB_RELEASE_TAG")
476
+ if not owner or not repo or not tag:
477
+ return None
478
+ return f"https://github.com/{owner}/{repo}/releases/download/{tag}/{asset_name}"
479
+
480
+
481
+ def _ensure_embedding_asset(cache_path: Path) -> None:
482
+ """Ensure an embedding asset exists locally, downloading it from a GitHub Release if needed."""
483
+ if cache_path.exists():
484
+ return
485
+
486
+ asset_name = cache_path.name
487
+ url = _release_asset_url(asset_name)
488
+ if url is None:
489
+ return
490
+
491
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
492
+ try:
493
+ with urlopen(url) as response, cache_path.open("wb") as fh:
494
+ fh.write(response.read())
495
+ except URLError:
496
+ if cache_path.exists():
497
+ cache_path.unlink(missing_ok=True)
498
+ raise
499
+
500
+
501
+ def retrieve_embeddings(
502
+ query: str,
503
+ codes: pd.DataFrame,
504
+ model_name: str = DEFAULT_LOCAL_EMBEDDING_MODEL,
505
+ top_k: int | None = None,
506
+ ) -> list[EmbeddingCandidate]:
507
+ """Build an embedding index on *codes* and retrieve candidates for *query*.
508
+
509
+ Parameters
510
+ ----------
511
+ query : str
512
+ Phenotype / EDF description to search.
513
+ codes : pd.DataFrame
514
+ Corpus with a ``description`` column (and optionally ``code``/``vocabulary``).
515
+ model_name : str
516
+ sentence-transformers model identifier.
517
+ top_k : int, optional
518
+ Number of results to return.
519
+
520
+ Returns
521
+ -------
522
+ list[EmbeddingCandidate]
523
+ """
524
+ df = codes.copy()
525
+ for col in ("code", "vocabulary"):
526
+ if col not in df.columns:
527
+ df[col] = ""
528
+
529
+ descs = df["description"].fillna("").astype(str).tolist()
530
+ model = _create_local_embedding_model(model_name)
531
+ embs = model.encode(descs, show_progress_bar=False, normalize_embeddings=True)
532
+ q_emb = model.encode(query, show_progress_bar=False, normalize_embeddings=True)
533
+ scores = embs @ q_emb
534
+
535
+ sorted_idx = np.argsort(-scores)
536
+ if top_k is not None:
537
+ sorted_idx = sorted_idx[:top_k]
538
+
539
+ return [
540
+ EmbeddingCandidate(
541
+ rank=r + 1,
542
+ code=df.iloc[idx]["code"],
543
+ description=descs[idx],
544
+ vocabulary=str(df.iloc[idx].get("vocabulary", "")),
545
+ score=float(round(scores[idx], 6)),
546
+ )
547
+ for r, idx in enumerate(sorted_idx)
548
+ ]
src/retrieval/hybrid.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hybrid retrieval combining lexical (BM25) and embedding (semantic) scores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import pandas as pd
8
+
9
+ from src.retrieval.embeddings import EmbeddingEncoder, EmbeddingIndex, retrieve_embeddings
10
+ from src.retrieval.lexical import build_lexical_index, score_lexical
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class HybridCandidate:
15
+ rank: int
16
+ code: str
17
+ description: str
18
+ vocabulary: str
19
+ lexical_score: float
20
+ embedding_score: float
21
+ score: float
22
+
23
+ def __lt__(self, other: object) -> bool:
24
+ if not isinstance(other, HybridCandidate):
25
+ return NotImplemented
26
+ return self.score > other.score
27
+
28
+
29
+ def _min_max_normalize(values: list[float]) -> list[float]:
30
+ if not values:
31
+ return values
32
+ lo, hi = min(values), max(values)
33
+ if hi - lo < 1e-12:
34
+ return [0.0 for _ in values]
35
+ return [(v - lo) / (hi - lo) for v in values]
36
+
37
+
38
+ def hybrid_retrieval(
39
+ query: str,
40
+ codes: pd.DataFrame,
41
+ lexical_weight: float = 0.5,
42
+ embedding_weight: float = 0.5,
43
+ embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2",
44
+ top_k: int | None = 25,
45
+ per_vocabulary_top_k: bool = True,
46
+ embedding_index: EmbeddingIndex | None = None,
47
+ query_model: EmbeddingEncoder | None = None,
48
+ lexical_query: str | None = None,
49
+ embedding_query: str | None = None,
50
+ ) -> list[HybridCandidate]:
51
+ """Combine BM25 lexical retrieval with embedding-based semantic retrieval.
52
+
53
+ Both signals are min-max normalized to [0, 1] and combined with the given
54
+ weights before ranking. The combined ranking is truncated to *top_k*.
55
+
56
+ Parameters
57
+ ----------
58
+ query : str
59
+ Phenotype / EDF text used as the search query.
60
+ codes : pd.DataFrame
61
+ Candidate code corpus with ``code``, ``description``, ``vocabulary`` columns.
62
+ lexical_weight, embedding_weight : float
63
+ Relative weighting of each retrieval signal. Need not sum to 1.
64
+ embedding_model : str
65
+ sentence-transformers model identifier used when *embedding_index* is None.
66
+ top_k : int, optional
67
+ Number of ranked results to return per vocabulary. ``None`` returns the
68
+ full corpus.
69
+ per_vocabulary_top_k : bool
70
+ If ``True`` (default), apply ``top_k`` separately within each selected
71
+ vocabulary. If ``False``, apply ``top_k`` after combining all selected
72
+ vocabularies.
73
+ embedding_index : EmbeddingIndex, optional
74
+ Pre-built embedding index to reuse across multiple queries.
75
+ query_model : EmbeddingEncoder, optional
76
+ Cached local or API-backed encoder used to embed the EDF/query text.
77
+ lexical_query : str, optional
78
+ Text used for the BM25 lexical score. If omitted, *query* is used.
79
+ embedding_query : str, optional
80
+ Text used for the embedding score. If omitted, *query* is used.
81
+
82
+ Returns
83
+ -------
84
+ list[HybridCandidate]
85
+ """
86
+ if codes.empty:
87
+ return []
88
+
89
+ df = codes.reset_index(drop=True).copy()
90
+ for col in ("code", "vocabulary"):
91
+ if col not in df.columns:
92
+ df[col] = ""
93
+
94
+ lexical_query = lexical_query or query
95
+ embedding_query = embedding_query or query
96
+ embedding_scores_by_code: dict[tuple[str, str], float] | None = None
97
+ if embedding_index is not None:
98
+ # Score the combined corpus exactly once. Previously this full matrix
99
+ # multiplication, full sort, and candidate-object construction ran
100
+ # once per vocabulary even though every pass produced identical raw
101
+ # scores. The corpus is deduplicated on (code, vocabulary), so the map
102
+ # is identical to the maps produced by the former repeated searches.
103
+ all_embedding_scores = embedding_index.score_all(embedding_query, model=query_model)
104
+ embedding_scores_by_code = {
105
+ (code, vocabulary): score
106
+ for code, vocabulary, score in zip(
107
+ embedding_index.codes,
108
+ embedding_index.vocabularies,
109
+ all_embedding_scores,
110
+ )
111
+ }
112
+
113
+ per_vocab_candidates: list[
114
+ tuple[str, list[tuple[str, str, str, float, float]]]
115
+ ] = []
116
+ for vocabulary, vocab_df in df.groupby("vocabulary", sort=False):
117
+ lexical_index = build_lexical_index(vocab_df)
118
+ lexical_scores = score_lexical(lexical_query, lexical_index, len(vocab_df))
119
+
120
+ if embedding_scores_by_code is None:
121
+ embedding_results = retrieve_embeddings(embedding_query, vocab_df, model_name=embedding_model, top_k=None)
122
+ vocabulary_embedding_scores = {
123
+ (c.code, c.vocabulary): c.score for c in embedding_results
124
+ }
125
+ else:
126
+ vocabulary_embedding_scores = embedding_scores_by_code
127
+
128
+ codes_values = vocab_df["code"].tolist()
129
+ description_values = vocab_df["description"].tolist()
130
+ vocabulary_values = vocab_df["vocabulary"].tolist()
131
+ vocab_candidates = [
132
+ (
133
+ code,
134
+ description,
135
+ candidate_vocabulary,
136
+ lexical_score,
137
+ vocabulary_embedding_scores.get((code, candidate_vocabulary), 0.0),
138
+ )
139
+ for code, description, candidate_vocabulary, lexical_score in zip(
140
+ codes_values,
141
+ description_values,
142
+ vocabulary_values,
143
+ lexical_scores,
144
+ )
145
+ ]
146
+ per_vocab_candidates.append((vocabulary, vocab_candidates))
147
+
148
+ all_lexical = [
149
+ lexical
150
+ for _, vocab_candidates in per_vocab_candidates
151
+ for _, _, _, lexical, _ in vocab_candidates
152
+ ]
153
+ all_embedding = [
154
+ embedding
155
+ for _, vocab_candidates in per_vocab_candidates
156
+ for _, _, _, _, embedding in vocab_candidates
157
+ ]
158
+ norm_all_lexical = _min_max_normalize(all_lexical)
159
+ norm_all_embedding = _min_max_normalize(all_embedding)
160
+
161
+ # Keep lightweight tuples until after top-k selection. Constructing a
162
+ # dataclass (and formerly a Pandas Series) for all 620k rows dominated the
163
+ # runtime even when only a handful of results were requested.
164
+ selected: list[tuple[str, str, str, float, float, float]] = []
165
+ flat_idx = 0
166
+ for _, vocab_candidates in per_vocab_candidates:
167
+ scored: list[tuple[str, str, str, float, float, float]] = []
168
+ for idx, (code, description, vocabulary, _, _) in enumerate(vocab_candidates):
169
+ norm_lexical = norm_all_lexical[flat_idx + idx]
170
+ norm_embedding = norm_all_embedding[flat_idx + idx]
171
+ score = lexical_weight * norm_lexical + embedding_weight * norm_embedding
172
+ scored.append(
173
+ (
174
+ code,
175
+ description,
176
+ vocabulary,
177
+ round(norm_lexical, 6),
178
+ round(norm_embedding, 6),
179
+ round(score, 6),
180
+ )
181
+ )
182
+ if per_vocabulary_top_k and top_k is not None:
183
+ scored.sort(key=lambda candidate: -candidate[5])
184
+ scored = scored[:top_k]
185
+ selected.extend(scored)
186
+ flat_idx += len(vocab_candidates)
187
+
188
+ selected.sort(key=lambda candidate: -candidate[5])
189
+ if not per_vocabulary_top_k and top_k is not None:
190
+ selected = selected[:top_k]
191
+ return [
192
+ HybridCandidate(
193
+ rank=rank,
194
+ code=code,
195
+ description=description,
196
+ vocabulary=vocabulary,
197
+ lexical_score=lexical_score,
198
+ embedding_score=embedding_score,
199
+ score=score,
200
+ )
201
+ for rank, (code, description, vocabulary, lexical_score, embedding_score, score)
202
+ in enumerate(selected, start=1)
203
+ ]
src/retrieval/lexical.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BM25 lexical retrieval over code descriptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from rank_bm25 import BM25Okapi
11
+
12
+
13
+ def _tokenize(text: str) -> list[str]:
14
+ """Simple whitespace + punctuation tokenizer for BM25 indexing."""
15
+ if not text or not isinstance(text, str):
16
+ return []
17
+ text = re.sub(r"[^\w\s]", " ", text.lower())
18
+ return [tok for tok in text.split() if len(tok) > 1]
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class LexicalCandidate:
23
+ rank: int
24
+ code: str
25
+ description: str
26
+ vocabulary: str
27
+ score: float
28
+
29
+ def __lt__(self, other: object) -> bool:
30
+ if not isinstance(other, LexicalCandidate):
31
+ return NotImplemented
32
+ return self.score > other.score # higher = more relevant
33
+
34
+
35
+ def build_lexical_index(codes: pd.DataFrame) -> BM25Okapi:
36
+ """Build a BM25 index from the code corpus descriptions.
37
+
38
+ Parameters
39
+ ----------
40
+ codes : pd.DataFrame
41
+ Must contain ``description`` column with full description strings.
42
+
43
+ Returns
44
+ -------
45
+ BM25Okapi
46
+ The built tokenised index.
47
+ """
48
+ doc_tokens: list[list[str]] = []
49
+ for desc in codes["description"].astype(str):
50
+ tokens = _tokenize(desc)
51
+ doc_tokens.append(tokens if tokens else [desc])
52
+
53
+ return BM25Okapi(doc_tokens)
54
+
55
+
56
+ def score_lexical(query: str, index: BM25Okapi, corpus_size: int) -> list[float]:
57
+ """Return corpus-order BM25 scores without allocating result objects."""
58
+ query_tokens = _tokenize(query)
59
+ if not query_tokens:
60
+ return [0.0] * corpus_size
61
+ return [round(float(score), 6) for score in index.get_scores(query_tokens)]
62
+
63
+
64
+ def retrieve_lexical(
65
+ query: str,
66
+ codes: pd.DataFrame | list[dict],
67
+ index: BM25Okapi | None = None,
68
+ top_k: int | None = None,
69
+ ) -> list[LexicalCandidate]:
70
+ """Retrieve candidate codes ranked by BM25 lexical similarity.
71
+
72
+ Parameters
73
+ ----------
74
+ query : str
75
+ The phenotype description / EDF summary to search against.
76
+ codes : pd.DataFrame or list[dict]
77
+ Code corpus containing ``description`` (mandatory) and optionally
78
+ ``code``, ``vocabulary`` columns (DataFrame) or dicts with those keys.
79
+ index : BM25Okapi, optional
80
+ Pre-built index. If omitted one is built on-the-fly from *codes*.
81
+ top_k : int, optional
82
+ Maximum number of results to return. Defaults to the full corpus size.
83
+
84
+ Returns
85
+ -------
86
+ list[LexicalCandidate]
87
+ Ranked candidates sorted descending by BM25 score.
88
+ """
89
+ if isinstance(codes, pd.DataFrame):
90
+ df = codes.copy()
91
+ keys = ("description", "code", "vocabulary")
92
+ for k in keys[1:]:
93
+ if k not in df.columns:
94
+ df[k] = "" # type: ignore[operator]
95
+ else:
96
+ df = pd.DataFrame(codes)
97
+ for k in ("code", "vocabulary"):
98
+ if k not in df.columns:
99
+ df[k] = ""
100
+
101
+ if index is None:
102
+ index = build_lexical_index(df)
103
+
104
+ query_tokens = _tokenize(query)
105
+ if not query_tokens:
106
+ # Fallback: rank all codes uniformly (no lexical signal)
107
+ return [
108
+ LexicalCandidate(
109
+ rank=0, code=r["code"], description=r["description"], vocabulary=r["vocabulary"], score=0.0
110
+ )
111
+ for i, r in df.iterrows()
112
+ ]
113
+
114
+ scores = index.get_scores(query_tokens).tolist() # type: ignore[union-attr]
115
+
116
+ if top_k is not None:
117
+ top_indices = np.argpartition(scores, -top_k)[-top_k:]
118
+ top_scores = [scores[i] for i in sorted(top_indices, key=lambda x: -scores[x])]
119
+ results: list[LexicalCandidate] = [
120
+ LexicalCandidate(
121
+ rank=r + 1,
122
+ code=df.iloc[idx]["code"],
123
+ description=df.iloc[idx]["description"],
124
+ vocabulary=df.iloc[idx].get("vocabulary", ""),
125
+ score=round(float(s), 6),
126
+ )
127
+ for r, (idx, s) in enumerate(sorted(enumerate(top_scores), key=lambda x: -x[1]))
128
+ ]
129
+ else:
130
+ results = [
131
+ LexicalCandidate(
132
+ rank=r + 1,
133
+ code=df.iloc[r]["code"],
134
+ description=df.iloc[r]["description"],
135
+ vocabulary=df.iloc[r].get("vocabulary", ""),
136
+ score=round(float(scores[r]), 6),
137
+ )
138
+ for r in range(len(df))
139
+ ]
140
+
141
+ return results
src/uncertainty/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Uncertainty-based selection of candidates for human review."""
src/uncertainty/selection.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Select uncertain classifications requiring targeted human review."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from src.llm.classify import ClassifiedCandidate
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class ReviewItem:
12
+ code: str
13
+ description: str
14
+ vocabulary: str
15
+ label: str
16
+ confidence: float
17
+ explanation: str
18
+ reason: str
19
+
20
+
21
+ def select_uncertain(
22
+ classified: list[ClassifiedCandidate],
23
+ confidence_threshold: float = 0.7,
24
+ possible_requires_review: bool = False,
25
+ ) -> list[ReviewItem]:
26
+ """Select classified candidates that require human validation.
27
+
28
+ A candidate is flagged for review when:
29
+ - its LLM confidence is below *confidence_threshold*, or
30
+ - it was classified as 'Possible' and *possible_requires_review* is True.
31
+
32
+ Parameters
33
+ ----------
34
+ classified : list[ClassifiedCandidate]
35
+ Output of :func:`src.llm.classify.llm_classify`.
36
+ confidence_threshold : float
37
+ Minimum confidence below which a candidate is routed for review.
38
+ possible_requires_review : bool
39
+ Whether 'Possible' classifications are always routed for review,
40
+ regardless of confidence.
41
+
42
+ Returns
43
+ -------
44
+ list[ReviewItem]
45
+ Items requiring human review, each annotated with the reason.
46
+ """
47
+ review_items: list[ReviewItem] = []
48
+ for c in classified:
49
+ reasons: list[str] = []
50
+ if c.decision_origin == "codemapper":
51
+ reasons.append("included by CodeMapper mapping; EDF scope not independently assessed")
52
+ if c.confidence < confidence_threshold:
53
+ reasons.append(f"confidence {c.confidence:.2f} below threshold {confidence_threshold:.2f}")
54
+ if possible_requires_review and c.label == "Possible":
55
+ reasons.append("classified as 'Possible'")
56
+
57
+ if reasons:
58
+ review_items.append(
59
+ ReviewItem(
60
+ code=c.code,
61
+ description=c.description,
62
+ vocabulary=c.vocabulary,
63
+ label=c.label,
64
+ confidence=c.confidence,
65
+ explanation=c.explanation,
66
+ reason="; ".join(reasons),
67
+ )
68
+ )
69
+ return review_items
70
+
71
+
72
+ def review_rate(classified: list[ClassifiedCandidate], review_items: list[ReviewItem]) -> float:
73
+ """Fraction of classified candidates flagged for human review."""
74
+ if not classified:
75
+ return 0.0
76
+ return len(review_items) / len(classified)
src/utils/__init__.py ADDED
File without changes
src/utils/config.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+
9
+ @dataclass
10
+ class PathsConfig:
11
+ edf_dir: str = "data/edfs/yamls"
12
+ codes_parquet_dir: str = "data/codes/parquet/snomed_definitive_20261030"
13
+ embeddings_dir: str = "data/codes/embeddings/snomed_definitive_20261030"
14
+ results_dir: str = "results"
15
+ aesi_datasets: dict[str, str] = field(default_factory=dict)
16
+ code_systems: dict[str, str] = field(
17
+ default_factory=lambda: {
18
+ "ICD10CM": "data/codes/csv/ICD10CM@2026-codes.csv",
19
+ "ICD9CM": "data/codes/csv/ICD9CM@2014-codes.csv",
20
+ "ICPC": "data/codes/csv/ICPC@1993-codes.csv",
21
+ "MDR": "data/codes/csv/MDR@28.0-codes.csv",
22
+ "RCD2": "data/codes/csv/RCD2@20200401-codes.csv",
23
+ "SNOMEDCT_US": "data/codes/csv/SNOMEDCT_US@2025_09_01-codes definitive.csv",
24
+ }
25
+ )
26
+
27
+
28
+ @dataclass
29
+ class RetrievalConfig:
30
+ lexical_weight: float = 0.1
31
+ embedding_weight: float = 0.9
32
+ embedding_provider: str = "openai"
33
+ embedding_model: str = "text-embedding-3-large"
34
+ embedding_dimensions: int | None = 3072
35
+ embedding_batch_size: int = 512
36
+ top_k: int = 50
37
+ use_top_k_limit: bool = False
38
+ per_vocabulary_top_k: bool = True
39
+ vocabularies: list[str] = field(
40
+ default_factory=lambda: ["ICD10CM", "ICD9CM", "ICPC", "MDR", "RCD2", "SNOMEDCT_US"]
41
+ )
42
+
43
+
44
+ @dataclass
45
+ class LLMConfig:
46
+ provider: str = "jev" # Stage 2 defaults to Jev; other providers remain available.
47
+ anthropic_model: str = "claude-opus-4-8"
48
+ openai_model: str = "gpt-6-luna"
49
+ google_model: str = "gemini-3-flash-preview"
50
+ jev_model: str = "jev-latest"
51
+ openai_decisions_model: str = "gpt-6-luna"
52
+ laya_model: str = "multilingual"
53
+ jpt_model: str = "kirp/jpt-4b"
54
+ mlx_model: str = "mlx-community/Qwen3.5-9B-8bit"
55
+ rank_effort: str = "low"
56
+ classify_effort: str = "medium"
57
+ max_retries: int = 3
58
+ use_possible_category: bool = False
59
+ adaptive_stopping_enabled: bool = True
60
+ sparse_narrow_threshold: int = 0
61
+ consecutive_sparse_batches: int = 10
62
+ minimum_batches: int = 3
63
+
64
+
65
+ @dataclass
66
+ class UncertaintyConfig:
67
+ review_provider: str = "openai"
68
+ review_model: str | None = None
69
+ confidence_threshold: float = 0.7
70
+ gpt_review_max_candidates: int = 200
71
+ possible_requires_review: bool = False
72
+ gpt_review_enabled: bool = True
73
+ gpt_review_batch_size: int = 10
74
+ gpt_review_reasoning_enabled: bool = True
75
+
76
+
77
+ @dataclass
78
+ class EvaluationConfig:
79
+ narrow_only_as_positive: bool = True
80
+ compare_vocabularies: list[str] = field(
81
+ default_factory=lambda: ["ICD10CM", "ICD9CM", "ICPC", "MDR", "RCD2", "SNOMEDCT_US"]
82
+ )
83
+
84
+
85
+ @dataclass
86
+ class CodeMapperConfig:
87
+ enabled: bool = False
88
+ base_url: str = "https://app.vac4eu.org/codemapper-testing/api"
89
+ cache_dir: str = "results/codemapper/cache"
90
+ batch_size: int = 100
91
+ requests_per_minute: float = 6
92
+ timeout_seconds: float = 30
93
+ max_retries: int = 3
94
+ vocabulary_versions: dict[str, str] = field(default_factory=lambda: {
95
+ "ICD10CM": "2026", "ICD9CM": "2014", "ICPC": "1993",
96
+ "MDR": "28.0", "RCD2": "20200401", "SNOMEDCT_US": "2025_09_01",
97
+ })
98
+
99
+ def __post_init__(self):
100
+ if self.batch_size < 1 or self.requests_per_minute <= 0:
101
+ raise ValueError("CodeMapper batch size and request rate must be positive")
102
+ if self.timeout_seconds <= 0 or self.max_retries < 1:
103
+ raise ValueError("CodeMapper timeout and retry count must be positive")
104
+ if not self.base_url.startswith("https://"):
105
+ raise ValueError("CodeMapper requires an HTTPS base URL")
106
+
107
+
108
+ @dataclass
109
+ class PipelineConfig:
110
+ phenotype: str = "myocarditis"
111
+ verbose: bool = True
112
+ paths: PathsConfig = field(default_factory=PathsConfig)
113
+ retrieval: RetrievalConfig = field(default_factory=RetrievalConfig)
114
+ llm: LLMConfig = field(default_factory=LLMConfig)
115
+ uncertainty: UncertaintyConfig = field(default_factory=UncertaintyConfig)
116
+ evaluation: EvaluationConfig = field(default_factory=EvaluationConfig)
117
+ codemapper: CodeMapperConfig = field(default_factory=CodeMapperConfig)
118
+
119
+
120
+ def load_config(path: str | Path) -> PipelineConfig:
121
+ """Load a YAML config file into a PipelineConfig, falling back to defaults
122
+ for any keys not present in the file."""
123
+ path = Path(path)
124
+ raw = yaml.safe_load(path.read_text()) or {}
125
+
126
+ return PipelineConfig(
127
+ phenotype=raw.get("phenotype", PipelineConfig.phenotype),
128
+ verbose=raw.get("verbose", PipelineConfig.verbose),
129
+ paths=PathsConfig(**raw.get("paths", {})),
130
+ retrieval=RetrievalConfig(**raw.get("retrieval", {})),
131
+ llm=LLMConfig(**raw.get("llm", {})),
132
+ uncertainty=UncertaintyConfig(**raw.get("uncertainty", {})),
133
+ evaluation=EvaluationConfig(**raw.get("evaluation", {})),
134
+ codemapper=CodeMapperConfig(**raw.get("codemapper", {})),
135
+ )
src/utils/logging.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import sys
3
+
4
+ _CONFIGURED = False
5
+
6
+ # Silence noisy third-party libraries (sentence-transformers/httpx/huggingface_hub
7
+ # log INFO-level HTTP requests and an "unauthenticated requests" warning on every
8
+ # model load) — none of this is actionable for local, cached model usage.
9
+ _QUIET_LOGGERS = ("sentence_transformers", "httpx", "huggingface_hub")
10
+
11
+
12
+ def get_logger(name: str) -> logging.Logger:
13
+ global _CONFIGURED
14
+ if not _CONFIGURED:
15
+ logging.basicConfig(
16
+ level=logging.INFO,
17
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
18
+ stream=sys.stdout,
19
+ )
20
+ for quiet_logger in _QUIET_LOGGERS:
21
+ logging.getLogger(quiet_logger).setLevel(logging.ERROR)
22
+ try:
23
+ # huggingface_hub emits its "unauthenticated requests" notice through
24
+ # its own verbosity control rather than a plain logger level, so the
25
+ # logger.setLevel above alone doesn't always suppress it.
26
+ from huggingface_hub.utils import logging as hf_logging
27
+
28
+ hf_logging.set_verbosity_error()
29
+ except ImportError:
30
+ pass
31
+ _CONFIGURED = True
32
+ return logging.getLogger(name)