cliniq / app.py
karthikmulugu08's picture
Fix: serialize structured_data and trace as JSON strings in dataset traces
ed12382 verified
Raw
History Blame Contribute Delete
13.4 kB
"""
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)