File size: 13,439 Bytes
8976277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8590075
8976277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81d72d8
 
 
8976277
 
717e91c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8976277
 
 
 
 
 
 
 
412208e
8976277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30aea61
 
 
 
 
 
 
8976277
30aea61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8976277
 
e6ed707
 
 
 
 
 
8976277
293d552
81d72d8
 
 
293d552
81d72d8
8976277
293d552
81d72d8
 
 
30b5994
 
 
 
 
 
 
 
 
 
 
81d72d8
 
30b5994
81d72d8
30b5994
81d72d8
 
293d552
8976277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ed12382
 
8976277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcc7b97
8976277
 
 
 
 
 
 
 
 
 
 
 
 
 
81d72d8
 
8976277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81d72d8
 
8976277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
ClinIQ — gr.Server backend.

Key feature: proactive Drug Safety Check runs automatically after upload
— no question needed. The 3B model acts as an on-device pharmacist.

Hackathon quests:
  🎨 Off-Brand        — gr.Server + full custom HTML/CSS/JS
  📡 Sharing is Caring — agent traces pushed to HF Hub dataset
  🦙 Llama Champion   — inference via llama.cpp on Modal
  🎯 Well-Tuned       — Qwen2.5-3B-Instruct from Hub
  🐜 Tiny Titan       — ≤4B model
"""

from __future__ import annotations

import base64
import datetime
import json
import os
import re
import tempfile
from pathlib import Path
from typing import Iterator, List, Tuple

import gradio as gr
from fastapi.responses import HTMLResponse, Response

from agent import build_graph, run_query, stream_query
from retriever import HybridRetriever
from safety_checker import run_safety_check, report_to_dict

# ── Config ─────────────────────────────────────────────────────────────────────

SAMPLE_DIR      = Path(__file__).parent / "sample_docs"
FRONTEND        = Path(__file__).parent / "frontend"
HF_TOKEN        = os.getenv("HF_TOKEN", "")
HF_DATASET_REPO = os.getenv("HF_DATASET_REPO", "karthikmulugu08/cliniq-traces")
MODAL_ENDPOINT  = os.getenv("MODAL_ENDPOINT", "")

# ── Global state ───────────────────────────────────────────────────────────────

_retriever   = HybridRetriever()
_graph       = build_graph(_retriever)
_raw_docs: List[Tuple[str, str]] = []   # (original_text, filename) for safety checker


def _auto_load_samples() -> None:
    """Pre-load sample docs at startup so the app is ready immediately after any restart."""
    if not SAMPLE_DIR.exists():
        return
    docs = [
        (p.read_text(encoding="utf-8", errors="ignore"), p.name)
        for p in sorted(SAMPLE_DIR.glob("*.txt"))
    ]
    if not docs:
        return
    _retriever.add_documents(docs)
    _raw_docs.clear()
    _raw_docs.extend(docs)
    print(f"[startup] Auto-loaded {len(docs)} sample docs → {len(_retriever.chunks)} chunks ready")

_auto_load_samples()


# ── Model call helper (shared by agent + safety checker) ──────────────────────

def _call_model(prompt: str, max_tokens: int = 600, json_mode: bool = False) -> str:
    import httpx
    if MODAL_ENDPOINT:
        resp = httpx.post(
            MODAL_ENDPOINT,
            json={"prompt": prompt, "max_tokens": max_tokens, "json_mode": json_mode},
            timeout=300,
        )
        resp.raise_for_status()
        return resp.json()["text"].strip()
    # Local CPU fallback
    import torch
    from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline as hf_pipeline
    MODEL_ID = os.getenv("MODEL_ID", "Qwen/Qwen2.5-3B-Instruct")
    if not hasattr(_call_model, "_pipe"):
        tok   = AutoTokenizer.from_pretrained(MODEL_ID)
        model = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.float32)
        _call_model._pipe = hf_pipeline("text-generation", model=model, tokenizer=tok)
    out = _call_model._pipe(prompt, max_new_tokens=max_tokens, do_sample=False,
                            temperature=None, top_p=None)
    return out[0]["generated_text"][len(prompt):].strip()


# ── Document helpers ───────────────────────────────────────────────────────────

def _read_file(path: str) -> str:
    """
    Extract text from TXT, PDF (native or scanned), or image files.
    - TXT/MD         → plain read
    - PDF with text  → pypdf fast extract
    - PDF scanned    → pdf2image + tesseract OCR (fallback)
    - JPG/PNG/TIFF   → tesseract OCR directly
    """
    p = Path(path)
    suffix = p.suffix.lower()

    # ── Plain text ────────────────────────────────────────────────────────────
    if suffix in (".txt", ".md", ".csv"):
        return p.read_text(encoding="utf-8", errors="ignore")

    # ── Native PDF (selectable text) ──────────────────────────────────────────
    if suffix == ".pdf":
        from pypdf import PdfReader
        pages = PdfReader(path).pages
        text = "\n".join(page.extract_text() or "" for page in pages).strip()
        if len(text) > 100:          # has real text → done
            return text
        # Scanned PDF — fall through to OCR below
        return _ocr_pdf(path)

    # ── Image files ───────────────────────────────────────────────────────────
    if suffix in (".jpg", ".jpeg", ".png", ".tiff", ".tif", ".bmp", ".webp"):
        return _ocr_image(path)

    # ── Unknown fallback ──────────────────────────────────────────────────────
    try:
        return p.read_text(encoding="utf-8", errors="ignore")
    except Exception:
        return ""


def _ocr_image(path: str) -> str:
    """Run tesseract OCR on a single image file."""
    try:
        import pytesseract
        from PIL import Image
        img = Image.open(path)
        # Upscale small images for better OCR accuracy
        w, h = img.size
        if w < 1000:
            scale = 1000 / w
            img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
        return pytesseract.image_to_string(img, lang="eng", config="--psm 6")
    except Exception as e:
        return f"[OCR failed: {e}]"


def _ocr_pdf(path: str) -> str:
    """Convert each PDF page to image then OCR (for scanned PDFs)."""
    try:
        import pytesseract
        from pdf2image import convert_from_path
        pages = convert_from_path(path, dpi=250)
        texts = []
        for page_img in pages:
            texts.append(pytesseract.image_to_string(page_img, lang="eng", config="--psm 6"))
        return "\n\n".join(texts)
    except Exception as e:
        return f"[OCR failed: {e}]"


def _sanitize(text: str) -> str:
    """Replace non-ASCII chars that break JSON serialization in llama-server."""
    import re
    return re.sub(r'[^\x00-\x7F]', '-', text)


def _build_full_context() -> str:
    """
    Build safety context from the original (un-chunked) document text.
    Extracts ALLERGIES and MEDICATIONS sections directly, preserving newlines
    so the regex in safety_checker can find them reliably.
    """
    if not _raw_docs:
        return ""

    parts = []
    for text, name in _raw_docs:
        text = _sanitize(text)
        # Flexible patterns covering many real-world clinical document formats
        am = re.search(
            r'((?:drug\s+|known\s+|food\s+)?(?:allergies?|allergy|sensitivities?|adverse\s+reactions?)'
            r'[^\n]*\n(?:.*\n?){1,10})',
            text, re.IGNORECASE
        )
        mm = re.search(
            r'((?:current\s+|active\s+|home\s+|discharge\s+)?(?:medications?|meds?|drugs?|prescriptions?|rx)'
            r'[^\n]*\n(?:.*\n?){1,15})',
            text, re.IGNORECASE
        )
        section = f"[{name}]"
        if am:
            section += "\n" + am.group(0)[:500].strip()
        if mm:
            section += "\n" + mm.group(0)[:500].strip()
        parts.append(section)

    return "\n\n---\n\n".join(parts)


# ── HF Hub trace sharing ───────────────────────────────────────────────────────

def _push_trace(question: str, answer: str, trace: list, structured_data=None) -> str:
    if not HF_TOKEN:
        return ""
    try:
        from huggingface_hub import HfApi
        api = HfApi(token=HF_TOKEN)
        api.create_repo(HF_DATASET_REPO, repo_type="dataset", exist_ok=True)
        record = {
            "timestamp":      datetime.datetime.utcnow().isoformat() + "Z",
            "question":       question,
            "answer":         answer,
            "structured_data": json.dumps(structured_data) if structured_data else None,
            "trace":          json.dumps(trace),
            "model":          "Qwen2.5-3B-Instruct (llama.cpp Q4_K_M)",
        }
        fname = f"traces/trace_{datetime.datetime.utcnow().strftime('%Y%m%d_%H%M%S_%f')}.json"
        api.upload_file(
            path_or_fileobj=json.dumps(record, indent=2).encode(),
            path_in_repo=fname,
            repo_id=HF_DATASET_REPO,
            repo_type="dataset",
            commit_message=f"ClinIQ agent trace: {question[:60]}",
        )
        return f"https://huggingface.co/datasets/{HF_DATASET_REPO}/blob/main/{fname}"
    except Exception as e:
        return f"(trace share error: {e})"


# ── gr.Server ──────────────────────────────────────────────────────────────────

app = gr.Server()


@app.api(name="ingest")
def ingest(files_json: str) -> str:
    items = json.loads(files_json)
    docs: List[Tuple[str, str]] = []
    for item in items:
        raw    = base64.b64decode(item["content_b64"])
        suffix = Path(item["name"]).suffix or ".txt"
        with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf:
            tf.write(raw); tf.flush()
            text = _read_file(tf.name)
        docs.append((text, item["name"]))
    _retriever.add_documents(docs)
    _raw_docs.clear()
    _raw_docs.extend(docs)
    return json.dumps({
        "status": "ok",
        "files": len(docs),
        "chunks": len(_retriever.chunks),
        "names": [d[1] for d in docs],
    })


@app.api(name="load_samples")
def load_samples() -> str:
    if not SAMPLE_DIR.exists():
        return json.dumps({"status": "error", "message": "sample_docs/ not found"})
    docs = [(p.read_text(encoding="utf-8", errors="ignore"), p.name)
            for p in sorted(SAMPLE_DIR.glob("*.txt"))]
    if not docs:
        return json.dumps({"status": "error", "message": "No sample files found"})
    _retriever.add_documents(docs)
    _raw_docs.clear()
    _raw_docs.extend(docs)
    return json.dumps({
        "status": "ok",
        "files":  len(docs),
        "chunks": len(_retriever.chunks),
        "names":  [d[1] for d in docs],
    })


@app.api(name="safety_check")
def safety_check() -> str:
    """
    Proactive drug safety check — runs after document ingestion.
    No question needed. Returns colour-coded alerts.
    """
    if not _retriever.ready:
        return json.dumps({"error": "No documents indexed yet."})
    context = _build_full_context()
    report  = run_safety_check(context, _call_model)
    return json.dumps(report_to_dict(report))


@app.api(name="ask_stream")
def ask_stream(question: str) -> Iterator[str]:
    if not question.strip():
        yield json.dumps({"type": "error", "message": "Empty question"}); return
    if not _retriever.ready:
        yield json.dumps({"type": "error", "message": "No documents indexed."}); return

    final_answer, final_trace, structured = "", [], None
    for event in stream_query(_graph, question):
        if event["type"] == "trace_step":
            final_trace.append(event["step"])
        elif event["type"] == "answer":
            final_answer = event["answer"]
            structured   = event.get("structured_data")
        yield json.dumps(event)

    trace_url = _push_trace(question, final_answer, final_trace, structured)
    yield json.dumps({"type": "done", "trace_url": trace_url})


@app.api(name="ask")
def ask(question: str) -> str:
    if not question.strip():
        return json.dumps({"error": "Empty question"})
    if not _retriever.ready:
        return json.dumps({"error": "No documents indexed."})
    result    = run_query(_graph, question)
    trace_url = _push_trace(question, result["answer"], result["trace"],
                            result.get("structured_data"))
    return json.dumps({
        "answer":          result["answer"],
        "structured_data": result.get("structured_data"),
        "query_type":      result["query_type"],
        "trace":           result["trace"],
        "chunks":          [{"source": c.source, "excerpt": c.text[:350]}
                            for c in result["chunks"][:4]],
        "trace_url":       trace_url,
    })


# ── Static frontend ────────────────────────────────────────────────────────────

@app.get("/")
async def homepage():
    return HTMLResponse((FRONTEND / "index.html").read_text(encoding="utf-8"))

@app.get("/style.css")
async def css():
    return Response((FRONTEND / "style.css").read_text(encoding="utf-8"),
                    media_type="text/css")


if __name__ == "__main__":
    app.launch(server_name="0.0.0.0", server_port=7860)