Turns a lab report into a plain-English explanation, an abnormality flag, a trend over time and lets you ask follow-up questions about it.
An educational interpretation layer not a diagnostic tool.
Important
MedExplain does not diagnose conditions, does not recommend medication or treatment, and does not claim clinical accuracy. It always defers to "consult a doctor." Every screen carries a persistent disclaimer banner, and every LLM call including multi-turn chat is hard-constrained to explanation only.
Upload / enter a report (CBC or Lipid Profile)
│
▼
extract values (OCR or manual)
│
▼
compare to reference ranges (plain Python rule engine)
│
▼
flag High / Low / Normal
│
▼
LLM explains flagged values in plain language
│
▼
dashboard shows results + trend over time
│
▼
ask a follow-up question about any flagged value
| Dashboard flagged results, plain-language explanation, follow-up chat entry point |
|---|
![]() |
| Follow-up chat, scoped to one value | Second report type Lipid Profile |
|---|---|
![]() |
![]() |
| Upload drag & drop | Upload OCR reads a real report photo |
|---|---|
![]() |
![]() |
| History real trend line with reference-range band |
|---|
![]() |
flowchart TD
subgraph Client["Frontend: Vite + React (Netlify)"]
Upload[Upload page]
Dashboard[Dashboard / manual entry]
History[History & Trends]
Chat[Chat modal per flagged value]
end
subgraph Server["Backend FastAPI"]
API[REST API]
Rules[Rule engine: plain Python, no LLM]
OCR[OCR pipeline Tesseract + pdfplumber]
LLMClient[LLM client]
end
DB[(Postgres / Neon report_types, parameters, reference_ranges,
saved_reports, saved_report_values, chat_messages)]
Groq[[Groq API Llama 3.3 70B]]
Upload -- "PDF / image + report type" --> API
API --> OCR
OCR -- "extracted values" --> Upload
Dashboard -- "sex, age, values, report type" --> API
API --> Rules
Rules -- "reads reference ranges" --> DB
Rules -- "Normal/High/Low + range + saved_value_id" --> Dashboard
API -- "persists report" --> DB
Dashboard -- "flagged {name,value,status} only" --> API
API --> LLMClient
LLMClient --> Groq
Groq --> LLMClient --> Dashboard
Chat -- "saved_value_id + message" --> API
API -- "rebuilds context from DB row, never trusts client" --> LLMClient
API -- "stores both sides of the conversation" --> DB
History -- "device_id" --> API
API -- "trend series" --> DB
DB --> History
Data-minimization boundary: the LLM only ever receives {parameter name, value, status} for values already flagged abnormal by the deterministic rule engine never a raw report, never patient identifiers. This is enforced in code (backend/llm.py), not just by convention. The chat feature keeps this boundary too: every reply is generated from a system prompt rebuilt fresh, server-side, from the database row never from client-supplied context, and never carrying the raw report into the conversation.
Comparing a number to a range is deterministic. Doing it in plain Python means the flag is always reproducible and auditable no hallucination risk on the one thing that must never be wrong. The LLM's only job is turning an already-correct flag into a human-readable sentence, or answering a bounded follow-up question about it.
| Layer | Choice | Why |
|---|---|---|
| Frontend | Vite + React (JS) + Tailwind v4 | Lightweight; Tailwind v4's CSS-based theme kept the "calm, pastel, clinical-but-warm" design system in one file |
| Routing | react-router-dom | Sidebar/bottom-tab navigation across Upload / Dashboard / History / Settings |
| Icons | lucide-react | Consistent icon set across nav, report-type tabs, and empty states |
| Charts | Recharts | Trend line + shaded reference-range band |
| Backend | FastAPI (Python) | Async-friendly, typed, minimal boilerplate for a small API surface |
| ORM | SQLAlchemy | Models mirror the raw SQL schema exactly no drift |
| Database | PostgreSQL (Neon, free tier) | Normalized schema designed to extend to new report types without migrations |
| OCR | Tesseract + pdfplumber | Tesseract for images, pdfplumber for text-layer PDFs; per-report-type parser registry (no PaddleOCR too heavy for free-tier hosting) |
| LLM | Groq (Llama 3.3 70B) | Free tier with generous rate limits, fast, more than capable for short explanations and bounded follow-up chat |
| Auth | Anonymous device ID (localStorage) | No login screen yet; forward-compatible real auth later adds a user_id column without changing the reports/chat schema |
| Frontend hosting | Netlify | netlify.toml at repo root drives the build (base=frontend) |
| Backend hosting | Railway / Render (planned) | Currently developed against localhost; see Roadmap |
The original plan called for a paid cloud LLM API. Budget constraints during development made that impractical, so both the one-shot explanation layer and the follow-up chat run on Groq's free tier instead. The prompt contracts are provider-agnostic swapping back to Claude later is a one-file change in backend/llm.py.
MedExplain--AI/
├── frontend/ # Vite + React app
│ ├── src/
│ │ ├── pages/ # UploadPage, ResultsPage, HistoryPage, SettingsPage
│ │ ├── components/ # Layout, DisclaimerBanner, ParameterCard, StatusPill, ChatModal
│ │ ├── deviceId.js # anonymous localStorage device identity
│ │ └── reportTypeIcons.jsx # icon lookup per report type (CBC, Lipid Profile, ...)
│ └── netlify.toml config lives at repo root
├── backend/ # FastAPI app
│ ├── main.py # routes
│ ├── models.py # SQLAlchemy models (mirror schema.sql)
│ ├── schemas.py # Pydantic request/response models
│ ├── rule_engine.py # deterministic Normal/High/Low flagging
│ ├── ocr.py # Tesseract/pdfplumber extraction, per-report-type parser registry
│ ├── llm.py # Groq client one-shot explanation + bounded follow-up chat
│ └── database.py
├── database/
│ ├── schema.sql # source-of-truth DDL
│ └── seed/
│ ├── 001_cbc.sql # CBC reference ranges
│ └── 002_lipid_profile.sql # Lipid Profile reference ranges
├── docs/
│ ├── implementation_plan.md # living phase-by-phase status doc
│ └── screenshots/
└── netlify.toml
Three normalized tables carry the reference knowledge base:
report_types ──┬──▶ parameters ──┬──▶ reference_ranges
(cbc, lipid_ │ (hemoglobin, │ (sliced by sex + age band)
profile) │ ldl, ...) │
Adding a new report type means inserting rows never a schema change (proven in Phase 9 by adding Lipid Profile with zero backend endpoint changes). Reference ranges vary by sex and age from day one (sex, age_min_years, age_max_years columns), not retrofitted later.
History and chat are stored separately, both keyed off the same saved value:
saved_reports ──▶ saved_report_values ──▶ chat_messages
(device_id, (parameter_id, value, (role, content,
sex, age, status, range_low, created_at)
created_at) range_high)
A saved_report_values row is the anchor for a follow-up conversation chat_messages.saved_report_value_id ties every message back to one specific flagged value from one specific report, never a whole report or an open-ended chat.
- Node.js, Python 3.12 (3.14 currently lacks
pydantic-corewheels use 3.12) - Tesseract (
brew install tesseract) - A Neon Postgres project
- A Groq API key (free)
psql "$DATABASE_URL" -f database/schema.sql
psql "$DATABASE_URL" -f database/seed/001_cbc.sql
psql "$DATABASE_URL" -f database/seed/002_lipid_profile.sqlcd backend
python3.12 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # fill in DATABASE_URL, GROQ_API_KEY, JWT_SECRET, CORS_ORIGINS
uvicorn main:app --reload --port 8001cd frontend
npm install
cp .env.example .env.local # VITE_API_URL=http://localhost:8001
npm run dev| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Liveness check |
GET |
/report-types |
List available report types (drives report-type selectors) |
GET |
/report-types/{code}/parameters |
List parameters for a report type (drives the entry form) |
POST |
/reports |
Submit values → flagged Normal/High/Low; persists if device_id present, returns a saved_value_id per flagged parameter |
POST |
/ocr/extract |
Upload PDF/image + report type → extracted {parameter_code, value} matches |
POST |
/explain |
Flagged values → plain-English explanations (Groq) |
GET |
/history?device_id=...&report_type=... |
Per-parameter trend series for the History page |
POST |
/chat |
Send a follow-up message about one flagged value (saved_report_value_id); returns the reply and full thread |
GET |
/chat/{saved_report_value_id} |
Resume an existing follow-up conversation |
- Deploy the backend (Railway/Render) currently developed against localhos. Netlify frontend is already live but has nothing to talk to until this lands.
- Real authentication (JWT signup/login), replacing the anonymous device ID the schema was designed so this only adds a
user_idcolumn, no migration of existing data.
- PDF export of the results summary
- Multilingual support (Urdu, Arabic, French)
- Dark mode
- "Questions to ask your doctor" generator
- Additional report types: LFT, KFT, Thyroid, Urine Analysis
- Rate limiting on
/explain,/ocr/extract, and/chat(currently open endpoints fine for a local/portfolio demo, a real gap before a public deploy)
- LSTM/transformer trend prediction with fewer than 10 data points per user, this is statistically meaningless. A straight line through actual points, honestly labeled "trend, not prediction," is more truthful than a model dressed up to look predictive.
- PaddleOCR too heavy for free tier hosting; caused OOM/timeouts in early evaluation.
- Ollama in production free hosting tiers don't have the RAM to run a local LLM; a cloud API is a hard requirement here.
- Sending raw reports, patient identifiers, or whole-report context to the LLM both the one-shot explanation and the follow-up chat only ever see
{name, value, status}(plus range, for chat) for one parameter at a time. - Whole-report or open-ended chat deliberately scoped follow-up chat to one flagged value per conversation; a wider context is harder to keep reliably bounded away from diagnosis/treatment territory across many turns.
- OCR accuracy depends on image quality. Low resolution photos of real lab reports (~400px wide) originally OCR'd to garbage; the pipeline now upscales and preprocesses, but a genuinely garbled source value is correctly reported as "couldn't read" rather than guessed this is by design, not a gap to silently paper over.
- Unit normalization is partial. Absolute cell counts (
/cumm) and SI-equivalent units (x10⁹/L) are handled for CBC; more exotic report formats will surface new cases as they're tested. - No real authentication yet. History and chat are tied to an anonymous per browser device ID, not an account clearing localStorage or switching devices loses access (the underlying data isn't deleted, just unreachable without the ID).
- No rate limiting.
/explain,/ocr/extract, and/chatare open endpoints with no auth and no per device throttling beyond a 40-message cap per chat thread acceptable for local development, a real gap before any public deployment. - No output-side safety check on LLM replies. The system prompts hard-constrain the model (no diagnosis, no medication, mandatory disclaimer), verified against direct adversarial prompts during testing but there's no automated check that a given reply actually honored those constraints before it reaches the user.
MIT see LICENSE.
MedExplain AI is an educational project. It is not a medical device, does not provide medical advice, and should never be used as a substitute for consulting a qualified healthcare professional.






