Spaces:
Running
Running
File size: 12,620 Bytes
79b0bef 4dc0836 79b0bef 4dc0836 79b0bef 4dc0836 79b0bef | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 | """
src/api/routes/notes.py
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Endpoints for clinical note analysis and retrieval.
Routes
ββββββ
POST /notes/analyse β analyse a pasted clinical note
GET /notes β list stored notes (paginated)
GET /notes/{id} β retrieve one note with its entities
GET /notes/stats β aggregate counts for the dashboard
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
"""
from __future__ import annotations
import time
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from src.api.schemas import (
AnalyseRequest,
AnalyseResponse,
EntityResponse,
ICD10MatchResponse,
NoteDetail,
NoteListResponse,
NoteSummary,
SeverityDistribution,
SeverityResponse,
SpecialtyCount,
StatsResponse,
TopEntityItem,
)
from src.db.connection import get_db_session
from src.db.repository import EntityRepository, ICD10Repository, NoteRepository
from src.etl.transform import prepare_for_inference
from src.utils.logger import get_logger
from src.utils.text_utils import word_count
logger = get_logger(__name__)
router = APIRouter(prefix="/notes", tags=["Notes"])
# NER pipeline and ICD mapper are initialised once at module level
# so the model is not reloaded on every request.
# They are loaded lazily β the first request will be slower.
_ner_pipeline = None
_icd_mapper = None
_classifier = None
def _get_ner():
"""Return the module-level NER pipeline, loading it if needed."""
global _ner_pipeline
if _ner_pipeline is None:
from src.nlp.ner import build_ner_pipeline
_ner_pipeline = build_ner_pipeline()
return _ner_pipeline
def _get_mapper():
"""Return the module-level ICD-10 mapper, loading it if needed."""
global _icd_mapper
if _icd_mapper is None:
from src.nlp.icd_mapper import ICD10Mapper
try:
_icd_mapper = ICD10Mapper()
except FileNotFoundError:
logger.warning(
"ICD-10 reference file not found β "
"ICD-10 mapping will return empty results."
)
return _icd_mapper
def icd10_embedding_available() -> bool:
"""Whether semantic (embedding-based) ICD-10 matching is usable.
Used by the ``/health`` endpoint to surface degraded-mode state
rather than leaving it silently indistinguishable from "no match
found". ``True`` if the mapper hasn't loaded yet (not yet known to
be broken) or has loaded successfully; ``False`` only after a real
load attempt has failed.
"""
mapper = _icd_mapper # don't trigger a load just to check status
if mapper is None:
return True
return mapper.embedding_available
def _get_classifier():
"""Return the module-level classifier, loading it if needed."""
global _classifier
if _classifier is None:
from src.nlp.classifier import ClinicalClassifier
clf = ClinicalClassifier()
try:
clf.load()
_classifier = clf
except Exception as exc:
logger.warning(
"Classifier could not be loaded (%s) β "
"severity prediction will be skipped.", exc
)
return _classifier
def warm_up() -> None:
"""Eagerly load the NER pipeline, ICD-10 mapper, and classifier.
Call this during application startup (see ``src/api/main.py``'s
lifespan) rather than letting them load lazily on first request.
Combined model-loading cost is ~110-140s β deferring it means
whichever user sends the first request eats that entire delay, and
most HTTP clients (including the dashboard) have a much shorter
timeout and will report a false "API did not respond" error well
before loading actually finishes. Paying this cost once at startup
means no real request is ever the slow one.
"""
logger.info("Warming up NLP pipeline (NER, ICD-10 mapper, classifier)...")
t_start = time.perf_counter()
# Constructing these wrapper objects is cheap -- the actual heavy
# loading (spaCy's .load(), the sentence-transformer embedding model
# + 74k-description index) only happens lazily on first real use.
# Force that now with a real dummy call, rather than just
# constructing the wrappers and leaving the expensive part for
# whichever request happens to be first.
ner = _get_ner()
if ner:
ner.extract("Patient has hypertension.")
mapper = _get_mapper()
if mapper:
# Deliberately obscure text guaranteed not to exact- or
# fuzzy-match any ICD-10 description, forcing the embedding
# fallback (and therefore the embedding model + index load)
# to actually run now instead of on the first real request.
mapper.map("zzqxv nonexistent fictional placeholder condition")
_get_classifier()
logger.info("Warm-up complete in %.1fs", time.perf_counter() - t_start)
# ββ Analyse βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.post(
"/analyse",
response_model = AnalyseResponse,
summary = "Analyse a clinical note",
description = (
"Extract named entities, map to ICD-10 codes, and classify "
"severity from a pasted clinical note. Does not persist to "
"the database β for real-time demo use."
),
)
def analyse_note(
payload: AnalyseRequest,
) -> AnalyseResponse:
"""Analyse a single clinical note in one round trip.
Runs the NER pipeline, optionally the ICD-10 mapper, and
optionally the severity classifier. Results are returned
immediately and are not stored in the database.
Args:
payload: :class:`AnalyseRequest` with the note text and
feature flags.
Returns:
:class:`AnalyseResponse` with entities, ICD-10 mappings,
and severity prediction.
"""
t_start = time.perf_counter()
# Step 1: clean the input text
cleaned = prepare_for_inference(payload.text)
# Step 2: run NER
ner = _get_ner()
raw_entities = ner.extract(cleaned) if ner else []
# Step 3: ICD-10 mapping (optional)
icd_results: dict[str, list] = {}
if payload.include_icd10 and raw_entities:
mapper = _get_mapper()
if mapper:
icd_results = mapper.map_entities(raw_entities)
# Step 4: severity classification (optional)
severity_out = None
if payload.include_severity:
clf = _get_classifier()
if clf:
try:
result = clf.predict(cleaned)
severity_out = SeverityResponse(
label = result.label,
confidence = result.confidence,
probabilities = result.probabilities,
task = result.task,
)
except Exception as exc:
logger.warning("Severity prediction failed: %s", exc)
# Step 5: shape the response
entity_responses = []
entity_counts: dict[str, int] = {}
for ent in raw_entities:
icd_matches = [
ICD10MatchResponse(**m.to_dict())
for m in icd_results.get(ent.text, [])
]
entity_responses.append(EntityResponse(
text = ent.text,
label = ent.label,
start = ent.start,
end = ent.end,
confidence = ent.confidence,
icd10_matches = icd_matches,
))
entity_counts[ent.label] = entity_counts.get(ent.label, 0) + 1
processing_ms = (time.perf_counter() - t_start) * 1000
return AnalyseResponse(
text_length = len(cleaned),
word_count = word_count(cleaned),
entities = entity_responses,
entity_counts = entity_counts,
severity = severity_out,
processing_ms = round(processing_ms, 1),
)
# ββ List notes ββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get(
"",
response_model = NoteListResponse,
summary = "List stored clinical notes",
)
def list_notes(
specialty: str | None = Query(None, description="Filter by specialty"),
severity: str | None = Query(None, description="Filter by severity"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db_session),
) -> NoteListResponse:
"""Return a paginated list of stored clinical notes.
Optionally filtered by specialty or severity label.
"""
if specialty:
notes = NoteRepository.list_by_specialty(
db, specialty, limit=limit, offset=offset
)
elif severity:
notes = NoteRepository.list_by_severity(
db, severity, limit=limit, offset=offset
)
else:
# No filter β return all notes paginated
from src.db.models import ClinicalNote
notes = (
db.query(ClinicalNote)
.order_by(ClinicalNote.id)
.offset(offset)
.limit(limit)
.all()
)
total = NoteRepository.total(db)
return NoteListResponse(
total = total,
limit = limit,
offset = offset,
items = [NoteSummary.model_validate(n) for n in notes],
)
# ββ Note detail βββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get(
"/{note_id}",
response_model = NoteDetail,
summary = "Retrieve a single note",
)
def get_note(
note_id: int,
db: Session = Depends(get_db_session),
) -> NoteDetail:
"""Fetch a single stored note by its database ID.
Args:
note_id: Integer primary key.
Raises:
HTTPException 404: If no note with that ID exists.
"""
note = NoteRepository.get(db, note_id)
if note is None:
raise HTTPException(
status_code = 404,
detail = f"Note {note_id} not found",
)
return NoteDetail.model_validate(note)
# ββ Aggregate stats βββββββββββββββββββββββββββββββββββββββββββββββ
@router.get(
"/stats/overview",
response_model = StatsResponse,
summary = "Aggregate statistics for the dashboard",
)
def get_stats(
db: Session = Depends(get_db_session),
) -> StatsResponse:
"""Return aggregate counts and distributions for the dashboard.
Used by the overview page to populate summary cards and charts.
"""
severity_dist = NoteRepository.count_by_severity(db)
specialty_counts = NoteRepository.count_by_specialty(db)
top_diseases = EntityRepository.top_entities(db, label="DISEASE", limit=10)
top_icd10 = ICD10Repository.top_codes(db, limit=10)
total_notes = NoteRepository.total(db)
total_ents = db.execute(
__import__("sqlalchemy").text("SELECT COUNT(*) FROM entities")
).scalar() or 0
return StatsResponse(
total_notes = total_notes,
total_entities = total_ents,
severity_distribution = SeverityDistribution(
routine = severity_dist.get("routine", 0),
urgent = severity_dist.get("urgent", 0),
critical = severity_dist.get("critical", 0),
total = total_notes,
),
top_specialties = [
SpecialtyCount(specialty=k, count=v)
for k, v in list(specialty_counts.items())[:10]
],
top_diseases = [
TopEntityItem(text=t, count=c) for t, c in top_diseases
],
top_icd10_codes = [
ICD10MatchResponse(
icd10_code = code,
description = desc,
confidence = 1.0,
match_method = "lookup",
rank = i + 1,
)
for i, (code, desc, _) in enumerate(top_icd10)
],
)
|