Spaces:
Running
Running
| """ | |
| 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() | |
| 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], | |
| }) | |
| 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], | |
| }) | |
| 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)) | |
| 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}) | |
| 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 ──────────────────────────────────────────────────────────── | |
| async def homepage(): | |
| return HTMLResponse((FRONTEND / "index.html").read_text(encoding="utf-8")) | |
| 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) | |