Spaces:
Sleeping
Sleeping
File size: 13,049 Bytes
c6cfe5f 14a3550 c6cfe5f cd5b31c c6cfe5f 14a3550 c6cfe5f 14a3550 5eed8db c6cfe5f d0ec498 5eed8db d0ec498 5eed8db 14a3550 d0ec498 c6cfe5f 14a3550 c6cfe5f cd5b31c 42e3031 d252dab 6008810 42e3031 207c2bb 6008810 d252dab 207c2bb 6008810 d252dab 207c2bb d252dab 42e3031 207c2bb 6008810 42e3031 d252dab 42e3031 e43bca6 14a3550 e43bca6 c6cfe5f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 | """FastAPI backend for the HCC-MEAT Coder Space.
Endpoints:
POST /api/code — submit a note, receive ranked HCC candidates + MEAT evidence
GET /api/gallery — return the preloaded demo gallery (note + precomputed result)
GET /api/healthz — simple health probe
GET /api/reference/hcc — static HCC label table for tooltip content in the UI
The app keeps a single `Pipeline` instance in state and reuses it across requests.
"""
from __future__ import annotations
import json
import logging
import traceback
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from app.api.metrics import snapshot as metrics_snapshot
from app.pipeline import Pipeline
from app.pipeline.types import CodingResult, Note
_LOG = logging.getLogger("hcc_meat.api")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
GALLERY_PATH = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "regression_suite.json"
UI_DIST = Path(__file__).resolve().parents[2] / "ui" / "dist"
class CodeRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=50_000)
note_id: str = Field(default="adhoc")
class GalleryItem(BaseModel):
case_id: str
name: str
tags: list[str]
text: str
result: CodingResult
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Build the pipeline and prime caches at startup.
Each step is wrapped defensively: if a step fails, we log the traceback
and leave the corresponding state empty so the app still boots. /api/status
reports this as `degraded` and the UI banner surfaces which facet failed.
A hard crash here would leave HF users with a 502 and no diagnostic path.
"""
app.state.startup_errors = {}
try:
app.state.pipeline = Pipeline()
_LOG.info("Pipeline built")
except Exception as e: # noqa: BLE001
app.state.pipeline = None
app.state.startup_errors["pipeline"] = repr(e)
_LOG.error("Pipeline build failed: %s\n%s", e, traceback.format_exc())
try:
app.state.gallery = (
_build_gallery(app.state.pipeline) if app.state.pipeline is not None else []
)
_LOG.info("Gallery primed: %d items", len(app.state.gallery))
except Exception as e: # noqa: BLE001
app.state.gallery = []
app.state.startup_errors["gallery"] = repr(e)
_LOG.error("Gallery build failed: %s\n%s", e, traceback.format_exc())
try:
app.state.metrics = metrics_snapshot() if app.state.pipeline is not None else {}
_LOG.info("Metrics snapshot computed")
except Exception as e: # noqa: BLE001
app.state.metrics = {}
app.state.startup_errors["metrics"] = repr(e)
_LOG.error("Metrics snapshot failed: %s\n%s", e, traceback.format_exc())
# Kick off LLM MEAT warm-up in the background when selected.
# Rule-based path serves until the LLM is ready; /api/status exposes the
# ready flag so the UI (and smoke tests) can see warm state.
import threading as _threading
app.state.meat_llm = None
extractor = getattr(app.state.pipeline, "meat_extractor", None) if app.state.pipeline else None
primary = getattr(extractor, "primary", None)
if primary is not None and hasattr(primary, "warm"):
app.state.meat_llm = primary
def _warm_meat_llm() -> None:
try:
primary.warm()
_LOG.info("MEAT LLM ready: %s", getattr(primary, "model_name", "?"))
except Exception as e: # noqa: BLE001
app.state.startup_errors["meat_llm"] = repr(e)
_LOG.error(
"MEAT LLM warm-up failed: %s\n%s", e, traceback.format_exc()
)
_threading.Thread(
target=_warm_meat_llm, name="meat-llm-warmup", daemon=True
).start()
yield
app = FastAPI(
title="HCC-MEAT Coder",
version="0.1.0",
description=(
"Suggests CMS-HCC v28 codes from a clinical note with MEAT-justified "
"evidence, calibrated confidence, and an over-coding risk flag."
),
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/healthz")
def healthz() -> dict:
return {"status": "ok", "version": app.version}
@app.get("/api/status")
def status() -> dict:
"""Runtime diagnostic: are reference CSVs loaded, pipeline built, gallery primed?
The UI polls this on startup so an HF-Space misdeploy (missing data dir,
startup crash) surfaces as a visible banner instead of a silent blank state.
"""
pipeline: Pipeline | None = getattr(app.state, "pipeline", None)
gallery: list[GalleryItem] = getattr(app.state, "gallery", []) or []
metrics: dict = getattr(app.state, "metrics", {}) or {}
crosswalk = pipeline.crosswalk if pipeline else None
ref_counts: dict = {}
if crosswalk is not None:
ref_counts = {
"hcc_labels": len(crosswalk.all_hccs()),
"hcc_crosswalk_icd10": len(crosswalk.all_icd10_in_crosswalk()),
}
ok = (
pipeline is not None
and ref_counts.get("hcc_labels", 0) > 0
and ref_counts.get("hcc_crosswalk_icd10", 0) > 0
and len(gallery) > 0
)
meat_llm = getattr(app.state, "meat_llm", None)
meat_llm_model = getattr(meat_llm, "model_name", None) if meat_llm else None
meat_llm_ready = bool(meat_llm and meat_llm.is_ready()) if meat_llm else None
return {
"status": "ok" if ok else "degraded",
"version": app.version,
"pipeline_ready": pipeline is not None,
"reference_counts": ref_counts,
"gallery_size": len(gallery),
"metrics_available": bool(metrics),
"meat_llm_configured": meat_llm is not None,
"meat_llm_model": meat_llm_model,
"meat_llm_ready": meat_llm_ready,
"startup_errors": getattr(app.state, "startup_errors", {}),
}
@app.post("/api/code", response_model=CodingResult)
def code(req: CodeRequest) -> CodingResult:
pipeline: Pipeline | None = getattr(app.state, "pipeline", None)
if pipeline is None:
raise HTTPException(503, "pipeline not ready — see /api/status")
note = Note(note_id=req.note_id, text=req.text)
return pipeline.run(note)
@app.get("/api/gallery", response_model=list[GalleryItem])
def gallery() -> list[GalleryItem]:
return app.state.gallery
@app.get("/api/metrics")
def metrics() -> dict:
"""Static snapshot built at startup. v0.1 derives from the regression fixture."""
return app.state.metrics
@app.post("/api/debug/pipeline")
def debug_pipeline(req: CodeRequest) -> dict:
"""Pipeline introspection: sections, spans, attributable spans, and
per-candidate MEAT extraction trace. Used to diagnose MEAT-coverage gaps
when the live Space disagrees with local expectations.
"""
pipeline: Pipeline | None = getattr(app.state, "pipeline", None)
if pipeline is None:
raise HTTPException(503, "pipeline not ready")
from app.pipeline.components import a_segmenter
from app.pipeline.components.c_negation import is_attributable
from app.pipeline.components.f_meat import _sentences_with_offsets
note = Note(note_id=req.note_id, text=req.text)
note = a_segmenter.segment(note)
spans = pipeline.ner(note)
spans = pipeline.negation(note, spans)
eligible = [s for s in spans if is_attributable(s)]
links = pipeline.linker(eligible)
candidates = pipeline.crosswalk.build_candidates(
links, raf_segment=pipeline.config.raf_segment
)
candidates = pipeline.crosswalk.apply_trumping(candidates)
sentences = _sentences_with_offsets(note.text)
extraction_trace: list[dict] = []
primary = getattr(pipeline.meat_extractor, "primary", None)
primary_has_try = primary is not None and hasattr(primary, "try_extract")
primary_ready = (
primary.is_ready() if primary is not None and hasattr(primary, "is_ready") else None
)
# Single LLM pass per candidate to keep latency sane: capture raw LLM
# output, then filter via the same substring rule Component I uses. No
# double-calling pipeline.meat_extractor.
for cand in candidates:
llm_raw: list[dict] = []
kept: list[dict] = []
if primary_has_try and primary_ready:
try:
res = primary.try_extract(note, cand)
if res is not None:
for ev in res:
in_note = ev.quote in note.text
llm_raw.append(
{
"cat": ev.category.value,
"quote": ev.quote,
"is_substring": in_note,
}
)
if in_note:
kept.append(
{"cat": ev.category.value, "quote": ev.quote[:120]}
)
except Exception as e: # noqa: BLE001
llm_raw = [{"error": f"{type(e).__name__}: {e}"}]
extraction_trace.append(
{
"hcc": cand.hcc,
"surface": [lnk.condition_span.text for lnk in cand.icd10_links],
"llm_raw_output": llm_raw,
"verified_evidence_preview": kept,
}
)
return {
"sections": [
{"kind": s.kind, "start": s.start, "end": s.end} for s in note.sections
],
"sentences": [{"start": s, "end": e, "text": t[:80]} for (s, e, t) in sentences],
"all_spans": [
{"text": s.text, "type": s.span_type.value, "start": s.start, "end": s.end}
for s in spans
],
"eligible_spans": len(eligible),
"candidates": [c.hcc for c in candidates],
"extractor_chain": {
"outer_type": type(pipeline.meat_extractor).__name__,
"primary_type": type(primary).__name__ if primary else None,
"primary_has_try_extract": primary_has_try,
"primary_ready": primary_ready,
},
"extraction_trace": extraction_trace,
}
@app.post("/api/benchmark")
def benchmark() -> dict:
"""Re-run every gallery note live (no precomputation) and summarize latency.
Exercises the CPU-free-tier latency target (S2 in AGENT_BRIEF §3): p95 < 8s.
"""
import statistics
import time
pipeline: Pipeline | None = getattr(app.state, "pipeline", None)
if pipeline is None:
raise HTTPException(503, "pipeline not ready — see /api/status")
items: list[GalleryItem] = app.state.gallery
latencies: list[float] = []
timings: list[dict] = []
for item in items:
t0 = time.perf_counter()
pipeline.run(Note(note_id=item.case_id, text=item.text))
ms = (time.perf_counter() - t0) * 1000.0
latencies.append(ms)
timings.append({"case_id": item.case_id, "ms": round(ms, 2), "chars": len(item.text)})
latencies.sort()
summary = {
"count": len(latencies),
"mean_ms": round(statistics.fmean(latencies), 2) if latencies else 0.0,
"median_ms": round(statistics.median(latencies), 2) if latencies else 0.0,
"p95_ms": round(latencies[int(0.95 * (len(latencies) - 1))], 2) if latencies else 0.0,
"max_ms": round(max(latencies), 2) if latencies else 0.0,
"min_ms": round(min(latencies), 2) if latencies else 0.0,
"target_p95_ms": 8000.0,
"timings": timings,
}
return summary
@app.get("/api/reference/hcc")
def reference_hcc() -> list[dict]:
"""Return HCC labels for UI tooltips + model card linking."""
import csv
path = Path("data/reference/hcc_v28_labels.csv")
if not path.exists():
raise HTTPException(500, "reference data missing")
with path.open() as f:
return list(csv.DictReader(f))
def _build_gallery(pipeline: Pipeline) -> list[GalleryItem]:
if not GALLERY_PATH.exists():
return []
cases = json.loads(GALLERY_PATH.read_text())["cases"]
out: list[GalleryItem] = []
for c in cases:
note = Note(note_id=c["case_id"], text=c["note_text"])
result = pipeline.run(note)
out.append(
GalleryItem(
case_id=c["case_id"],
name=c["name"],
tags=c.get("tags", []),
text=c["note_text"],
result=result,
)
)
return out
# Mount the built React UI *last* so /api/* routes win.
if UI_DIST.exists():
app.mount("/", StaticFiles(directory=UI_DIST, html=True), name="ui")
|