A supplementary healthcare assistant that runs entirely on your own machine.
Open a session per health condition, record your doctor's guidelines and routine, then have a context-aware conversation grounded in your actual medical guidance.
No API keys · No cloud inference · No usage limits · Your health data never leaves your machine
General-purpose chatbots answer medical questions from generic training data. They don't know that your doctor put you on Metformin 850 mg, told you to target 90–120 mg/dL fasting, and wants you back for an HbA1c on the 14th.
HelpMedAI flips that around. You record what your doctor actually told you, and every answer is grounded in that, retrieved per-session from a vector database — never invented, and never mixed with another condition or another person's data.
| Feature | What it does | |
|---|---|---|
| 🔐 | Authentication | JWT login/signup with bcrypt-hashed passwords |
| 🗂️ | Condition sessions | A separate, isolated thread per health concern, listed newest-first |
| 📋 | Doctor's guidelines | Record instructions, medications, dosages, routine and allergies per session |
| 🔎 | Session-scoped RAG | Answers retrieve from your recorded details, filtered by user and session |
| 💬 | Context-aware chat | Follow-ups like "and what dose of it?" resolve from conversation history |
| 🚨 | Emergency screening | Deterministic red-flag detection that always fires — no model sampling involved |
| 🖥️ | Familiar UI | ChatGPT-style interface, responsive down to mobile |
| 🏠 | Fully local | Qwen3.5-4B via llama.cpp on CPU — no GPU, no API key, no rate limit |
flowchart LR
subgraph Client["🌐 Browser"]
UI["React 18 + Vite<br/>:3000"]
end
subgraph Server["⚙️ FastAPI :8001"]
API["api/api.py<br/>routes + JWT guard"]
CHAT["chat.py<br/>orchestration"]
CHAIN["chains/<br/>LangChain prompt"]
SAFE["safety.py<br/>red-flag screen"]
RAGP["rag/<br/>index + retrieve"]
end
subgraph Stores["💾 Local services"]
MONGO[("MongoDB<br/>:27017")]
QDRANT[("Qdrant<br/>:6333")]
LLM["Qwen3.5-4B GGUF<br/>llama.cpp · CPU"]
end
UI -->|"REST + Bearer"| API
API --> CHAT
CHAT --> SAFE
CHAT --> RAGP
CHAT --> CHAIN
CHAT <-->|"users · sessions<br/>messages"| MONGO
RAGP <-->|"vectors filtered by<br/>user_id + chat_id"| QDRANT
CHAIN -->|"one call per message"| LLM
Every reply is built from exactly three sources, in a single LLM call:
- Patient context — the session profile (condition, doctor's guidelines, medications, routine), injected into the system message on every turn.
- Retrieved excerpts — top-k chunks from Qdrant, filtered on
user_idandchat_id. - Conversation history — prior turns as real
HumanMessage/AIMessageobjects, trimmed to a budget that fits the context window.
| Layer | Choice | Notes |
|---|---|---|
| LLM | unsloth/Qwen3.5-4B-GGUF Q4_K_M (2.55 GB) |
via llama-cpp-python, CPU-only, prebuilt wheel |
| Orchestration | LangChain 1.3 | message-based chain, ChatLlamaCpp |
| Vector DB | Qdrant 1.18 (Docker) | 384-dim cosine, payload-indexed on user/session |
| Embeddings | all-MiniLM-L6-v2 |
384-dim, fast on CPU |
| Database | MongoDB | users, chats, messages, session_documents |
| API | FastAPI + Uvicorn | Pydantic v2 models, JWT auth |
| Frontend | React 18, Vite 5, React Router 6 | axios, lucide-react |
- Python 3.11
- Node.js 18+
- MongoDB running on
localhost:27017 - Docker (for Qdrant)
- ~4 GB free disk (model + embeddings) and ~4 GB RAM free
cd backend
python -m venv venv
venv\Scripts\activate # macOS/Linux: source venv/bin/activate
# llama-cpp-python ships from its own wheel index (avoids compiling llama.cpp)
pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
pip install -r requirements.txtdocker compose up -d # Qdrant on 127.0.0.1:6333
curl localhost:6333/readyz # -> "all shards are ready"
python scripts/download_model.py # ~2.55 GB, cached in ~/.cache/huggingfacecp .env.example .env # then set a real SECRET_KEY# Terminal 1 — backend (single worker: the model is a per-process singleton)
cd backend
uvicorn api.api:app --host 127.0.0.1 --port 8001
# Terminal 2 — frontend
cd frontend
npm install
npm run devOpen http://localhost:3000 · API docs at http://127.0.0.1:8001/docs
Local inference speed depends entirely on your CPU. Benchmark before picking a model size:
python scripts/benchmark.pyReference numbers on an Intel Core 7 150U (15 W, 10c/12t, no GPU):
| Metric | Value |
|---|---|
| Model load | ~3 s |
| Throughput | ~6.1 tok/s |
| Typical reply | 45–65 s |
If the benchmark reports under ~4 tok/s, switch to a smaller model in .env:
MODEL_REPO=unsloth/Qwen2.5-1.5B-Instruct-GGUF
MODEL_FILE=Qwen2.5-1.5B-Instruct-Q4_K_M.ggufNote
Replies are slower and less fluent than a frontier cloud model. That is the deliberate trade for running a health assistant with no API key and no data leaving your machine.
The model is too large and slow for a cheap VPS, so inference stays on your machine.
A DigitalOcean droplet serves the React bundle and reverse-proxies /api to your PC
over a private Tailscale network — the backend gets no public exposure, and your
home IP is never revealed.
Browser ──HTTPS──> DO droplet ──Tailscale (WireGuard)──> Your PC
nginx: encrypted, private FastAPI bound to
• React build 100.x.y.z:8001 only
• /api proxy
Full runbook: deploy/DEPLOYMENT.md — including a free
Part 0 that proves the tunnel with just your phone before you pay for a droplet.
| Script | Purpose |
|---|---|
deploy/run-backend-tailscale.ps1 |
Bind the backend to the tailnet + scope the Windows firewall to it |
deploy/setup-droplet.sh |
Provision nginx, Tailscale and ufw on the droplet |
deploy/deploy-frontend.sh |
Build locally and rsync to the droplet |
deploy/nginx-helpmedai.conf |
Reverse proxy with 300 s timeouts, rate limits, security headers |
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/api/health |
Status incl. Qdrant — never touches the LLM |
POST |
/api/auth/signup · /api/auth/login |
Returns a JWT |
GET |
/api/auth/me |
Current user |
GET |
/api/chats |
List sessions |
POST |
/api/chats |
Create a session + first reply |
GET |
/api/chats/{id} |
Session + messages |
POST |
/api/chats/{id}/messages |
Send a message, get a grounded reply |
GET PUT |
/api/chats/{id}/profile |
Read/save patient details — PUT re-indexes into Qdrant |
PUT |
/api/chats/{id}/consultation |
Save doctor notes (mirrors to profile + re-indexes) |
PUT |
/api/chats/{id}/title |
Rename a session |
DELETE |
/api/chats/{id} |
Delete session, messages and its vectors |
MongoDB collections (click to expand)
users — email (unique index), password (bcrypt), name, timestamps
chats — one condition session:
{
user_id, title, condition, chat_status,
profile: {
condition_details, doctor_guidelines,
medications: [{ name, dosage, schedule, notes }],
routine, allergies, notes
},
doctor_consultation: { instructions_and_medications, updated_at },
created_at, updated_at
}messages — chat_id, user_id, role (user/assistant/system), content,
token_count, created_at. Compound index on (chat_id, created_at).
session_documents — provenance for embedded text: doc_type, text, and the
qdrant_point_ids it produced, so re-indexing replaces precisely instead of duplicating.
Qdrant collection session_docs
384-dim cosine vectors. Each point carries { user_id, chat_id, doc_type } in its payload,
with keyword payload indexes on all three. Every search filters on user_id AND chat_id —
that filter is the isolation boundary and is constructed inside
src/rag/retriever.py rather than left to callers.
These are the non-obvious calls, and why they went the way they did.
- One LLM call per user message. At ~6 tok/s every extra call costs the user tens of seconds. Session titles are derived from the message text, and urgency comes from a keyword screen — not a model call.
- Emergency detection is deterministic. For "should this person call an ambulance" a
fixed screen is both free and more dependable than a sampled 4B model, which could answer
differently on a re-roll. See
src/safety.py. - Only patient-provided text is indexed. Model output is never written back into the vector store. Recycling generated text as retrievable "fact" is a hallucination feedback loop — unacceptable in a health tool.
create_history_aware_retrieveris deliberately unused. Its query-rewriting step is a second LLM call, which would roughly double response time on CPU.- Inference never runs on the event loop. Generation is offloaded to a worker thread, so
/api/healthstill responds while a 50-second generation is in flight. - Port 8001, and
127.0.0.1notlocalhost.localhostcan resolve to IPv6::1first, letting a container bound to[::]:PORTintercept API calls. Configured in one place:frontend/src/config.js.
DSEp3/
├── backend/
│ ├── api/api.py FastAPI routes, JWT guard, lifespan
│ ├── src/
│ │ ├── config.py pydantic-settings — every tunable
│ │ ├── models.py Pydantic schemas for all stored documents
│ │ ├── database.py MongoDB repositories
│ │ ├── safety.py red-flag screen + title derivation
│ │ ├── auth.py bcrypt + JWT
│ │ ├── chat.py session orchestration
│ │ ├── llm/local_llm.py Qwen via llama.cpp (singleton + lock + thread offload)
│ │ ├── rag/ embeddings · vectorstore · indexer · retriever
│ │ └── chains/ LangChain message-based chain
│ ├── scripts/ download_model.py · benchmark.py
│ ├── docker-compose.yml Qdrant
│ └── README.md backend deep-dive
└── frontend/
└── src/
├── config.js API base URL (single source of truth)
├── pages/ Login · Signup · ChatMain · ChatSession
├── components/ Sidebar
├── hooks/ useChats · useIsMobile
└── styles/ AuthPage.css · ChatLayout.css
llama-cpp-python tries to compile from source
You omitted the wheel index. Install it on its own first:
pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpuQdrant connection errors on startup
The container isn't up. From backend/:
docker compose up -d
curl localhost:6333/readyzThe API still boots without Qdrant so the cause is diagnosable — retrieval just returns
nothing. Check /api/health, which reports Qdrant status.
Port 8000 or 27017 already in use
Another container may be publishing them. Check with:
docker ps --format '{{.Names}}\t{{.Ports}}'The backend uses 8001 precisely to avoid the common 8000 clash.
Replies are unbearably slow
Run python scripts/benchmark.py. Under ~4 tok/s, drop to the 1.5B model shown in
Performance. You can also lower MAX_TOKENS in .env to cap reply length.