clinical-nlp-api / dashboard /api_client.py
Ayodeji Akande
Pin ruff config and fix the resulting lint findings
4dc0836
Raw
History Blame Contribute Delete
9.98 kB
"""
dashboard/api_client.py
────────────────────────────────────────────────────────────────
Typed HTTP client that the Streamlit pages use to talk to the
FastAPI backend.
Why a dedicated client module
──────────────────────────────
Streamlit pages should not contain raw requests.get() calls β€”
that scatters URL construction, error handling, and response
parsing across the codebase. This module is the single place
that knows the API's URL structure.
The pages import functions like get_stats() or analyse_note()
and receive typed dicts back. If the API changes, only this
file changes.
Configuration
─────────────
Set API_BASE_URL in Streamlit secrets or as an environment
variable. The default points to a local FastAPI instance for
development.
[secrets.toml]
API_BASE_URL = "https://your-api.railway.app"
Error handling
──────────────
All functions return None (or an empty structure) on failure
and log the error rather than raising β€” this keeps the
dashboard alive even when the API is temporarily unavailable.
────────────────────────────────────────────────────────────────
"""
from __future__ import annotations
import os
from typing import Any
import requests
import streamlit as st
from src.utils.logger import get_logger
logger = get_logger(__name__)
# ── Base URL ──────────────────────────────────────────────────────
# Read from Streamlit secrets first (production), then environment
# variable, then fall back to localhost for development.
def _base_url() -> str:
"""Return the FastAPI base URL from secrets or environment."""
try:
return st.secrets.get("API_BASE_URL", "http://localhost:8000")
except Exception:
return os.getenv("API_BASE_URL", "http://localhost:8000")
# Default timeout for all requests (seconds)
_TIMEOUT = 30
# /notes/analyse lazily loads the NER pipeline, ICD-10 mapper (incl. the
# sentence-transformer embedding model), and severity classifier on its
# first call in a freshly started backend process -- measured cold-start
# cost is ~110-140s. _TIMEOUT (30s) is fine for every other endpoint but
# would always time out the very first analyse request, which is the
# one a user hits immediately after starting the backend.
_ANALYSE_TIMEOUT = 180
# ── Health ────────────────────────────────────────────────────────
def check_health() -> dict[str, Any]:
"""Check whether the API backend is reachable.
Returns:
Health response dict, or ``{"status": "unreachable"}`` on error.
"""
try:
resp = requests.get(
f"{_base_url()}/health", timeout=5
)
resp.raise_for_status()
return resp.json()
except Exception as exc:
logger.warning("API health check failed: %s", exc)
return {"status": "unreachable", "database": "unknown"}
# ── Note analysis ─────────────────────────────────────────────────
def analyse_note(
text: str,
include_icd10: bool = True,
include_severity: bool = True,
) -> dict[str, Any] | None:
"""Send a clinical note to the API for full analysis.
Args:
text : Clinical note text to analyse.
include_icd10 : Whether to run ICD-10 mapping.
include_severity : Whether to run severity classification.
Returns:
AnalyseResponse dict, or None on error.
"""
try:
resp = requests.post(
f"{_base_url()}/notes/analyse",
json = {
"text": text,
"include_icd10": include_icd10,
"include_severity": include_severity,
},
timeout = _ANALYSE_TIMEOUT,
)
resp.raise_for_status()
return resp.json()
except requests.exceptions.Timeout:
logger.error("analyse_note timed out after %ds", _ANALYSE_TIMEOUT)
return None
except Exception as exc:
logger.error("analyse_note failed: %s", exc)
return None
# ── Stats ─────────────────────────────────────────────────────────
def get_stats() -> dict[str, Any] | None:
"""Fetch aggregate statistics for the dashboard overview.
Returns:
StatsResponse dict, or None on error.
"""
try:
resp = requests.get(
f"{_base_url()}/notes/stats/overview",
timeout = _TIMEOUT,
)
resp.raise_for_status()
return resp.json()
except Exception as exc:
logger.error("get_stats failed: %s", exc)
return None
# ── Notes list ────────────────────────────────────────────────────
def list_notes(
specialty: str | None = None,
severity: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict[str, Any] | None:
"""Fetch a paginated list of stored notes.
Args:
specialty : Filter by medical specialty.
severity : Filter by severity label.
limit : Max records per page.
offset : Pagination offset.
Returns:
NoteListResponse dict, or None on error.
"""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if specialty:
params["specialty"] = specialty
if severity:
params["severity"] = severity
try:
resp = requests.get(
f"{_base_url()}/notes",
params = params,
timeout = _TIMEOUT,
)
resp.raise_for_status()
return resp.json()
except Exception as exc:
logger.error("list_notes failed: %s", exc)
return None
# ── Entities ──────────────────────────────────────────────────────
def get_top_entities(
label: str | None = None,
limit: int = 20,
) -> list[dict[str, Any]]:
"""Fetch the most frequently extracted entities.
Args:
label : Filter to one entity type (e.g. ``"DISEASE"``).
limit : Number of top entities to return.
Returns:
List of ``{"text": str, "count": int}`` dicts.
"""
params: dict[str, Any] = {"limit": limit}
if label:
params["label"] = label
try:
resp = requests.get(
f"{_base_url()}/entities/top",
params = params,
timeout = _TIMEOUT,
)
resp.raise_for_status()
data = resp.json()
return data.get("items", [])
except Exception as exc:
logger.error("get_top_entities failed: %s", exc)
return []
def get_cooccurrence_pairs(
label: str = "DISEASE",
min_count: int = 5,
limit: int = 100,
) -> list[dict[str, Any]]:
"""Fetch entity co-occurrence pairs for the network graph.
Args:
label : Entity type to analyse.
min_count : Minimum co-occurrence count for inclusion.
limit : Maximum pairs to return.
Returns:
List of ``{"source": str, "target": str, "weight": int}`` dicts.
"""
try:
resp = requests.get(
f"{_base_url()}/entities/cooccurrence",
params = {
"label": label,
"min_count": min_count,
"limit": limit,
},
timeout = _TIMEOUT,
)
resp.raise_for_status()
return resp.json()
except Exception as exc:
logger.error("get_cooccurrence_pairs failed: %s", exc)
return []
# ── ICD-10 ────────────────────────────────────────────────────────
def get_model_metrics(task: str = "severity") -> dict[str, Any] | None:
"""Fetch training metrics for the currently deployed classifier.
Args:
task: Classification task to look up (default: "severity").
Returns:
ModelMetricsResponse dict, or None if unreachable or no run
has been recorded yet (API returns 404 in that case).
"""
try:
resp = requests.get(
f"{_base_url()}/model/metrics",
params = {"task": task},
timeout = _TIMEOUT,
)
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
except Exception as exc:
logger.error("get_model_metrics failed: %s", exc)
return None
def lookup_icd10(text: str, top_k: int = 3) -> dict[str, Any] | None:
"""Map a free-text entity to ICD-10 candidates.
Args:
text : Entity text to map (e.g. ``"hypertension"``).
top_k : Number of candidate codes to return.
Returns:
ICD10LookupResponse dict, or None on error.
"""
try:
resp = requests.post(
f"{_base_url()}/icd/lookup",
json = {"text": text, "top_k": top_k},
timeout = _TIMEOUT,
)
resp.raise_for_status()
return resp.json()
except Exception as exc:
logger.error("lookup_icd10 failed: %s", exc)
return None