# Project export: MedRAG

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: AI-powered medication decision support — synthesizing patient FHIR data and RAG-retrieved clinical knowledge to help physicians prescribe safely.
- Devpost: https://devpost.com/software/medrag-m4x6qv
- GitHub: https://github.com/anthonychen1925/MedRAG
- Video: https://www.youtube.com/embed/ME1FFIYFea4?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — Anthony Chen (11 commits), Cursor (1 commits)

## Devpost submission (written by the team)

### Inspiration

Medication errors are one of the most preventable causes of patient harm, but managing polypharmacy in real time is genuinely hard. A physician considering a new drug for a patient on ten medications with kidney disease can't realistically cross-reference every interaction and contraindication on the spot. We wanted to build a tool that takes a patient's record, a proposed drug, and a clinical question, and returns a structured, grounded safety report.

### What it does

MedRAG lets a physician upload a FHIR R4 patient bundle, enter a proposed medication and their clinical question, and receive a structured safety report covering drug–drug interactions, contraindications, lab flags, and monitoring recommendations. Every claim is cited back to a retrieved source — openFDA drug labels, DDInter 2.0 interaction pairs, or openFDA FAERS adverse-event data — and the system explicitly flags what it doesn't know. The physician makes the final call.

### How we built it

We built a RAG pipeline backed by Redis 8 native vector sets. An ingestion script pulls FDA drug labels, DDInter interaction pairs, and FAERS adverse-event signals for ~270 generics, chunks and embeds them with a local BGE model, and stores them in Redis. At query time, we parse the uploaded FHIR bundle, run source-balanced retrieval with per-source quotas to ensure interaction, label, and safety data are all represented, assemble a structured prompt, and send it to Claude Opus 4.8. The report comes back with numbered chunk citations that the Flask UI renders as clickable links to the original sources. Challenges we faced We ran the BGE embedding model entirely on CPU, which made ingestion slow and required careful optimization to get through ~20k chunks in a reasonable time. We also had no access to real patient data since it's private, so we relied entirely on synthetic FHIR bundles generated by Synthea to build and test the pipeline.

### Accomplishments we're proud of

We're also proud that the system is honest about its limits: it never tells a physician an interaction wasn't found when it really means the pair wasn't in the index.

### What we learned

Local embeddings with BAAI/bge-large-en-v1.5 gave us strong retrieval quality at zero API cost, which made rapid iteration during development practical.

## README (from the GitHub repository)

# MedRAG — Medication Decision Support via RAG

A Retrieval-Augmented Generation system that helps physicians evaluate whether a new medication is appropriate for a specific patient, given their medical history, active diagnoses, and current medication regimen.

---

## What This System Does

When a physician is considering prescribing a new drug, MedRAG:

1. Accepts a **FHIR R4 patient bundle** (`.json`) uploaded by the physician and parses it into a structured clinical summary.
2. Accepts a **proposed medication** and a **free-text clinical question** from the physician.
3. Retrieves the most relevant medical knowledge from a curated index (openFDA drug labels, DDInter 2.0 interaction pairs, openFDA FAERS adverse-event signals).
4. Feeds the parsed patient record + retrieved documents to **Claude Opus 4.8**, which reasons over the combined context.
5. Returns a structured safety report covering interactions, contraindications, relevant lab flags, monitoring recommendations, and a top-line recommendation.

The physician reviews this report and makes the final prescribing decision.

---

## System Architecture

```
── INDEX BUILD (ingest.py, runs once) ───────────────────────────────

Three sources (free, citable):
  • openFDA drug labels      (per-drug safety/dosing)
  • DDInter 2.0              (severity-rated interaction pairs)
  • openFDA FAERS            (real-world adverse-event signals)
  ▼
Chunker
  │  Section-level chunking with metadata
  │  (source, drug name, section type, date, verification URL)
  ▼
BGE  (BAAI/bge-large-en-v1.5, local, 1024-dim)
  │  Embed each chunk → vector   (no API cost)
  ▼
Redis 8 Vector Set  (redis-py client)
  │  Store vectors + JSON attributes (VADD); search with VSIM,
  │  with server-side attribute FILTER for source/section

── QUERY (runs per physician request) ───────────────────────────────

Physician Input
  │  (1) FHIR R4 .json file upload
  │  (2) Proposed medication (name, dose, indication)
  │  (3) Free-text clinical question
  ▼
FHIR Parser
  │  Extract: Patient, MedicationRequest, Condition,
  │           AllergyIntolerance, Observation resources
  │  Output: structured patient record + data quality flags
  ▼
Source-balanced Retrieval (retrieval.py)
  │  Multiple targeted BGE-embedded queries with guaranteed quotas:
  │   • per-pair interaction chunk for EACH current medication
  │   • proposed drug's label safety profile
  │   • renal dose-adjustment chunk (if reduced kidney function)
  │   • proposed drug's FAERS adverse-event signal
  │   • general fill to the remaining budget
  ▼
Prompt Assembler
  │  Combine: parsed patient record + retrieved chunks +
  │           proposed medication + physician question
  ▼
Claude Opus 4.8  ◄──── CONTEXT.md defines behavior here
  │  Reason, synthesize, flag gaps, apply severity scale,
  │  cite each grounded claim as [chunk N]
  ▼
Structured Report  (with clickable citations → source URLs)
  │  Recommendation · Interactions · Contraindications ·
  │  Lab Flags · Monitoring · Alternatives · Uncertainty
  │  References panel: DailyMed · DDInter drug page · FAERS viewer
  ▼
Physician Review Interface (Flask, app.py — single process serves UI + pipeline)
```

---

## How It Runs (Runtime)

MedRAG is a **single-process Flask application**. There is no separate frontend server — `app.py` renders the styled UI and orchestrates the full pipeline in one Python process. The static files in `frontend/` are design mockups only; the live UI is embedded in `app.py`.

### Processes required at runtime

| Process | Purpose | Start command |
|---|---|---|
| **Redis 8** | Stores ~20k embedded knowledge chunks | `brew services start redis` or `redis-server` |
| **Flask app** | UI + pipeline orchestration | `source .venv/bin/activate && python app.py` |

The **BGE embedding model** is loaded into RAM when `app.py` starts (not on every request). You will see:

```
[MedRAG] Loading embedder…
[MedRAG] Embedder ready.
* Running on http://127.0.0.1:5001
```

The app listens on **port 5001** (5000 is often occupied by macOS AirPlay). Open **http://127.0.0.1:5001** in a browser.

> **Use the `.venv` Python.** Running `python app.py` from conda base or system Python will fail at analysis time because `sentence-transformers` is installed in the project venv only.

### Startup sequence (`python app.py`)

1. Load settings from `.env` via `config.py`
2. Preload **BGE** (`BAAI/bge-large-en-v1.5`) — takes ~10–30 seconds on first start
3. Start Flask on `127.0.0.1:5001` (debug mode on, auto-reloader **off** — the reloader conflicts with ML model loading)

The embedder is cached as a **process singleton** in `embeddings.py` so it is loaded once per server lifetime.

### Per-request flow (when you submit an analysis)

1. **FHIR parse** (`fhir_parser.py`) — uploaded `.json` (or demo patient) → structured patient record + data quality flags
2. **Retrieval** (`retrieval.py`) — multiple targeted vector searches against Redis (~14 chunks max by default):
   - Best interaction chunk per current medication (DDInter)
   - Proposed drug's label safety sections (openFDA → DailyMed)
   - Renal dose chunk if patient has reduced kidney function
   - FAERS adverse-event signal for the proposed drug
   - General semantic fill to the remaining budget
3. **Prompt assembly** (`prompt_assembly.py`) — patient + chunks + proposed drug + question
4. **Claude Opus 4.8** (`api_client.py`) — `CONTEXT.md` as system prompt; returns markdown report with `[chunk N]` citations (~30–60 s)
5. **Report render** (`app.py`) — markdown parsed into styled section cards; citations link to References & Sources panel

### Index build vs. query

| Step | When | Command |
|---|---|---|
| **Index build** | One-time (or when drugs/sources change) | `python ingest.py --recreate` (~25 min, ~20k chunks) |
| **Query / UI** | Every session | `python app.py` (requires Redis already populated) |

`ingest.py` is **not** run on every startup.

## Tech Stack

### Core pipeline

| Layer | Technology | Role |
|---|---|---|
| **Language** | Python 3.11+ | Entire backend pipeline, ingestion, retrieval, and web server |
| **Reasoning engine** | [Anthropic SDK](https://github.com/anthropics/anthropic-sdk-python) → **Claude Opus 4.8** (`claude-opus-4-8`) | Reads retrieved chunks + patient record; generates structured safety report. System prompt = `CONTEXT.md`. |
| **Embedding model (default)** | [sentence-transformers](https://github.com/UKPLab/sentence-transformers) → **BGE** `BAAI/bge-large-en-v1.5` (1024-dim, via [Hugging Face Hub](https://huggingface.co/BAAI/bge-large-en-v1.5)) | Local document/query embeddings for semantic search — no per-embedding API cost |
| **Embedding model (optional)** | [Voyage AI SDK](https://github.com/voyage-ai/voyageai-python) → `voyage-3-large` | Cloud embeddings when `EMBED_PROVIDER=voyage` |
| **Vector database** | **Redis 8** native **vector sets** via [redis-py](https://github.com/redis/redis-py) (`redis>=5.0.0`) | Stores ~20k chunk embeddings + JSON metadata. Commands: `VADD`, `VSIM`, `VGETATTR`, `VSETATTR`, `VCARD`. Cosine KNN with server-side `FILTER` on attributes. |
| **Numerics** | [NumPy](https://numpy.org/) | Vector serialization (FLOAT32), stub embedder, similarity ops |
| **Config** | [python-dotenv](https://github.com/theskumar/python-dotenv) | Loads `.env` secrets and tuning parameters |
| **HTTP client** | [Requests](https://requests.readthedocs.io/) | Fetches openFDA APIs and DDInter CSVs during ingestion |

### Web UI & orchestration

| Layer | Technology | Role |
|---|---|---|
| **Web framework** | [Flask](https://flask.palletsprojects.com/) 3.x (`app.py`) | Single process: serves UI, orchestrates pipeline, hosts FAERS viewer at `/source/faers/<drug>` |
| **Templating** | Jinja2 (via Flask `render_template_string`) | Server-side HTML for setup page, report cards, and FAERS viewer |
| **CSS / styling** | [Tailwind CSS](https://tailwindcss.com/) (CDN) | Dark glass-panel "bento" layout |
| **Typography

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 15 recognized source files, 198 KB.
- Anthropic (technology) — detected in the code
- Flask (technology) — detected in the code
- HTML (language) — detected in the code
- Python (language) — detected in the code
- Redis (technology) — detected in the code
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (23 of 23)

```
.env.example
.gitignore
api_client.py
app.py
config.py
CONTEXT.md
data/drug_list.txt
embeddings.py
fhir_parser.py
frontend/index.html
frontend/report.html
frontend/setup.html
ingest.py
LICENSE
prompt_assembly.py
README.md
requirements.txt
retrieval.py
run_case.py
synthetic_patients/ckd4_metformin_contraindication.json
synthetic_patients/elderly_polypharmacy.json
synthetic_patients/low_risk_statin_control.json
vector_store.py
```

### Dependencies

- requirements.txt: anthropic@>=0.40.0, flask@>=3.0.0, numpy@>=1.26.0, python-dotenv@>=1.0.0, redis@>=5.0.0, requests@>=2.31.0, sentence-transformers@>=3.0.0, voyageai@>=0.3.0

### Recent commits (newest first)

- Update README and Context
- Update README and context, fix faers link to be human readable. Project MVP is completed
- app.py and frontend changed
- Updated README and context file
- Fixed links for DDInter 2.0
- Stop tracking generated/licensed data (dump.rdb, DDInter CSVs)
- Merge remote-tracking branch 'origin/main'
- Increased chunks in vector DB
- Add frontend: landing page, patient setup, and clinical safety report
- RAG prototype
- Add files via upload
- Add files via upload
- Initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### CONTEXT.md

```markdown
# MedRAG: Medication Decision Support System — Context for Claude Opus 4.8

## What You Are

You are the reasoning engine inside **MedRAG**, a Retrieval-Augmented Generation (RAG) system designed to assist licensed physicians in making informed decisions about prescribing new medications to patients. You are **not** a replacement for clinical judgment — you are a decision-support tool that synthesizes retrieved medical knowledge with patient-specific context and surfaces the most relevant safety considerations.

---

## System Stack

MedRAG is built from the following tools and frameworks. You do not call these directly — they run upstream of you — but understanding them helps you interpret what the retrieved context represents and what the system can and cannot do.

### Runtime infrastructure

| Component | Technology | Role |
|---|---|---|
| **Web server / orchestrator** | Flask (`app.py`) | Receives physician input, runs parse → retrieve → prompt → your API call → renders report |
| **Vector database** | Redis 8 (native vector sets via `redis-py`) | Stores ~20k embedded knowledge chunks; queried with `VSIM` cosine KNN + attribute `FILTER` |
| **Embedding model (default)** | BGE `BAAI/bge-large-en-v1.5` via `sentence-transformers` (Hugging Face Hub) | Embeds retrieval queries at request time; preloaded once at server startup |
| **Embedding model (optional)** | Voyage AI `voyage-3-large` | Cloud alternative when `EMBED_PROVIDER=voyage` |
| **Reasoning engine** | Claude Opus 4.8 via Anthropic SDK | **You.** System prompt = this file (`CONTEXT.md`) |
| **Config** | `python-dotenv` + `.env` | API keys, Redis URL, retrieval tuning |
| **Numerics** | NumPy | Vector serialization and similarity operations |

### Patient input

| Component | Technology | Role |
|---|---|---|
| **Patient data format** | FHIR R4 Bundle (`.json`) | Uploaded by physician or loaded from demo/CLI |
| **Parser** | `fhir_parser.py` | Extracts `Patient`, `MedicationRequest`, `Condition`, `AllergyIntolerance`, `Observation` |
| **Coding systems** | LOINC (labs/vitals), ICD-10 (diagnoses) | Parsed from FHIR resources when present |
| **Demo data generator** | Synthea | Recommended for synthetic FHIR R4 test patients |

### Knowledge base (indexed in Redis)

| Source | Access | What it contributes |
|---|---|---|
| **openFDA Drug Label API** | `api.fda.gov/drug/label.json` | FDA structured product labels (contraindications, warnings, dosing, etc.) — cited via **DailyMed** |
| **DDInter 2.0** | Downloadable CSVs + web drug-detail pages | Severity-rated drug–drug interaction pairs (Major / Moderate / Minor) |
| **openFDA FAERS API** | `api.fda.gov/drug/event.json` | Most-reported adverse events per drug (spontaneous reports; signal only) — cited via MedRAG FAERS viewer |

Index built by `ingest.py` using **Requests** to fetch APIs/CSVs, **BGE** to embed chunks, and **Redis `VADD`** to store vectors + metadata. ~270 drugs from `data/drug_list.txt`, ~20k chunks total.

### UI renderi
[truncated — 16463 more characters]
```

### requirements.txt

```
# Core pipeline
redis>=5.0.0                  # Redis 8 client (native vector sets: VADD/VSIM/VGETATTR)
sentence-transformers>=3.0.0  # local BGE embeddings (default, no API cost)
anthropic>=0.40.0             # Claude Opus 4.8 reasoning engine

# Optional cloud embedding provider (only needed if EMBED_PROVIDER=voyage)
voyageai>=0.3.0               # voyage-3-large embeddings

# Web framework + utilities
flask>=3.0.0            # physician-facing UI / orchestration
python-dotenv>=1.0.0    # load API keys / config from .env
requests>=2.31.0        # openFDA / DailyMed ingestion
numpy>=1.26.0           # vector ops + stub embedder

```

### app.py

```python
"""MedRAG web UI.

A single-file Flask app that orchestrates the pipeline:
  FHIR upload -> parse -> retrieve -> assemble prompt -> Claude -> report.

The UI keeps the dark, glass-panel "bento" aesthetic of the frontend/ mockups
(index.html, setup.html, report.html) while being fully wired to the backend:
Claude's markdown report is parsed into sections and rendered as styled cards,
with the recommendation shown as a status badge and clickable [chunk N] citations
linking to a References & Sources panel.

Run:
    python app.py
then open http://127.0.0.1:5001
"""

from __future__ import annotations

import html
import json
import re
import traceback
import urllib.parse

from flask import Flask, abort, render_template_string, request, url_for

from config import settings
from fhir_parser import parse_fhir_bundle
from prompt_assembly import build_prompt
from ingest import FAERS_TOP_N, OPENFDA_EVENT_URL, fetch_faers_reactions
from retrieval import retrieve_chunks

app = Flask(__name__)

FAERS_DASHBOARD_URL = (
    "https://www.fda.gov/drugs/surveillance/questions-and-answers-fda-adverse-event-"
    "reporting-system-faers/fda-adverse-event-reporting-system-faers-public-dashboard"
)

DEMO_PATH = "synthetic_patients/elderly_polypharmacy.json"

# Shared <head>: Tailwind config + theme tokens + report-body styling. Mirrors
# the design language of the frontend/ mockups.
HEAD = """
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>MedRAG — Medication Decision Support</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;600;700&family=Inter:wght@400;600&family=Geist:wght@500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
<script>
  tailwind.config = {
    darkMode: "class",
    theme: { extend: {
      colors: {
        "error-container": "#93000a", "surface-container-low": "#0e1d25",
        "clinical-teal": "#00E5BC", "tertiary": "#d1bcff",
        "secondary-container": "#00f1fe", "background": "#06151d",
        "surface-variant": "#28373f", "on-error-container": "#ffdad6",
        "primary-fixed-dim": "#aec6ff", "inverse-primary": "#0059c5",
        "on-primary-container": "#edf0ff", "outline": "#8c90a0",
        "on-background": "#d5e5f0", "surface-bright": "#2c3b44",
        "on-surface": "#d5e5f0", "tertiary-fixed-dim": "#d1bcff",
        "surface": "#06151d", "surface-container-high": "#1d2c34",
        "inverse-surface": "#d5e5f0", "secondary": "#ddfcff",
        "on-surface-variant": "#c2c6d6", "primary-fixed": "#d8e2ff",
        "surface-glass": "rgba(28, 43, 51, 0.6)", "surface-container": "#122129",
        "surface-container-highest": "#28373f", "secondary-fixed-dim": "#00dbe7",
        "outline-variant": "#424754", "tertiary-container": "#803fff",
        "deep-indigo": "#0A0F1E", "surface-dim": "#06151d", "on-primary": "#002e6b",
        "error": "#ffb4ab", "tertiary-fixed": "#e9ddff", "primary": "#aec6ff",
        "primary-container": "#0668e1", "surface-tint": "#aec6ff",
        "secondary-fixed": "#74f5ff", "surface-container-lowest": "#021017"
      },
      borderRadius: { "DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px" },
      spacing: { "margin-desktop": "64px", "margin-mobile": "20px", "container-max": "1440px", "gutter": "24px", "base": "8px" },
      fontFamily: {
        "display-lg": ["Hanken Grotesk"], "headline-lg-mobile": ["Hanken Grotesk"],
        "body-lg": ["Inter"], "title-md": ["Inter"], "label-sm": ["Geist"],
        "body-md": ["Inter"], "headline-lg": ["Hanken Grotesk"]
      },
      fontSize: {
        "display-lg": ["48px", {"lineHeight": "56px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
        "headline-lg-mobile": ["28px", {"lineHeight": "36px", "fontWeight": "600"}],
        "body-lg": ["18px", {"lineHeight": "28px", "fontWeight": "400"}],
        "title-md": ["20px", {"lineHeight": "28px", "fontWeight": "600"}],
        "label-sm": ["12px", {"lineHeight": "16px", "letterSpacing": "0.05em", "fontWeight": "500"}],
        "body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
        "headline-lg": ["32px", {"lineHeight": "40px", "letterSpacing": "-0.01em", "fontWeight": "600"}]
      }
    }}
  }
</script>
<style>
  body {
    background-color: #0A0F1E; color: #d5e5f0;
    background-image:
      radial-gradient(circle at 15% 50%, rgba(0, 242, 255, 0.05), transparent 25%),
      radial-gradient(circle at 85% 30%, rgba(128, 63, 255, 0.05), transparent 25%);
    background-attachment: fixed; min-height: 100vh;
  }
  .glass-panel {
    background-color: rgba(28, 43, 51, 0.6);
    backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
    border: 1px solid rgba(255, 255, 255, 0.1);
  }
  .glow-hover:hover { box-shadow: 0 0 20px rgba(0, 242, 255, 0.15); }
  .glow-focus:focus-within { box-shadow: 0 0 20px 0 rgba(0, 242, 255, 0.15); border-bottom-color: #00E5BC; }
  .material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; }
  /* Rendered Claude markdown inside report cards */
  .report-body p { margin: 0.5rem 0; color: #c2c6d6; }
  .report-body h2 { font-size: 18px; font-weight: 600; color: #00E5BC; margin: 1rem 0 0.4rem; }
  .report-body h3 { font-size: 15px; font-weight: 600; color: #aec6ff; margin: 0.8rem 0 0.3rem; }
  .report-body ul { list-style: disc; padding-left: 1.3rem; margin: 0.4rem 0; }
  .report-body li { margin: 0.3rem 0; color: #c2c6d6; }
  .report-body strong { color: #eef4f8; font-weight: 600; }
  .report-body code { background: rgba(0,0,0,0.35); padding: 1px 6px; border-radius: 5px; font-size: 0.85em; }
  .report-body a.cite { color: #00E5BC; font-weight: 600; text-decoration: none; }
  .report-body a.cite:hover { text-decoration: underline; }
  .report-bod
[truncated — 25737 more characters]
```

### api_client.py

```python
"""Anthropic client wrapper for MedRAG.

Loads the CONTEXT.md system prompt and calls Claude Opus 4.8 to produce the
structured safety report. The model's clinical behavior is defined entirely by
CONTEXT.md (the system prompt) plus the assembled user prompt.
"""

from __future__ import annotations

from functools import lru_cache
from pathlib import Path
from typing import Optional

from config import settings

CONTEXT_PATH = Path(__file__).parent / "CONTEXT.md"


@lru_cache(maxsize=1)
def load_system_context() -> str:
    """Read CONTEXT.md (the system prompt). Cached after first read."""
    if not CONTEXT_PATH.exists():
        raise FileNotFoundError(f"CONTEXT.md not found at {CONTEXT_PATH}")
    return CONTEXT_PATH.read_text(encoding="utf-8")


@lru_cache(maxsize=1)
def _get_client():
    try:
        from anthropic import Anthropic
    except ImportError as exc:  # pragma: no cover
        raise ImportError("anthropic is not installed. Run `pip install anthropic`.") from exc

    if not settings.anthropic_api_key:
        raise ValueError(
            "ANTHROPIC_API_KEY is not set. Add it to your .env to generate reports."
        )
    return Anthropic(api_key=settings.anthropic_api_key)


def generate_report(
    prompt: str,
    system_context: Optional[str] = None,
    max_tokens: int = 4096,
) -> str:
    """Send the assembled prompt to Claude and return the report text."""
    client = _get_client()
    system = system_context if system_context is not None else load_system_context()

    response = client.messages.create(
        model=settings.anthropic_model,
        max_tokens=max_tokens,
        system=system,
        messages=[{"role": "user", "content": prompt}],
    )

    return "".join(
        block.text for block in response.content if getattr(block, "type", None) == "text"
    )

```

### run_case.py

```python
"""CLI driver to run one MedRAG case end-to-end from the terminal.

Useful for spot-checking the pipeline on different patients/drugs without the
web UI.

Examples
--------
    python run_case.py --patient synthetic_patients/elderly_polypharmacy.json \
        --drug amiodarone --dose 200mg --route oral \
        --indication "atrial fibrillation" \
        --question "Concerns with her warfarin and digoxin given CKD?"

    # Skip the (paid) Claude call and just inspect retrieval:
    python run_case.py --patient <file> --drug metformin --retrieval-only
"""

from __future__ import annotations

import argparse
import json

from fhir_parser import parse_fhir_bundle
from prompt_assembly import build_prompt
from retrieval import retrieve_chunks


def main() -> None:
    p = argparse.ArgumentParser(description="Run a single MedRAG evaluation case.")
    p.add_argument("--patient", required=True, help="Path to a FHIR R4 bundle .json")
    p.add_argument("--drug", required=True, help="Proposed generic drug name")
    p.add_argument("--dose", default="", help="Proposed dose (e.g. 1000mg)")
    p.add_argument("--route", default="", help="Route (e.g. oral)")
    p.add_argument("--indication", default="", help="Indication being considered")
    p.add_argument("--question", default="", help="Physician question")
    p.add_argument("--retrieval-only", action="store_true",
                   help="Show retrieved chunks and skip the Claude call.")
    p.add_argument("--max-tokens", type=int, default=2500)
    args = p.parse_args()

    with open(args.patient) as f:
        record = parse_fhir_bundle(json.load(f))

    print("=" * 78)
    demo = record["demographics"]
    dx = ", ".join(d["name"] for d in record["diagnoses"])
    meds = ", ".join(m["name"] for m in record["medications"]) or "none"
    labs = ", ".join(f"{lab['test']}={lab['value']}" for lab in record["labs"]) or "none"
    print(f"PATIENT: {demo.get('age')}{(demo.get('sex') or '?')[:1].upper()}  | dx: {dx}")
    print(f"  meds: {meds}")
    print(f"  labs: {labs}")
    print(f"PROPOSED: {args.drug} {args.dose} {args.route} for {args.indication or 'n/a'}")
    print("=" * 78)

    proposed = {"name": args.drug, "dose": args.dose, "route": args.route,
                "indication": args.indication}
    chunks = retrieve_chunks(args.drug, record, indication=args.indication)

    print(f"\nRETRIEVED {len(chunks)} chunks:")
    for i, c in enumerate(chunks, 1):
        print(f"  [{i}] {c.score:.3f} | {c.drug_name} / {c.section_type}")

    if args.retrieval_only:
        return

    from api_client import generate_report
    prompt = build_prompt(record, proposed, chunks, args.question)
    print("\n" + "=" * 78 + "\nREPORT\n" + "=" * 78)
    print(generate_report(prompt, max_tokens=args.max_tokens))


if __name__ == "__main__":
    main()

```

### config.py

```python
"""Central configuration for MedRAG.

Loads settings from environment variables (via a `.env` file when present)
and exposes them as a single `settings` object the rest of the pipeline imports.
"""

from __future__ import annotations

import os
from dataclasses import dataclass

try:
    from dotenv import load_dotenv

    load_dotenv()
except ImportError:  # dotenv is optional at runtime
    pass


def _get_bool(name: str, default: bool = False) -> bool:
    raw = os.getenv(name)
    if raw is None:
        return default
    return raw.strip().lower() in {"1", "true", "yes", "on"}


def _get_int(name: str, default: int) -> int:
    raw = os.getenv(name)
    if raw is None or raw.strip() == "":
        return default
    try:
        return int(raw)
    except ValueError:
        return default


@dataclass(frozen=True)
class Settings:
    # Redis
    redis_url: str | None
    redis_host: str | None
    redis_port: int
    redis_password: str | None
    redis_index_name: str

    # Embeddings
    embed_provider: str  # "bge" | "voyage" | "stub"

    # Voyage (cloud, paid)
    voyage_api_key: str | None
    voyage_model: str
    voyage_dim: int

    # BGE (local, free)
    bge_model: str
    bge_dim: int
    bge_query_instruction: str

    # Anthropic
    anthropic_api_key: str | None
    anthropic_model: str

    # Behavior
    use_stub_embedder: bool
    retrieval_top_k: int
    retrieval_per_pair_k: int
    retrieval_max_chunks: int

    @property
    def embed_dim(self) -> int:
        """Vector dimensionality for the active embedding provider."""
        if self.use_stub_embedder:
            return self.bge_dim if self.embed_provider == "bge" else self.voyage_dim
        if self.embed_provider == "voyage":
            return self.voyage_dim
        return self.bge_dim

    def resolved_redis_url(self) -> str | None:
        """Return a connection URL, building one from parts if needed."""
        if self.redis_url:
            return self.redis_url
        if self.redis_host:
            auth = f"default:{self.redis_password}@" if self.redis_password else ""
            return f"redis://{auth}{self.redis_host}:{self.redis_port}"
        return None


def load_settings() -> Settings:
    return Settings(
        redis_url=os.getenv("REDIS_URL") or None,
        redis_host=os.getenv("REDIS_HOST") or None,
        redis_port=_get_int("REDIS_PORT", 6379),
        redis_password=os.getenv("REDIS_PASSWORD") or None,
        redis_index_name=os.getenv("REDIS_INDEX_NAME", "medrag_kb"),
        embed_provider=(os.getenv("EMBED_PROVIDER", "bge") or "bge").lower(),
        voyage_api_key=os.getenv("VOYAGE_API_KEY") or None,
        voyage_model=os.getenv("VOYAGE_MODEL", "voyage-3-large"),
        voyage_dim=_get_int("VOYAGE_DIM", 1024),
        bge_model=os.getenv("BGE_MODEL", "BAAI/bge-large-en-v1.5"),
        bge_dim=_get_int("BGE_DIM", 1024),
        bge_query_instruction=os.getenv(
            "BGE_QUERY_INSTRUCTION",
            "Represent this sentence for searching relevant passages:",
        ),
        anthropic_api_key=os.getenv("ANTHROPIC_API_KEY") or None,
        anthropic_model=os.getenv("ANTHROPIC_MODEL", "claude-opus-4-8"),
        use_stub_embedder=_get_bool("USE_STUB_EMBEDDER", False),
        retrieval_top_k=_get_int("RETRIEVAL_TOP_K", 8),
        retrieval_per_pair_k=_get_int("RETRIEVAL_PER_PAIR_K", 3),
        retrieval_max_chunks=_get_int("RETRIEVAL_MAX_CHUNKS", 14),
    )


settings = load_settings()

```

### prompt_assembly.py

```python
"""Prompt assembly for MedRAG.

Combines the parsed patient record, retrieved knowledge chunks, proposed
medication, and physician question into the exact input contract Claude expects
(see CONTEXT.md "Input Structure").

Assembly decisions (from README.md):
- Retrieved chunks are ordered by relevance score descending.
- Each chunk includes source / drug name / section type / date so Claude can
  surface freshness concerns.
- Data quality flags are included so Claude is aware of what is missing.
- The physician's free-text question appears LAST, immediately before Claude's
  response, so it is the most proximal instruction.
"""

from __future__ import annotations

from typing import Iterable, Union

from vector_store import SearchHit

DEFAULT_QUESTION = "Please provide a general safety assessment for this medication."

ChunkLike = Union[SearchHit, dict]


def _g(chunk: ChunkLike, attr: str, default: str = "") -> str:
    if isinstance(chunk, dict):
        return chunk.get(attr, default) or default
    return getattr(chunk, attr, default) or default


def _format_demographics(demo: dict) -> str:
    lines = []
    age = demo.get("age")
    sex = demo.get("sex")
    lines.append(f"    - Age: {age if age is not None else 'unknown'}, "
                 f"Sex: {sex or 'unknown'}")
    weight = demo.get("weight_kg")
    bmi = demo.get("bmi")
    if weight is not None or bmi is not None:
        wparts = []
        if weight is not None:
            wparts.append(f"{weight} kg")
        if bmi is not None:
            wparts.append(f"BMI {bmi}")
        lines.append(f"    - Weight/BMI: {', '.join(wparts)}")
    if demo.get("pregnancy_status") is not None:
        lines.append(f"    - Pregnancy status: {demo['pregnancy_status']}")
    if demo.get("smoking_status") is not None:
        lines.append(f"    - Smoking status: {demo['smoking_status']}")
    return "\n".join(lines)


def _format_diagnoses(diagnoses: list[dict]) -> str:
    if not diagnoses:
        return "    - None recorded"
    out = []
    for d in diagnoses:
        icd = f" ({d['icd10']})" if d.get("icd10") else ""
        out.append(f"    - {d.get('name', 'unknown')}{icd} [{d.get('status', 'unknown')}]")
    return "\n".join(out)


def _format_medications(meds: list[dict]) -> str:
    if not meds:
        return "    - None recorded"
    out = []
    for m in meds:
        bits = [m.get("name", "unknown")]
        if m.get("dose"):
            bits.append(m["dose"])
        if m.get("route"):
            bits.append(m["route"])
        if m.get("frequency"):
            bits.append(m["frequency"])
        out.append(f"    - {', '.join(bits)} (status: {m.get('status', 'unknown')})")
    return "\n".join(out)


def _format_allergies(allergies: list[dict]) -> str:
    if not allergies:
        return "    - None recorded"
    out = []
    for a in allergies:
        parts = [a.get("substance", "unknown")]
        if a.get("reaction"):
            parts.append(f"reaction: {a['reaction']}")
        if a.get("criticality"):
            parts.append(f"criticality: {a['criticality']}")
        out.append(f"    - {', '.join(parts)}")
    return "\n".join(out)


def _format_labs(labs: list[dict]) -> str:
    if not labs:
        return "    - None recorded"
    out = []
    for lab in labs:
        ref = f", ref {lab['reference_range']}" if lab.get("reference_range") else ""
        date = f", {lab['date']}" if lab.get("date") else ""
        out.append(
            f"    - {lab.get('test', 'unknown')}: {lab.get('value')} "
            f"{lab.get('unit', '')}{ref}{date}"
        )
    return "\n".join(out)


def _format_flags(flags: list[str]) -> str:
    if not flags:
        return "    - None"
    return "\n".join(f"    - {f}" for f in flags)


def _format_chunks(chunks: Iterable[ChunkLike]) -> str:
    chunks = list(chunks)
    if not chunks:
        return ("  No knowledge-base chunks were retrieved for this query. "
                "Treat the retrieved context as empty and flag this explicitly.")
    out = []
    for i, c in enumerate(chunks, start=1):
        url = _g(c, "url")
        header = (
            f"  [chunk {i}] source: {_g(c, 'source', 'unknown')} | "
            f"drug: {_g(c, 'drug_name', 'unknown')} | "
            f"section: {_g(c, 'section_type', 'unknown')} | "
            f"date: {_g(c, 'date', 'unknown')}"
        )
        if url:
            header += f" | url: {url}"
        out.append(f"{header}\n  {_g(c, 'text')}")
    return "\n\n".join(out)


def build_prompt(
    patient_record: dict,
    proposed_drug: dict,
    chunks: Iterable[ChunkLike],
    physician_question: str = "",
) -> str:
    demo = patient_record.get("demographics", {}) or {}
    question = physician_question.strip() if physician_question else DEFAULT_QUESTION

    drug_line = proposed_drug.get("name", "unknown")
    dose = proposed_drug.get("dose")
    route = proposed_drug.get("route")
    dose_route = ", ".join(x for x in [dose, route] if x) or "not specified"
    indication = proposed_drug.get("indication", "not specified")

    return f"""PATIENT RECORD:
  Demographics:
{_format_demographics(demo)}

  Active Diagnoses:
{_format_diagnoses(patient_record.get('diagnoses', []))}

  Current Medications:
{_format_medications(patient_record.get('medications', []))}
    NOTE: This list is sourced from MedicationRequest resources and reflects
    what was prescribed, not necessarily what the patient is currently taking.

  Known Allergies:
{_format_allergies(patient_record.get('allergies', []))}

  Relevant Lab Values:
{_format_labs(patient_record.get('labs', []))}

  Data Quality Flags:
{_format_flags(patient_record.get('data_quality_flags', []))}

PROPOSED MEDICATION:
  - Drug name (generic): {drug_line}
  - Proposed dose and route: {dose_route}
  - Indication being considered: {indication}

RETRIEVED CONTEXT:
{_format_chunks(chunks)}

PHYSICIAN QUESTION:
  {question}
"""

```

### embeddings.py

```python
"""Embedding layer for MedRAG.

Provides a single interface over three backends:

- ``BGEEmbedder``     -> local BAAI BGE embeddings via sentence-transformers.
  Runs on-device with no API cost (default provider).
- ``VoyageEmbedder``  -> cloud ``voyage-3-large`` embeddings (requires an API key).
- ``StubEmbedder``    -> deterministic local vectors for building/testing the
  pipeline without downloading a model. NOT semantically meaningful; never use
  for a real knowledge base you intend to query for clinical relevance.

BGE (v1.5 English) and Voyage both benefit from distinguishing ``document`` vs
``query`` inputs, so the interface exposes both ``embed_documents`` and
``embed_query``.
"""

from __future__ import annotations

import hashlib
from typing import Protocol

import numpy as np

from config import Settings, settings as default_settings


class Embedder(Protocol):
    dim: int

    def embed_documents(self, texts: list[str]) -> list[list[float]]: ...

    def embed_query(self, text: str) -> list[float]: ...


class BGEEmbedder:
    """Local BAAI BGE embeddings via sentence-transformers (no API cost).

    For BGE v1.5 English models, retrieval quality improves when a short
    instruction is prepended to *queries* (not documents). Vectors are
    L2-normalized so cosine similarity in Redis is well-behaved.
    """

    def __init__(
        self,
        model_name: str = "BAAI/bge-large-en-v1.5",
        dim: int = 1024,
        query_instruction: str = "",
    ):
        try:
            from sentence_transformers import SentenceTransformer
        except ImportError as exc:  # pragma: no cover
            raise ImportError(
                "sentence-transformers is not installed. "
                "Run `pip install sentence-transformers`."
            ) from exc

        self.model_name = model_name
        self.query_instruction = query_instruction
        self._model = SentenceTransformer(model_name)
        # Method was renamed across sentence-transformers versions.
        get_dim = getattr(self._model, "get_embedding_dimension", None) or getattr(
            self._model, "get_sentence_embedding_dimension", None
        )
        actual = get_dim() if get_dim else None
        if actual and actual != dim:
            # Trust the model's real dimensionality over the configured value.
            print(
                f"[embeddings] note: {model_name} outputs {actual} dims; "
                f"using {actual} (configured BGE_DIM was {dim})."
            )
            dim = actual
        self.dim = dim

    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        if not texts:
            return []
        vectors = self._model.encode(
            texts, normalize_embeddings=True, convert_to_numpy=True
        )
        return [v.astype("float32").tolist() for v in vectors]

    def embed_query(self, text: str) -> list[float]:
        payload = f"{self.query_instruction} {text}".strip() if self.query_instruction else text
        vector = self._model.encode(
            [payload], normalize_embeddings=True, convert_to_numpy=True
        )[0]
        return vector.astype("float32").tolist()


class VoyageEmbedder:
    """Wraps the Voyage AI client for ``voyage-3-large`` embeddings."""

    def __init__(self, api_key: str, model: str = "voyage-3-large", dim: int = 1024):
        try:
            import voyageai
        except ImportError as exc:  # pragma: no cover
            raise ImportError(
                "voyageai is not installed. Run `pip install voyageai`."
            ) from exc

        if not api_key:
            raise ValueError("VOYAGE_API_KEY is required for VoyageEmbedder.")

        self._client = voyageai.Client(api_key=api_key)
        self.model = model
        self.dim = dim

    def _embed(self, texts: list[str], input_type: str) -> list[list[float]]:
        result = self._client.embed(
            texts,
            model=self.model,
            input_type=input_type,
            output_dimension=self.dim,
        )
        return result.embeddings

    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        if not texts:
            return []
        return self._embed(texts, input_type="document")

    def embed_query(self, text: str) -> list[float]:
        return self._embed([text], input_type="query")[0]


class StubEmbedder:
    """Deterministic, dependency-free embedder for offline pipeline testing.

    Vectors are derived from a hash of the text, so identical text always maps
    to an identical (unit-normalized) vector. This is enough to exercise Redis
    indexing, upsert, and top-k search mechanics, but carries no real semantics.
    """

    def __init__(self, dim: int = 1024):
        self.dim = dim

    def _vector(self, text: str) -> list[float]:
        # Seed a PRNG from a stable hash of the text for reproducibility.
        digest = hashlib.sha256(text.encode("utf-8")).digest()
        seed = int.from_bytes(digest[:8], "big")
        rng = np.random.default_rng(seed)
        vec = rng.standard_normal(self.dim)
        norm = np.linalg.norm(vec)
        if norm > 0:
            vec = vec / norm
        return vec.astype(np.float32).tolist()

    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        return [self._vector(t) for t in texts]

    def embed_query(self, text: str) -> list[float]:
        return self._vector(text)


def get_embedder(settings: Settings = default_settings) -> Embedder:
    """Factory: return the embedder for the configured provider.

    ``USE_STUB_EMBEDDER=true`` always wins (offline pipeline testing). Otherwise
    the provider is chosen by ``EMBED_PROVIDER`` (default "bge").

    Cached as a process singleton so the BGE model is loaded once per server
    process (important for Flask — reloading it on every request is slow, and
    loading inside the debug reloader child can raise BrokenPipeError).
    """
    global _EMBEDDER_SINGLETON
    if _EMBEDD
[truncated — 1128 more characters]
```

### vector_store.py

```python
"""Redis vector store for the MedRAG knowledge base.

Uses Redis 8's native **vector sets** (the ``vectorset`` module: VADD / VSIM /
VGETATTR), which ship in core Redis 8 and require no separate Redis Stack
install. Each knowledge-base chunk is stored as one element of a single vector
set, with its embedding plus a JSON attribute blob holding the text + metadata:

    element name  -> chunk id
    vector        -> FLOAT32 embedding (dim = settings.embed_dim)
    attributes    -> {"text", "source", "drug_name", "section_type", "date"}

KNN search uses ``VSIM ... WITHSCORES`` (cosine similarity in [0, 1], higher is
more similar); attributes for each hit are fetched with ``VGETATTR``.

Note: earlier revisions targeted the RediSearch ``FT.*`` query engine. Native
vector sets are used instead so the system runs on a stock local Redis 8.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from typing import Any, Optional

import numpy as np
import redis

from config import Settings, settings as default_settings


@dataclass
class Chunk:
    """A single knowledge-base chunk to be indexed."""

    id: str
    text: str
    source: str = ""
    drug_name: str = ""
    section_type: str = ""
    date: str = ""
    url: str = ""
    embedding: list[float] = field(default_factory=list)


@dataclass
class SearchHit:
    id: str
    score: float
    text: str
    source: str
    drug_name: str
    section_type: str
    date: str
    url: str = ""


class VectorStore:
    def __init__(self, settings: Settings = default_settings):
        url = settings.resolved_redis_url()
        if not url:
            raise ValueError(
                "No Redis connection configured. Set REDIS_URL (or REDIS_HOST/"
                "PORT/PASSWORD) in your environment / .env file."
            )
        # Fail fast rather than hang forever if the endpoint is unresponsive.
        self.client = redis.Redis.from_url(
            url,
            decode_responses=False,
            socket_timeout=10,
            socket_connect_timeout=10,
            retry_on_timeout=True,
            health_check_interval=30,
        )
        # The vector set key holding all chunks.
        self.key = settings.redis_index_name
        self.dim = settings.embed_dim

    # -- connectivity ----------------------------------------------------
    def ping(self) -> bool:
        return bool(self.client.ping())

    def index_exists(self) -> bool:
        return bool(self.client.exists(self.key))

    def supports_vectorset(self) -> bool:
        """True if this Redis build exposes the vector-set commands."""
        try:
            modules = self.client.execute_command("MODULE", "LIST")
        except redis.ResponseError:
            return False
        flat = b" ".join(
            part if isinstance(part, bytes) else str(part).encode()
            for row in modules for part in (row if isinstance(row, (list, tuple)) else [row])
        )
        return b"vectorset" in flat or b"search" in flat

    # -- index management ------------------------------------------------
    def create_index(self, recreate: bool = False) -> None:
        """Vector sets are created lazily on first VADD; only handle recreate."""
        if recreate and self.index_exists():
            self.client.delete(self.key)

    # -- writes ----------------------------------------------------------
    @staticmethod
    def _vector_bytes(embedding: list[float]) -> bytes:
        return np.asarray(embedding, dtype=np.float32).tobytes()

    def upsert(self, chunks: list[Chunk]) -> int:
        pipe = self.client.pipeline(transaction=False)
        for chunk in chunks:
            attrs = json.dumps(
                {
                    "text": chunk.text,
                    "source": chunk.source,
                    "drug_name": chunk.drug_name,
                    "section_type": chunk.section_type,
                    "date": chunk.date,
                    "url": chunk.url,
                }
            )
            pipe.execute_command(
                "VADD",
                self.key,
                "FP32",
                self._vector_bytes(chunk.embedding),
                chunk.id,
                "SETATTR",
                attrs,
            )
        pipe.execute()
        return len(chunks)

    def set_attrs(self, element_id: str, attrs: dict) -> bool:
        """Replace an element's JSON attributes without touching its vector."""
        try:
            self.client.execute_command("VSETATTR", self.key, element_id, json.dumps(attrs))
            return True
        except redis.ResponseError:
            return False

    def get_attrs(self, element_id: str) -> dict:
        return _load_attrs(self.client.execute_command("VGETATTR", self.key, element_id))

    def count(self) -> int:
        if not self.index_exists():
            return 0
        try:
            return int(self.client.execute_command("VCARD", self.key))
        except redis.ResponseError:
            return 0

    # -- reads -----------------------------------------------------------
    def search(
        self,
        query_vector: list[float],
        k: int = 8,
        drug_name: Optional[str] = None,
        source: Optional[str] = None,
        section_types: Optional[list[str]] = None,
    ) -> list[SearchHit]:
        if not self.index_exists():
            return []

        args: list[Any] = [
            "VSIM", self.key, "FP32", self._vector_bytes(query_vector),
            "WITHSCORES", "COUNT", k,
        ]
        clauses: list[str] = []
        if drug_name:
            clauses.append(f'.drug_name == "{drug_name.replace(chr(34), "")}"')
        if source:
            clauses.append(f'.source == "{source.replace(chr(34), "")}"')
        if section_types:
            quoted = ", ".join(f'"{s.replace(chr(34), "")}"' for s in section_types)
            clauses.append(f".section_type in [{quoted}]")
        if clauses:
           
[truncated — 2378 more characters]
```

[6 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]