Skip to content

Latest commit

 

History

21 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🩺 HelpMedAI

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.

Python FastAPI React Vite MongoDB Qdrant LangChain Qwen

No API keys · No cloud inference · No usage limits · Your health data never leaves your machine


Why this exists

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.


Features

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

Architecture

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
Loading

How an answer is assembled

Every reply is built from exactly three sources, in a single LLM call:

  1. Patient context — the session profile (condition, doctor's guidelines, medications, routine), injected into the system message on every turn.
  2. Retrieved excerpts — top-k chunks from Qdrant, filtered on user_id and chat_id.
  3. Conversation history — prior turns as real HumanMessage/AIMessage objects, trimmed to a budget that fits the context window.

Tech stack

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

Quick start

Prerequisites

  • 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

1 · Backend

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.txt

2 · Services and model

docker 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/huggingface

3 · Configure

cp .env.example .env                 # then set a real SECRET_KEY

4 · Run

# 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 dev

Open http://localhost:3000 · API docs at http://127.0.0.1:8001/docs


Performance

Local inference speed depends entirely on your CPU. Benchmark before picking a model size:

python scripts/benchmark.py

Reference 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.gguf

Note

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.


Deployment

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

API

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

Data model

MongoDB collections (click to expand)

usersemail (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
}

messageschat_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.


Design decisions

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_retriever is 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/health still responds while a 50-second generation is in flight.
  • Port 8001, and 127.0.0.1 not localhost. localhost can resolve to IPv6 ::1 first, letting a container bound to [::]:PORT intercept API calls. Configured in one place: frontend/src/config.js.

Project structure

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

Troubleshooting

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/cpu
Qdrant connection errors on startup

The container isn't up. From backend/:

docker compose up -d
curl localhost:6333/readyz

The 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.


About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages