File size: 9,975 Bytes
79b0bef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4dc0836
79b0bef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4dc0836
79b0bef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4dc0836
79b0bef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4dc0836
 
79b0bef
 
4dc0836
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
"""
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