Spaces:
Running
Running
| """ | |
| 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 | |