hetsheta commited on
Commit
ea50fb7
Β·
0 Parent(s):

Initial commit

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .env.example +40 -0
  2. .gitignore +45 -0
  3. README.md +197 -0
  4. app/__init__.py +0 -0
  5. app/api/__init__.py +0 -0
  6. app/api/routes/__init__.py +0 -0
  7. app/api/routes/audio.py +101 -0
  8. app/api/routes/auth.py +71 -0
  9. app/api/routes/conversations.py +103 -0
  10. app/api/routes/documents.py +151 -0
  11. app/api/routes/health_admin.py +103 -0
  12. app/api/routes/query.py +61 -0
  13. app/core/__init__.py +0 -0
  14. app/core/config.py +67 -0
  15. app/core/security.py +56 -0
  16. app/db/__init__.py +0 -0
  17. app/db/chat_db.py +182 -0
  18. app/db/database.py +72 -0
  19. app/main.py +74 -0
  20. app/schemas/__init__.py +0 -0
  21. app/schemas/schemas.py +147 -0
  22. app/services/__init__.py +0 -0
  23. app/services/embedding_service.py +37 -0
  24. app/services/gemini_service.py +41 -0
  25. app/services/ingestion_service.py +396 -0
  26. app/services/llm_service.py +141 -0
  27. app/services/qdrant_service.py +184 -0
  28. app/services/rag_pipeline.py +226 -0
  29. app/services/reranker_service.py +43 -0
  30. app/services/speech_service.py +245 -0
  31. app/services/stt_service.py +161 -0
  32. frontend/index.html +16 -0
  33. frontend/package-lock.json +0 -0
  34. frontend/package.json +33 -0
  35. frontend/postcss.config.js +3 -0
  36. frontend/src/App.jsx +60 -0
  37. frontend/src/api/client.js +104 -0
  38. frontend/src/components/chat/ChatInput.jsx +442 -0
  39. frontend/src/components/chat/MessageBubble.jsx +177 -0
  40. frontend/src/components/chat/UploadModal.jsx +198 -0
  41. frontend/src/components/chat/WelcomeScreen.jsx +45 -0
  42. frontend/src/components/layout/Sidebar.jsx +211 -0
  43. frontend/src/components/ui/ProtectedRoute.jsx +15 -0
  44. frontend/src/index.css +97 -0
  45. frontend/src/main.jsx +10 -0
  46. frontend/src/pages/AdminPage.jsx +273 -0
  47. frontend/src/pages/AuthPage.jsx +152 -0
  48. frontend/src/pages/ChatPage.jsx +214 -0
  49. frontend/src/store/index.js +144 -0
  50. frontend/tailwind.config.js +43 -0
.env.example ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ APP_NAME="MedRAG"
2
+ APP_VERSION="1.0.0"
3
+ DEBUG=false
4
+
5
+ SECRET_KEY=CHANGE_THIS_TO_A_LONG_RANDOM_STRING_IN_PRODUCTION
6
+ ALGORITHM=HS256
7
+ ACCESS_TOKEN_EXPIRE_MINUTES=60
8
+ REFRESH_TOKEN_EXPIRE_DAYS=30
9
+
10
+ QDRANT_HOST=localhost
11
+ QDRANT_PORT=6333
12
+ QDRANT_API_KEY=
13
+ QDRANT_PATH=data/qdrant
14
+ QDRANT_COLLECTION_NAME=medical-knowledge-rag
15
+ QDRANT_NAMESPACE=medical-docs
16
+ QDRANT_VECTOR_SIZE=768
17
+
18
+ GROQ_API_KEY=your-groq-api-key-here
19
+ LLM_MODEL=meta-llama/llama-4-scout-17b-16e-instruct
20
+ LLM_MAX_TOKENS=1024
21
+ LLM_TEMPERATURE=0.1
22
+
23
+ EMBEDDING_MODEL=NeuML/pubmedbert-base-embeddings
24
+ SPEECH_MODEL=Qwen/Qwen3-ASR-1.7B
25
+ SPEECH_DEVICE=
26
+ SPEECH_MAX_UPLOAD_MB=25
27
+ TTS_VOICE=af_heart
28
+ TTS_SPEED=1.0
29
+
30
+ RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
31
+ RERANKER_TOP_K=5
32
+ RERANKER_MAX_CHUNKS_PER_DOC=2
33
+
34
+ RAG_RETRIEVAL_TOP_K=20
35
+ RAG_CHUNK_SIZE=512
36
+ RAG_CHUNK_OVERLAP=64
37
+
38
+ DATABASE_URL=sqlite+aiosqlite:///./data/medrag_main.db
39
+
40
+ ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
.gitignore ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environments
2
+ .env
3
+ .env.local
4
+ .env.development.local
5
+ .env.test.local
6
+ .env.production.local
7
+
8
+ # Python
9
+ venv/
10
+ .venv/
11
+ ENV/
12
+ env/
13
+ __pycache__/
14
+ *.pyc
15
+ *.pyo
16
+ *.pyd
17
+ .pytest_cache/
18
+ .coverage
19
+ htmlcov/
20
+
21
+ # Node / JS
22
+ node_modules/
23
+ dist/
24
+ build/
25
+ .eslintcache
26
+ .parcel-cache
27
+ .cache/
28
+ .vite/
29
+
30
+ # Databases
31
+ data/*.db
32
+ data/qdrant/
33
+ *.db
34
+
35
+ # Logs
36
+ *.log
37
+ logs/
38
+ npm-debug.log*
39
+ yarn-debug.log*
40
+ yarn-error.log*
41
+ pnpm-debug.log*
42
+
43
+ # OS files
44
+ .DS_Store
45
+ Thumbs.db
README.md ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # πŸ₯ MedRAG β€” Medical Knowledge RAG System
2
+
3
+ A full-stack, production-ready Retrieval-Augmented Generation system for medical knowledge, with a ChatGPT-style interface, per-user chat history, JWT auth, and an admin dashboard.
4
+
5
+ ## Tech Stack
6
+
7
+ | Layer | Technology |
8
+ |---|---|
9
+ | **LLM** | Llama 4 Scout 17B via **Groq** |
10
+ | **Embeddings** | `NeuML/pubmedbert-base-embeddings` |
11
+ | **Reranker** | `cross-encoder/ms-marco-MiniLM-L-6-v2` |
12
+ | **Vector Store** | **Qdrant** (local path storage) |
13
+ | **Backend** | **FastAPI** + SQLAlchemy async |
14
+ | **Auth** | **JWT** (access + refresh tokens) |
15
+ | **Chat DB** | Per-user SQLite β€” each user gets `data/users/<id>/chats.db` |
16
+ | **Frontend** | React + Tailwind + Zustand + Framer Motion |
17
+
18
+ ---
19
+
20
+ ## Project Structure
21
+
22
+ ```
23
+ medical-rag/
24
+ β”œβ”€β”€ app/
25
+ β”‚ β”œβ”€β”€ main.py # FastAPI app, lifespan, routers
26
+ β”‚ β”œβ”€β”€ core/
27
+ β”‚ β”‚ β”œβ”€β”€ config.py # Pydantic settings
28
+ β”‚ β”‚ └── security.py # JWT helpers
29
+ β”‚ β”œβ”€β”€ db/
30
+ β”‚ β”‚ β”œβ”€β”€ database.py # Shared DB: users + documents
31
+ β”‚ β”‚ └── chat_db.py # Per-user chat DB (conversations + messages)
32
+ β”‚ β”œβ”€β”€ schemas/schemas.py # All Pydantic models
33
+ β”‚ β”œβ”€β”€ services/
34
+ β”‚ β”‚ β”œβ”€β”€ embedding_service.py # PubMedBERT async encoder
35
+ β”‚ β”‚ β”œβ”€β”€ reranker_service.py # Cross-encoder reranker
36
+ β”‚ β”‚ β”œβ”€β”€ qdrant_service.py # Qdrant CRUD
37
+ β”‚ β”‚ β”œβ”€β”€ llm_service.py # Groq β†’ Llama 4 Scout
38
+ β”‚ β”‚ β”œβ”€β”€ ingestion_service.py # PDF/DOCX/TXT β†’ chunk β†’ embed β†’ store
39
+ β”‚ β”‚ └── rag_pipeline.py # Retrieve β†’ Rerank β†’ Generate β†’ Persist
40
+ β”‚ └── api/routes/
41
+ β”‚ β”œβ”€β”€ auth.py # /auth/register, /login, /refresh, /me
42
+ β”‚ β”œβ”€β”€ conversations.py # /conversations CRUD
43
+ β”‚ β”œβ”€β”€ query.py # /query (RAG endpoint)
44
+ β”‚ β”œβ”€β”€ documents.py # /documents upload/list/delete
45
+ β”‚ └── health_admin.py # /health, /admin/*
46
+ β”œβ”€β”€ frontend/
47
+ β”‚ └── src/
48
+ β”‚ β”œβ”€β”€ api/client.js # Axios + auto-refresh interceptor
49
+ β”‚ β”œβ”€β”€ store/index.js # Zustand: auth + chat state
50
+ β”‚ β”œβ”€β”€ pages/
51
+ β”‚ β”‚ β”œβ”€β”€ AuthPage.jsx # Login + Register
52
+ β”‚ β”‚ β”œβ”€β”€ ChatPage.jsx # Main ChatGPT-style chat
53
+ β”‚ β”‚ └── AdminPage.jsx # Admin dashboard
54
+ β”‚ └── components/
55
+ β”‚ β”œβ”€β”€ layout/Sidebar.jsx # Conversation list, search, grouped by date
56
+ β”‚ └── chat/
57
+ β”‚ β”œβ”€β”€ MessageBubble.jsx # Markdown, sources, timing
58
+ β”‚ β”œβ”€β”€ ChatInput.jsx # Textarea + PDF upload + button
59
+ β”‚ β”œβ”€β”€ UploadModal.jsx # Drag & drop upload dialog
60
+ β”‚ └── WelcomeScreen.jsx # Suggestion cards
61
+ β”œβ”€β”€ scripts/
62
+ β”‚ └── create_admin.py # First admin user seeder
63
+ └── requirements.txt
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Quickstart
69
+
70
+ ### 1. Clone & configure
71
+
72
+ ```bash
73
+ cp .env.example .env
74
+ # Edit .env β€” fill in GROQ_API_KEY and SECRET_KEY
75
+ ```
76
+
77
+ ### 2. Backend (local dev)
78
+
79
+ ```bash
80
+ python -m venv .venv
81
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
82
+ pip install -r requirements.txt
83
+
84
+ # Create first admin user
85
+ python scripts/create_admin.py \
86
+ --email admin@hospital.com \
87
+ --password yourpassword \
88
+ --name "Dr. Admin"
89
+
90
+ # Start server
91
+ uvicorn app.main:app --reload --port 8000
92
+ ```
93
+
94
+ API docs: http://localhost:8000/docs
95
+
96
+ ### 3. Frontend (local dev)
97
+
98
+ ```bash
99
+ cd frontend
100
+ npm install
101
+ npm run dev
102
+ # β†’ http://localhost:5173
103
+ ```
104
+
105
+ ---
106
+
107
+ ## RAG Pipeline
108
+
109
+ ```
110
+ User question
111
+ β”‚
112
+ β–Ό
113
+ [PubMedBERT Embed] β€” medical-domain embedding
114
+ β”‚
115
+ β–Ό
116
+ [Qdrant Search] β€” top-20 cosine similarity candidates
117
+ β”‚
118
+ β–Ό
119
+ [Cross-Encoder] β€” ms-marco-MiniLM-L-6-v2 reranks to top-5
120
+ β”‚
121
+ β–Ό
122
+ [Llama 4 Scout] β€” Groq inference, medical system prompt
123
+ β”‚
124
+ β–Ό
125
+ Answer + Sources β€” saved to user's per-user SQLite chat DB
126
+ ```
127
+
128
+ ---
129
+
130
+ ## API Reference
131
+
132
+ | Method | Endpoint | Auth | Description |
133
+ |---|---|---|---|
134
+ | POST | `/api/v1/auth/register` | None | Create account |
135
+ | POST | `/api/v1/auth/login` | None | Get JWT tokens |
136
+ | POST | `/api/v1/auth/refresh` | None | Refresh access token |
137
+ | GET | `/api/v1/auth/me` | User | Current user info |
138
+ | GET | `/api/v1/conversations` | User | List user's conversations |
139
+ | POST | `/api/v1/conversations` | User | Create new conversation |
140
+ | GET | `/api/v1/conversations/{id}/messages` | User | Get chat history |
141
+ | PATCH | `/api/v1/conversations/{id}` | User | Rename conversation |
142
+ | DELETE | `/api/v1/conversations/{id}` | User | Delete conversation |
143
+ | POST | `/api/v1/query` | User | RAG query (returns answer + sources) |
144
+ | GET | `/api/v1/documents` | User | List all documents |
145
+ | POST | `/api/v1/documents/upload` | Admin | Upload + ingest document |
146
+ | DELETE | `/api/v1/documents/{id}` | Admin | Delete document + vectors |
147
+ | GET | `/api/v1/admin/stats` | Admin | System statistics |
148
+ | GET | `/api/v1/admin/users` | Admin | All users |
149
+ | PATCH | `/api/v1/admin/users/{id}/role` | Admin | Change user role |
150
+ | PATCH | `/api/v1/admin/users/{id}/toggle` | Admin | Enable/disable user |
151
+
152
+ ---
153
+
154
+ ## Per-User Chat Database
155
+
156
+ Each user gets a completely isolated SQLite file:
157
+
158
+ ```
159
+ data/
160
+ β”œβ”€β”€ medrag_main.db # Shared: users + documents registry
161
+ └── users/
162
+ β”œβ”€β”€ <user-id-1>/
163
+ β”‚ └── chats.db # conversations + messages for user 1
164
+ β”œβ”€β”€ <user-id-2>/
165
+ β”‚ └── chats.db # completely separate for user 2
166
+ └── ...
167
+ ```
168
+
169
+ Conversations are auto-titled from the first message and grouped in the UI by Today / Last 7 days / Older.
170
+
171
+ ---
172
+
173
+ ## Environment Variables
174
+
175
+ | Variable | Description |
176
+ |---|---|
177
+ | `SECRET_KEY` | JWT signing secret (use a long random string) |
178
+ | `GROQ_API_KEY` | Your Groq API key |
179
+ | `LLM_MODEL` | `meta-llama/llama-4-scout-17b-16e-instruct` |
180
+ | `QDRANT_PATH` | `data/qdrant` |
181
+ | `QDRANT_COLLECTION_NAME` | `medical-knowledge-rag` |
182
+ | `QDRANT_NAMESPACE` | `medical-docs` |
183
+ | `EMBEDDING_MODEL` | `NeuML/pubmedbert-base-embeddings` |
184
+ | `RERANKER_MODEL` | `cross-encoder/ms-marco-MiniLM-L-6-v2` |
185
+ | `RAG_RETRIEVAL_TOP_K` | Candidates from Qdrant (default: 20) |
186
+ | `RERANKER_TOP_K` | Final chunks after rerank (default: 5) |
187
+ | `RAG_CHUNK_SIZE` | Tokens per chunk (default: 512) |
188
+ | `RAG_CHUNK_OVERLAP` | Overlap tokens (default: 64) |
189
+
190
+ ---
191
+
192
+ ## Notes
193
+
194
+ - **Document upload is admin-only** β€” regular users query but admins manage the knowledge base
195
+ - **No documents = no answers** β€” ingest PDFs/DOCX first before querying
196
+ - The first model load (PubMedBERT + cross-encoder) takes ~30–60 seconds; subsequent requests are fast
197
+ - For production: swap SQLite for PostgreSQL (`asyncpg` driver) and optionally move Qdrant to a managed/cloud deployment
app/__init__.py ADDED
File without changes
app/api/__init__.py ADDED
File without changes
app/api/routes/__init__.py ADDED
File without changes
app/api/routes/audio.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import soundfile as sf
9
+ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
10
+ from fastapi.responses import StreamingResponse
11
+ from loguru import logger
12
+
13
+ from app.core.config import get_settings
14
+ from app.core.security import get_current_user_id
15
+ from app.schemas.schemas import SpeechToTextResponse, TextToSpeechRequest
16
+ from app.services.speech_service import get_speech_service, get_tts_service
17
+
18
+ router = APIRouter(prefix="/audio", tags=["audio"])
19
+
20
+ ALLOWED_AUDIO_EXTENSIONS = {"wav", "mp3", "m4a", "mp4", "webm", "ogg", "flac", "opus"}
21
+
22
+
23
+ def _validate_audio_file(filename: str, size_bytes: int) -> None:
24
+ settings = get_settings()
25
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
26
+ if ext not in ALLOWED_AUDIO_EXTENSIONS:
27
+ raise HTTPException(
28
+ status_code=status.HTTP_400_BAD_REQUEST,
29
+ detail=f"Unsupported audio format. Allowed: {', '.join(sorted(ALLOWED_AUDIO_EXTENSIONS))}",
30
+ )
31
+ max_bytes = settings.speech_max_upload_mb * 1024 * 1024
32
+ if size_bytes > max_bytes:
33
+ raise HTTPException(
34
+ status_code=status.HTTP_400_BAD_REQUEST,
35
+ detail=f"Audio file is too large. Maximum size is {settings.speech_max_upload_mb} MB.",
36
+ )
37
+
38
+
39
+ @router.post("/transcribe", response_model=SpeechToTextResponse)
40
+ async def transcribe_audio(
41
+ file: UploadFile = File(...),
42
+ _: str = Depends(get_current_user_id),
43
+ ):
44
+ filename = file.filename or "recording.wav"
45
+ raw_bytes = await file.read()
46
+ if not raw_bytes:
47
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Audio file is empty")
48
+
49
+ _validate_audio_file(filename, len(raw_bytes))
50
+ suffix = Path(filename).suffix or ".wav"
51
+ temp_path = None
52
+ try:
53
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
54
+ temp_file.write(raw_bytes)
55
+ temp_path = temp_file.name
56
+ text = get_speech_service().transcribe(temp_path)
57
+ except Exception as exc:
58
+ logger.exception("STT transcription failed for {}", filename)
59
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
60
+ finally:
61
+ if temp_path and os.path.exists(temp_path):
62
+ os.unlink(temp_path)
63
+
64
+ if not text.strip():
65
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="No speech was detected in the recording")
66
+
67
+ return SpeechToTextResponse(
68
+ text=text.strip(),
69
+ language=None,
70
+ model=get_settings().speech_model,
71
+ filename=filename,
72
+ )
73
+
74
+
75
+ @router.post("/speak")
76
+ async def speak_text(
77
+ request: TextToSpeechRequest,
78
+ _: str = Depends(get_current_user_id),
79
+ ):
80
+ try:
81
+ audio, sample_rate = get_tts_service().speak(
82
+ request.text,
83
+ voice=request.voice or get_settings().tts_voice,
84
+ speed=request.speed,
85
+ play=False,
86
+ )
87
+ except Exception as exc:
88
+ logger.exception("TTS generation failed")
89
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
90
+
91
+ if audio is None or sample_rate <= 0:
92
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Text-to-speech produced no audio")
93
+
94
+ wav_bytes = io.BytesIO()
95
+ sf.write(wav_bytes, audio, sample_rate, format="WAV")
96
+ wav_bytes.seek(0)
97
+ return StreamingResponse(
98
+ wav_bytes,
99
+ media_type="audio/wav",
100
+ headers={"Content-Disposition": "inline; filename=tts.wav"},
101
+ )
app/api/routes/auth.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from fastapi import APIRouter, Depends, HTTPException, status
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+ from sqlalchemy import select
5
+
6
+ from app.core.security import (
7
+ hash_password, verify_password,
8
+ create_access_token, create_refresh_token,
9
+ decode_token, get_current_user_id,
10
+ )
11
+ from app.db.database import User, get_db
12
+ from app.db.chat_db import ensure_user_db
13
+ from app.schemas.schemas import UserRegister, UserLogin, TokenResponse, RefreshRequest, UserOut
14
+
15
+ router = APIRouter(prefix="/auth", tags=["auth"])
16
+
17
+
18
+ @router.post("/register", response_model=UserOut, status_code=201)
19
+ async def register(payload: UserRegister, db: AsyncSession = Depends(get_db)):
20
+ existing = await db.execute(select(User).where(User.email == payload.email))
21
+ if existing.scalar_one_or_none():
22
+ raise HTTPException(status_code=400, detail="Email already registered")
23
+
24
+ user = User(
25
+ id=str(uuid.uuid4()),
26
+ email=payload.email,
27
+ hashed_password=hash_password(payload.password),
28
+ full_name=payload.full_name,
29
+ role="user",
30
+ )
31
+ db.add(user)
32
+ await db.commit()
33
+ await db.refresh(user)
34
+ # Provision per-user chat DB immediately
35
+ await ensure_user_db(user.id)
36
+ return user
37
+
38
+
39
+ @router.post("/login", response_model=TokenResponse)
40
+ async def login(payload: UserLogin, db: AsyncSession = Depends(get_db)):
41
+ result = await db.execute(select(User).where(User.email == payload.email))
42
+ user = result.scalar_one_or_none()
43
+ if not user or not verify_password(payload.password, user.hashed_password):
44
+ raise HTTPException(status_code=401, detail="Incorrect email or password")
45
+ if not user.is_active:
46
+ raise HTTPException(status_code=403, detail="Account is disabled")
47
+ return TokenResponse(
48
+ access_token=create_access_token(user.id),
49
+ refresh_token=create_refresh_token(user.id),
50
+ )
51
+
52
+
53
+ @router.post("/refresh", response_model=TokenResponse)
54
+ async def refresh(payload: RefreshRequest):
55
+ claims = decode_token(payload.refresh_token)
56
+ if claims.get("kind") != "refresh":
57
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
58
+ user_id: str = claims["sub"]
59
+ return TokenResponse(
60
+ access_token=create_access_token(user_id),
61
+ refresh_token=create_refresh_token(user_id),
62
+ )
63
+
64
+
65
+ @router.get("/me", response_model=UserOut)
66
+ async def me(user_id: str = Depends(get_current_user_id), db: AsyncSession = Depends(get_db)):
67
+ result = await db.execute(select(User).where(User.id == user_id))
68
+ user = result.scalar_one_or_none()
69
+ if not user:
70
+ raise HTTPException(status_code=404, detail="User not found")
71
+ return user
app/api/routes/conversations.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Conversation management routes β€” ChatGPT-style sidebar.
3
+ """
4
+ import json
5
+ from typing import List
6
+ from fastapi import APIRouter, Depends, HTTPException
7
+
8
+ from app.core.security import get_current_user_id
9
+ from app.db import chat_db
10
+ from app.schemas.schemas import (
11
+ ConversationOut,
12
+ ConversationCreate,
13
+ ConversationRename,
14
+ MessageOut,
15
+ SourceChunk,
16
+ )
17
+
18
+ router = APIRouter(prefix="/conversations", tags=["conversations"])
19
+
20
+
21
+ def _msg_to_out(msg) -> MessageOut:
22
+ sources = None
23
+ meta = None
24
+ if msg.sources_json:
25
+ try:
26
+ raw = json.loads(msg.sources_json)
27
+ sources = [SourceChunk(**s) for s in raw]
28
+ except Exception:
29
+ sources = None
30
+ if getattr(msg, "meta_json", None):
31
+ try:
32
+ meta = json.loads(msg.meta_json)
33
+ except Exception:
34
+ meta = None
35
+ return MessageOut(
36
+ id=msg.id,
37
+ conversation_id=msg.conversation_id,
38
+ role=msg.role,
39
+ content=msg.content,
40
+ sources=sources,
41
+ meta=meta,
42
+ created_at=msg.created_at,
43
+ )
44
+
45
+
46
+ @router.post("", response_model=ConversationOut, status_code=201)
47
+ async def create_conversation(
48
+ payload: ConversationCreate,
49
+ user_id: str = Depends(get_current_user_id),
50
+ ):
51
+ conv = await chat_db.create_conversation(user_id, payload.title)
52
+ return ConversationOut(
53
+ id=conv.id,
54
+ title=conv.title,
55
+ created_at=conv.created_at,
56
+ updated_at=conv.updated_at,
57
+ )
58
+
59
+
60
+ @router.get("", response_model=List[ConversationOut])
61
+ async def list_conversations(user_id: str = Depends(get_current_user_id)):
62
+ convs = await chat_db.list_conversations(user_id)
63
+ return [
64
+ ConversationOut(id=c.id, title=c.title, created_at=c.created_at, updated_at=c.updated_at)
65
+ for c in convs
66
+ ]
67
+
68
+
69
+ @router.get("/{conv_id}/messages", response_model=List[MessageOut])
70
+ async def get_messages(
71
+ conv_id: str,
72
+ user_id: str = Depends(get_current_user_id),
73
+ ):
74
+ conv = await chat_db.get_conversation(user_id, conv_id)
75
+ if not conv:
76
+ raise HTTPException(status_code=404, detail="Conversation not found")
77
+ msgs = await chat_db.get_messages(user_id, conv_id)
78
+ return [_msg_to_out(m) for m in msgs]
79
+
80
+
81
+ @router.patch("/{conv_id}", response_model=ConversationOut)
82
+ async def rename_conversation(
83
+ conv_id: str,
84
+ payload: ConversationRename,
85
+ user_id: str = Depends(get_current_user_id),
86
+ ):
87
+ conv = await chat_db.get_conversation(user_id, conv_id)
88
+ if not conv:
89
+ raise HTTPException(status_code=404, detail="Conversation not found")
90
+ await chat_db.rename_conversation(user_id, conv_id, payload.title)
91
+ conv = await chat_db.get_conversation(user_id, conv_id)
92
+ return ConversationOut(id=conv.id, title=conv.title, created_at=conv.created_at, updated_at=conv.updated_at)
93
+
94
+
95
+ @router.delete("/{conv_id}", status_code=204)
96
+ async def delete_conversation(
97
+ conv_id: str,
98
+ user_id: str = Depends(get_current_user_id),
99
+ ):
100
+ conv = await chat_db.get_conversation(user_id, conv_id)
101
+ if not conv:
102
+ raise HTTPException(status_code=404, detail="Conversation not found")
103
+ await chat_db.delete_conversation(user_id, conv_id)
app/api/routes/documents.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+ from sqlalchemy import select
5
+ from typing import List
6
+
7
+ from app.core.security import get_current_user_id
8
+ from app.db.database import get_db, DocumentRecord, User
9
+ from app.schemas.schemas import BatchIngestResponse, DocumentOut
10
+ from app.services.ingestion_service import (
11
+ create_document_record,
12
+ get_conversation_documents,
13
+ get_all_documents,
14
+ get_document,
15
+ schedule_ingestion,
16
+ validate_medical_document,
17
+ )
18
+ from app.db import chat_db
19
+ from app.services.qdrant_service import get_qdrant_service
20
+
21
+ router = APIRouter(prefix="/documents", tags=["documents"])
22
+ ALLOWED_EXT = {"pdf", "docx", "txt"}
23
+
24
+
25
+ def _check_ext(filename: str) -> None:
26
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
27
+ if ext not in ALLOWED_EXT:
28
+ raise HTTPException(status_code=400, detail=f"Unsupported file type. Allowed: {', '.join(ALLOWED_EXT)}")
29
+
30
+
31
+ async def _require_admin(user_id: str, db: AsyncSession) -> User:
32
+ result = await db.execute(select(User).where(User.id == user_id))
33
+ user = result.scalar_one_or_none()
34
+ if not user or user.role != "admin":
35
+ raise HTTPException(status_code=403, detail="Admin access required")
36
+ return user
37
+
38
+
39
+ @router.post("/upload", response_model=BatchIngestResponse, status_code=202)
40
+ async def upload_document(
41
+ files: List[UploadFile] = File(...),
42
+ conversation_id: str = Form(...),
43
+ title: str | None = Form(default=None),
44
+ source: str | None = Form(default=None),
45
+ user_id: str = Depends(get_current_user_id),
46
+ db: AsyncSession = Depends(get_db),
47
+ ):
48
+ """Queue one or more medical documents for ingestion."""
49
+ conv = await chat_db.get_conversation(user_id, conversation_id)
50
+ if not conv:
51
+ raise HTTPException(status_code=404, detail="Conversation not found")
52
+ pending_documents: List[tuple[str, bytes, str]] = []
53
+
54
+ for file in files:
55
+ filename = file.filename or "unknown"
56
+ _check_ext(filename)
57
+ raw_bytes = await file.read()
58
+ try:
59
+ validate_medical_document(filename, raw_bytes)
60
+ except ValueError as exc:
61
+ raise HTTPException(status_code=400, detail=f"{filename}: {exc}") from exc
62
+
63
+ doc_title = title.strip() if title and len(files) == 1 else Path(filename).stem.replace("-", " ").replace("_", " ")
64
+ pending_documents.append((filename, raw_bytes, doc_title))
65
+
66
+ document_ids: List[str] = []
67
+ for filename, raw_bytes, doc_title in pending_documents:
68
+ doc_id = await create_document_record(
69
+ filename=filename,
70
+ title=doc_title,
71
+ source=source,
72
+ user_id=user_id,
73
+ conversation_id=conversation_id,
74
+ db=db,
75
+ )
76
+ schedule_ingestion(
77
+ doc_id=doc_id,
78
+ filename=filename,
79
+ title=doc_title,
80
+ source=source,
81
+ raw_bytes=raw_bytes,
82
+ )
83
+ document_ids.append(doc_id)
84
+
85
+ noun = "document" if len(document_ids) == 1 else "documents"
86
+ return BatchIngestResponse(
87
+ document_ids=document_ids,
88
+ message=f"Queued {len(document_ids)} {noun} for ingestion",
89
+ )
90
+
91
+
92
+ @router.get("", response_model=List[DocumentOut])
93
+ async def list_documents(
94
+ conversation_id: str | None = None,
95
+ user_id: str = Depends(get_current_user_id),
96
+ db: AsyncSession = Depends(get_db),
97
+ ):
98
+ if conversation_id:
99
+ conv = await chat_db.get_conversation(user_id, conversation_id)
100
+ if not conv:
101
+ raise HTTPException(status_code=404, detail="Conversation not found")
102
+ docs = await get_conversation_documents(conversation_id, db)
103
+ else:
104
+ docs = await get_all_documents(db)
105
+ return docs
106
+
107
+
108
+ @router.get("/{doc_id}", response_model=DocumentOut)
109
+ async def get_document_detail(
110
+ doc_id: str,
111
+ user_id: str = Depends(get_current_user_id),
112
+ db: AsyncSession = Depends(get_db),
113
+ ):
114
+ doc = await get_document(doc_id, db)
115
+ if not doc:
116
+ raise HTTPException(status_code=404, detail="Document not found")
117
+ return doc
118
+
119
+
120
+ @router.delete("/conversation/{conversation_id}", status_code=204)
121
+ async def clear_conversation_documents(
122
+ conversation_id: str,
123
+ user_id: str = Depends(get_current_user_id),
124
+ db: AsyncSession = Depends(get_db),
125
+ ):
126
+ conv = await chat_db.get_conversation(user_id, conversation_id)
127
+ if not conv:
128
+ raise HTTPException(status_code=404, detail="Conversation not found")
129
+
130
+ docs = await get_conversation_documents(conversation_id, db)
131
+ qdrant = get_qdrant_service()
132
+ for doc in docs:
133
+ await qdrant.delete_by_doc_id(doc.id)
134
+ await db.delete(doc)
135
+ await db.commit()
136
+
137
+
138
+ @router.delete("/{doc_id}", status_code=204)
139
+ async def delete_document(
140
+ doc_id: str,
141
+ user_id: str = Depends(get_current_user_id),
142
+ db: AsyncSession = Depends(get_db),
143
+ ):
144
+ await _require_admin(user_id, db)
145
+ doc = await get_document(doc_id, db)
146
+ if not doc:
147
+ raise HTTPException(status_code=404, detail="Document not found")
148
+ qdrant = get_qdrant_service()
149
+ await qdrant.delete_by_doc_id(doc_id)
150
+ await db.delete(doc)
151
+ await db.commit()
app/api/routes/health_admin.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from sqlalchemy import select, func
4
+ from typing import List
5
+
6
+ from app.core.security import get_current_user_id
7
+ from app.db.database import get_db, User, DocumentRecord
8
+ from app.schemas.schemas import HealthResponse, UserOut
9
+ from app.services.qdrant_service import get_qdrant_service
10
+ from app.services.llm_service import get_llm_service
11
+ from app.core.config import get_settings
12
+
13
+ settings = get_settings()
14
+
15
+ health_router = APIRouter(tags=["health"])
16
+ admin_router = APIRouter(prefix="/admin", tags=["admin"])
17
+
18
+
19
+ @health_router.get("/health", response_model=HealthResponse)
20
+ async def health():
21
+ qdrant_status = await get_qdrant_service().health()
22
+ llm_status = await get_llm_service().health()
23
+ return HealthResponse(
24
+ status="ok" if qdrant_status == "ok" and llm_status == "ok" else "degraded",
25
+ qdrant=qdrant_status,
26
+ llm=llm_status,
27
+ version=settings.app_version,
28
+ )
29
+
30
+
31
+ async def _require_admin(user_id: str, db: AsyncSession) -> User:
32
+ result = await db.execute(select(User).where(User.id == user_id))
33
+ user = result.scalar_one_or_none()
34
+ if not user or user.role != "admin":
35
+ raise HTTPException(status_code=403, detail="Admin access required")
36
+ return user
37
+
38
+
39
+ @admin_router.get("/users", response_model=List[UserOut])
40
+ async def list_users(
41
+ user_id: str = Depends(get_current_user_id),
42
+ db: AsyncSession = Depends(get_db),
43
+ ):
44
+ await _require_admin(user_id, db)
45
+ result = await db.execute(select(User).order_by(User.created_at.desc()))
46
+ return result.scalars().all()
47
+
48
+
49
+ @admin_router.patch("/users/{target_id}/role")
50
+ async def update_user_role(
51
+ target_id: str,
52
+ role: str,
53
+ user_id: str = Depends(get_current_user_id),
54
+ db: AsyncSession = Depends(get_db),
55
+ ):
56
+ await _require_admin(user_id, db)
57
+ if role not in ("user", "admin"):
58
+ raise HTTPException(status_code=400, detail="Role must be 'user' or 'admin'")
59
+ result = await db.execute(select(User).where(User.id == target_id))
60
+ target = result.scalar_one_or_none()
61
+ if not target:
62
+ raise HTTPException(status_code=404, detail="User not found")
63
+ target.role = role
64
+ await db.commit()
65
+ return {"id": target_id, "role": role}
66
+
67
+
68
+ @admin_router.patch("/users/{target_id}/toggle")
69
+ async def toggle_user_active(
70
+ target_id: str,
71
+ user_id: str = Depends(get_current_user_id),
72
+ db: AsyncSession = Depends(get_db),
73
+ ):
74
+ await _require_admin(user_id, db)
75
+ result = await db.execute(select(User).where(User.id == target_id))
76
+ target = result.scalar_one_or_none()
77
+ if not target:
78
+ raise HTTPException(status_code=404, detail="User not found")
79
+ target.is_active = not target.is_active
80
+ await db.commit()
81
+ return {"id": target_id, "is_active": target.is_active}
82
+
83
+
84
+ @admin_router.get("/stats")
85
+ async def admin_stats(
86
+ user_id: str = Depends(get_current_user_id),
87
+ db: AsyncSession = Depends(get_db),
88
+ ):
89
+ await _require_admin(user_id, db)
90
+ user_count = await db.scalar(select(func.count()).select_from(User))
91
+ doc_count = await db.scalar(select(func.count()).select_from(DocumentRecord))
92
+ ready_docs = await db.scalar(
93
+ select(func.count()).select_from(DocumentRecord).where(DocumentRecord.status == "ready")
94
+ )
95
+ total_chunks = await db.scalar(select(func.sum(DocumentRecord.chunk_count)).select_from(DocumentRecord))
96
+ qdrant_status = await get_qdrant_service().health()
97
+ return {
98
+ "total_users": user_count,
99
+ "total_documents": doc_count,
100
+ "ready_documents": ready_docs,
101
+ "total_chunks_indexed": total_chunks or 0,
102
+ "qdrant_status": qdrant_status,
103
+ }
app/api/routes/query.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from app.core.security import get_current_user_id
3
+ from app.schemas.schemas import QueryRequest, QueryResponse
4
+ from app.services.rag_pipeline import run_rag_pipeline
5
+ from app.db import chat_db
6
+ from app.db.database import get_db
7
+ from app.services.ingestion_service import get_conversation_ready_document_count
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+ from loguru import logger
10
+
11
+ router = APIRouter(prefix="/query", tags=["rag"])
12
+
13
+
14
+ @router.post("", response_model=QueryResponse)
15
+ async def query(
16
+ request: QueryRequest,
17
+ user_id: str = Depends(get_current_user_id),
18
+ db: AsyncSession = Depends(get_db),
19
+ ):
20
+ # Verify conversation belongs to user
21
+ conv = await chat_db.get_conversation(user_id, request.conversation_id)
22
+ if not conv:
23
+ raise HTTPException(status_code=404, detail="Conversation not found")
24
+ ready_docs = await get_conversation_ready_document_count(request.conversation_id, db)
25
+ if ready_docs == 0:
26
+ await chat_db.add_message(
27
+ user_id=user_id,
28
+ conv_id=request.conversation_id,
29
+ role="user",
30
+ content=request.query,
31
+ )
32
+ asst_msg = await chat_db.add_message(
33
+ user_id=user_id,
34
+ conv_id=request.conversation_id,
35
+ role="assistant",
36
+ content="Upload documents to this chat first",
37
+ )
38
+ messages = await chat_db.get_messages(user_id, request.conversation_id)
39
+ if len(messages) <= 2:
40
+ await chat_db.auto_title_conversation(user_id, request.conversation_id, request.query)
41
+ return QueryResponse(
42
+ message_id=asst_msg.id,
43
+ conversation_id=request.conversation_id,
44
+ query=request.query,
45
+ search_query=request.query,
46
+ answer="Upload documents to this chat first",
47
+ sources=[],
48
+ model="none",
49
+ retrieval_strategy="none",
50
+ conversation_turns_used=0,
51
+ retrieval_ms=0,
52
+ rerank_ms=0,
53
+ generation_ms=0,
54
+ )
55
+
56
+ logger.info(f"RAG query user={user_id} conv={request.conversation_id}: {request.query[:60]}...")
57
+ try:
58
+ return await run_rag_pipeline(request, user_id)
59
+ except Exception as exc:
60
+ logger.error(f"RAG pipeline error: {exc}")
61
+ raise HTTPException(status_code=500, detail=f"RAG pipeline failed: {str(exc)}")
app/core/__init__.py ADDED
File without changes
app/core/config.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+ from typing import List
3
+
4
+ from pydantic import Field
5
+ from pydantic_settings import BaseSettings
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ app_name: str = "MedRAG"
10
+ app_version: str = "1.0.0"
11
+ debug: bool = False
12
+
13
+ secret_key: str = Field(default="dev-secret-change-in-prod")
14
+ algorithm: str = "HS256"
15
+ access_token_expire_minutes: int = 60
16
+ refresh_token_expire_days: int = 30
17
+
18
+ qdrant_host: str = "localhost"
19
+ qdrant_port: int = 6333
20
+ qdrant_api_key: str | None = None
21
+ qdrant_path: str = "data/qdrant"
22
+ qdrant_collection_name: str = "medical-knowledge-rag"
23
+ qdrant_namespace: str = "medical-docs"
24
+ qdrant_vector_size: int = 768
25
+
26
+ groq_api_key: str = Field(default="")
27
+ google_api_key: str = Field(default="")
28
+ llm_model: str = "meta-llama/llama-4-scout-17b-16e-instruct"
29
+ gemini_model: str = "gemini-2.5-flash"
30
+ llm_max_tokens: int = 1024
31
+ llm_temperature: float = 0.1
32
+
33
+ embedding_model: str = "NeuML/pubmedbert-base-embeddings"
34
+ speech_model: str = "Qwen/Qwen3-ASR-1.7B"
35
+ speech_device: str | None = None
36
+ speech_max_upload_mb: int = 25
37
+ tts_voice: str = "af_heart"
38
+ tts_speed: float = 1.0
39
+
40
+ reranker_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
41
+ reranker_top_k: int = 5
42
+ reranker_max_chunks_per_doc: int = 2
43
+ reranker_min_score: float = -1.0
44
+ reranker_score_window: float = 1.5
45
+
46
+ rag_retrieval_top_k: int = 20
47
+ rag_chunk_size: int = 512
48
+ rag_chunk_overlap: int = 64
49
+ rag_conversation_history_turns: int = 6
50
+ rag_hybrid_rrf_k: int = 60
51
+
52
+ database_url: str = "sqlite+aiosqlite:///./data/medrag_main.db"
53
+ allowed_origins: str = "http://localhost:3000,http://localhost:5173"
54
+
55
+ @property
56
+ def allowed_origins_list(self) -> List[str]:
57
+ return [origin.strip() for origin in self.allowed_origins.split(",") if origin.strip()]
58
+
59
+ class Config:
60
+ env_file = ".env"
61
+ env_file_encoding = "utf-8"
62
+ case_sensitive = False
63
+
64
+
65
+ @lru_cache()
66
+ def get_settings() -> Settings:
67
+ return Settings()
app/core/security.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Optional
3
+ import bcrypt
4
+ from jose import JWTError, jwt
5
+ from fastapi import Depends, HTTPException, status
6
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
7
+
8
+ from app.core.config import get_settings
9
+
10
+ settings = get_settings()
11
+ bearer_scheme = HTTPBearer()
12
+
13
+
14
+ def hash_password(plain: str) -> str:
15
+ return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
16
+
17
+
18
+ def verify_password(plain: str, hashed: str) -> bool:
19
+ return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
20
+
21
+
22
+ def _create_token(subject: str, kind: str, expires_delta: timedelta) -> str:
23
+ expire = datetime.now(timezone.utc) + expires_delta
24
+ payload = {"sub": subject, "kind": kind, "exp": expire, "iat": datetime.now(timezone.utc)}
25
+ return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
26
+
27
+
28
+ def create_access_token(user_id: str) -> str:
29
+ return _create_token(user_id, "access", timedelta(minutes=settings.access_token_expire_minutes))
30
+
31
+
32
+ def create_refresh_token(user_id: str) -> str:
33
+ return _create_token(user_id, "refresh", timedelta(days=settings.refresh_token_expire_days))
34
+
35
+
36
+ def decode_token(token: str) -> dict:
37
+ try:
38
+ return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
39
+ except JWTError as exc:
40
+ raise HTTPException(
41
+ status_code=status.HTTP_401_UNAUTHORIZED,
42
+ detail="Could not validate credentials",
43
+ headers={"WWW-Authenticate": "Bearer"},
44
+ ) from exc
45
+
46
+
47
+ def get_current_user_id(
48
+ credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
49
+ ) -> str:
50
+ payload = decode_token(credentials.credentials)
51
+ if payload.get("kind") != "access":
52
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
53
+ user_id: Optional[str] = payload.get("sub")
54
+ if not user_id:
55
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
56
+ return user_id
app/db/__init__.py ADDED
File without changes
app/db/chat_db.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Per-user chat database.
3
+
4
+ Each user gets their own SQLite file at:
5
+ ./data/users/<user_id>/chats.db
6
+
7
+ Schema:
8
+ conversations – chat sessions (like ChatGPT sidebar items)
9
+ messages – individual messages within a conversation
10
+ """
11
+ import os
12
+ import uuid
13
+ from datetime import datetime, timezone
14
+ from typing import List, Optional
15
+
16
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
17
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
18
+ from sqlalchemy import String, DateTime, Text, ForeignKey, select, update
19
+ from loguru import logger
20
+
21
+
22
+ class ChatBase(DeclarativeBase):
23
+ pass
24
+
25
+
26
+ class Conversation(ChatBase):
27
+ __tablename__ = "conversations"
28
+
29
+ id: Mapped[str] = mapped_column(String(36), primary_key=True)
30
+ title: Mapped[str] = mapped_column(String(512), default="New Chat")
31
+ created_at: Mapped[datetime] = mapped_column(
32
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
33
+ )
34
+ updated_at: Mapped[datetime] = mapped_column(
35
+ DateTime(timezone=True),
36
+ default=lambda: datetime.now(timezone.utc),
37
+ onupdate=lambda: datetime.now(timezone.utc),
38
+ )
39
+
40
+
41
+ class Message(ChatBase):
42
+ __tablename__ = "messages"
43
+
44
+ id: Mapped[str] = mapped_column(String(36), primary_key=True)
45
+ conversation_id: Mapped[str] = mapped_column(
46
+ String(36), ForeignKey("conversations.id"), nullable=False, index=True
47
+ )
48
+ role: Mapped[str] = mapped_column(String(20), nullable=False) # "user" | "assistant"
49
+ content: Mapped[str] = mapped_column(Text, nullable=False)
50
+ sources_json: Mapped[str | None] = mapped_column(Text) # JSON-serialised SourceChunk list
51
+ meta_json: Mapped[str | None] = mapped_column(Text)
52
+ created_at: Mapped[datetime] = mapped_column(
53
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
54
+ )
55
+
56
+
57
+ # ─── Engine pool per user ─────────────────────────────────────────────────────
58
+ _engines: dict[str, any] = {}
59
+
60
+
61
+ def _get_user_engine(user_id: str):
62
+ if user_id not in _engines:
63
+ user_dir = f"./data/users/{user_id}"
64
+ os.makedirs(user_dir, exist_ok=True)
65
+ db_path = f"{user_dir}/chats.db"
66
+ engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", echo=False)
67
+ _engines[user_id] = engine
68
+ return _engines[user_id]
69
+
70
+
71
+ async def ensure_user_db(user_id: str) -> None:
72
+ engine = _get_user_engine(user_id)
73
+ async with engine.begin() as conn:
74
+ await conn.run_sync(ChatBase.metadata.create_all)
75
+ columns = await conn.exec_driver_sql("PRAGMA table_info(messages)")
76
+ column_names = {row[1] for row in columns.fetchall()}
77
+ if "meta_json" not in column_names:
78
+ await conn.exec_driver_sql("ALTER TABLE messages ADD COLUMN meta_json TEXT")
79
+ logger.debug(f"Chat DB ready for user {user_id}")
80
+
81
+
82
+ def _session_maker(user_id: str) -> async_sessionmaker:
83
+ return async_sessionmaker(_get_user_engine(user_id), expire_on_commit=False)
84
+
85
+
86
+ # ─── CRUD helpers ─────────────────────────────────────────────────────────────
87
+
88
+ async def create_conversation(user_id: str, title: str = "New Chat") -> Conversation:
89
+ await ensure_user_db(user_id)
90
+ async with _session_maker(user_id)() as db:
91
+ conv = Conversation(id=str(uuid.uuid4()), title=title)
92
+ db.add(conv)
93
+ await db.commit()
94
+ await db.refresh(conv)
95
+ return conv
96
+
97
+
98
+ async def list_conversations(user_id: str) -> List[Conversation]:
99
+ await ensure_user_db(user_id)
100
+ async with _session_maker(user_id)() as db:
101
+ result = await db.execute(
102
+ select(Conversation).order_by(Conversation.updated_at.desc())
103
+ )
104
+ return result.scalars().all()
105
+
106
+
107
+ async def get_conversation(user_id: str, conv_id: str) -> Optional[Conversation]:
108
+ await ensure_user_db(user_id)
109
+ async with _session_maker(user_id)() as db:
110
+ result = await db.execute(
111
+ select(Conversation).where(Conversation.id == conv_id)
112
+ )
113
+ return result.scalar_one_or_none()
114
+
115
+
116
+ async def rename_conversation(user_id: str, conv_id: str, new_title: str) -> None:
117
+ async with _session_maker(user_id)() as db:
118
+ await db.execute(
119
+ update(Conversation)
120
+ .where(Conversation.id == conv_id)
121
+ .values(title=new_title, updated_at=datetime.now(timezone.utc))
122
+ )
123
+ await db.commit()
124
+
125
+
126
+ async def delete_conversation(user_id: str, conv_id: str) -> None:
127
+ async with _session_maker(user_id)() as db:
128
+ result = await db.execute(select(Conversation).where(Conversation.id == conv_id))
129
+ conv = result.scalar_one_or_none()
130
+ if conv:
131
+ # cascade delete messages first
132
+ msgs = await db.execute(select(Message).where(Message.conversation_id == conv_id))
133
+ for msg in msgs.scalars().all():
134
+ await db.delete(msg)
135
+ await db.delete(conv)
136
+ await db.commit()
137
+
138
+
139
+ async def add_message(
140
+ user_id: str,
141
+ conv_id: str,
142
+ role: str,
143
+ content: str,
144
+ sources_json: str | None = None,
145
+ meta_json: str | None = None,
146
+ ) -> Message:
147
+ async with _session_maker(user_id)() as db:
148
+ msg = Message(
149
+ id=str(uuid.uuid4()),
150
+ conversation_id=conv_id,
151
+ role=role,
152
+ content=content,
153
+ sources_json=sources_json,
154
+ meta_json=meta_json,
155
+ )
156
+ db.add(msg)
157
+ # Touch conversation updated_at so it floats to top
158
+ await db.execute(
159
+ update(Conversation)
160
+ .where(Conversation.id == conv_id)
161
+ .values(updated_at=datetime.now(timezone.utc))
162
+ )
163
+ await db.commit()
164
+ await db.refresh(msg)
165
+ return msg
166
+
167
+
168
+ async def get_messages(user_id: str, conv_id: str) -> List[Message]:
169
+ await ensure_user_db(user_id)
170
+ async with _session_maker(user_id)() as db:
171
+ result = await db.execute(
172
+ select(Message)
173
+ .where(Message.conversation_id == conv_id)
174
+ .order_by(Message.created_at.asc())
175
+ )
176
+ return result.scalars().all()
177
+
178
+
179
+ async def auto_title_conversation(user_id: str, conv_id: str, first_question: str) -> None:
180
+ """Set a smart title from the first user message (truncated)."""
181
+ title = first_question[:60] + ("…" if len(first_question) > 60 else "")
182
+ await rename_conversation(user_id, conv_id, title)
app/db/database.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main application database (shared across all users).
3
+ - users table
4
+ - documents table (Qdrant vector registry)
5
+
6
+ Per-user chat history lives in separate SQLite files:
7
+ ./data/users/<user_id>/chats.db
8
+ """
9
+ import os
10
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
11
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
12
+ from sqlalchemy import String, Boolean, DateTime, Text, Integer
13
+ from datetime import datetime, timezone
14
+
15
+ from app.core.config import get_settings
16
+
17
+ settings = get_settings()
18
+
19
+ # Ensure data directory exists
20
+ os.makedirs("./data", exist_ok=True)
21
+
22
+ engine = create_async_engine(settings.database_url, echo=settings.debug)
23
+ AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
24
+
25
+
26
+ class Base(DeclarativeBase):
27
+ pass
28
+
29
+
30
+ class User(Base):
31
+ __tablename__ = "users"
32
+
33
+ id: Mapped[str] = mapped_column(String(36), primary_key=True)
34
+ email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
35
+ hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
36
+ full_name: Mapped[str] = mapped_column(String(255), nullable=False)
37
+ role: Mapped[str] = mapped_column(String(50), default="user")
38
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
39
+ created_at: Mapped[datetime] = mapped_column(
40
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
41
+ )
42
+
43
+
44
+ class DocumentRecord(Base):
45
+ __tablename__ = "documents"
46
+
47
+ id: Mapped[str] = mapped_column(String(36), primary_key=True)
48
+ filename: Mapped[str] = mapped_column(String(512), nullable=False)
49
+ title: Mapped[str] = mapped_column(String(512), nullable=False)
50
+ source: Mapped[str | None] = mapped_column(String(512))
51
+ chunk_count: Mapped[int] = mapped_column(Integer, default=0)
52
+ uploaded_by: Mapped[str] = mapped_column(String(36), nullable=False)
53
+ conversation_id: Mapped[str] = mapped_column(String(36), nullable=False, default="")
54
+ created_at: Mapped[datetime] = mapped_column(
55
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
56
+ )
57
+ status: Mapped[str] = mapped_column(String(50), default="processing")
58
+ error_message: Mapped[str | None] = mapped_column(Text)
59
+
60
+
61
+ async def init_db() -> None:
62
+ async with engine.begin() as conn:
63
+ await conn.run_sync(Base.metadata.create_all)
64
+ columns = await conn.exec_driver_sql("PRAGMA table_info(documents)")
65
+ column_names = {row[1] for row in columns.fetchall()}
66
+ if "conversation_id" not in column_names:
67
+ await conn.exec_driver_sql("ALTER TABLE documents ADD COLUMN conversation_id VARCHAR(36) NOT NULL DEFAULT ''")
68
+
69
+
70
+ async def get_db(): # type: ignore[return]
71
+ async with AsyncSessionLocal() as session:
72
+ yield session
app/main.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import asyncio
3
+ from contextlib import asynccontextmanager
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from loguru import logger
8
+
9
+ from app.core.config import get_settings
10
+ from app.db.database import init_db
11
+ from app.api.routes.auth import router as auth_router
12
+ from app.api.routes.conversations import router as conv_router
13
+ from app.api.routes.query import router as query_router
14
+ from app.api.routes.documents import router as docs_router
15
+ from app.api.routes.audio import router as audio_router
16
+ from app.api.routes.health_admin import health_router, admin_router
17
+
18
+ settings = get_settings()
19
+ os.makedirs("./data/users", exist_ok=True)
20
+
21
+
22
+ from app.services.speech_service import get_speech_service, get_tts_service
23
+
24
+ @asynccontextmanager
25
+ async def lifespan(app: FastAPI):
26
+ logger.info(f"Starting {settings.app_name} v{settings.app_version}")
27
+ await init_db()
28
+ logger.info("Main database initialised")
29
+
30
+ # Preload STT and TTS models during startup to avoid latency on first request
31
+ try:
32
+ logger.info("Preloading Speech-to-Text (STT) model...")
33
+ await asyncio.to_thread(get_speech_service)
34
+ logger.info("STT model preloaded successfully")
35
+
36
+ logger.info("Preloading Text-to-Speech (TTS) model...")
37
+ await asyncio.to_thread(get_tts_service().preload, settings.tts_voice)
38
+ logger.info("TTS model preloaded successfully")
39
+ except Exception as e:
40
+ logger.error(f"Failed to preload speech models during startup: {e}")
41
+
42
+ yield
43
+ logger.info("Shutting down")
44
+
45
+
46
+ app = FastAPI(
47
+ title=settings.app_name,
48
+ version=settings.app_version,
49
+ description="Medical Knowledge RAG API β€” Groq Β· Llama 4 Scout Β· Qdrant Β· BioBERT Β· Cross-Encoder",
50
+ lifespan=lifespan,
51
+ )
52
+
53
+ app.add_middleware(
54
+ CORSMiddleware,
55
+ allow_origins=settings.allowed_origins_list,
56
+ allow_credentials=True,
57
+ allow_methods=["*"],
58
+ allow_headers=["*"],
59
+ )
60
+
61
+ # ─── Routers ──────────────────────────────────────────────────────────────────
62
+ PREFIX = "/api/v1"
63
+ app.include_router(health_router, prefix=PREFIX)
64
+ app.include_router(auth_router, prefix=PREFIX)
65
+ app.include_router(conv_router, prefix=PREFIX)
66
+ app.include_router(query_router, prefix=PREFIX)
67
+ app.include_router(docs_router, prefix=PREFIX)
68
+ app.include_router(audio_router, prefix=PREFIX)
69
+ app.include_router(admin_router, prefix=PREFIX)
70
+
71
+
72
+ @app.get("/")
73
+ async def root():
74
+ return {"name": settings.app_name, "version": settings.app_version, "docs": "/docs"}
app/schemas/__init__.py ADDED
File without changes
app/schemas/schemas.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, EmailStr, Field
2
+ from typing import Optional, List, Any
3
+ from datetime import datetime
4
+
5
+
6
+ # ─── Auth ──────────────────────────────────────────────────────────────────────
7
+
8
+ class UserRegister(BaseModel):
9
+ email: EmailStr
10
+ password: str = Field(min_length=8)
11
+ full_name: str = Field(min_length=2)
12
+
13
+
14
+ class UserLogin(BaseModel):
15
+ email: EmailStr
16
+ password: str
17
+
18
+
19
+ class TokenResponse(BaseModel):
20
+ access_token: str
21
+ refresh_token: str
22
+ token_type: str = "bearer"
23
+
24
+
25
+ class RefreshRequest(BaseModel):
26
+ refresh_token: str
27
+
28
+
29
+ class UserOut(BaseModel):
30
+ id: str
31
+ email: str
32
+ full_name: str
33
+ role: str
34
+ is_active: bool
35
+ created_at: datetime
36
+ model_config = {"from_attributes": True}
37
+
38
+
39
+ # ─── Conversations ─────────────────────────────────────────────────────────────
40
+
41
+ class ConversationOut(BaseModel):
42
+ id: str
43
+ title: str
44
+ created_at: datetime
45
+ updated_at: datetime
46
+ model_config = {"from_attributes": True}
47
+
48
+
49
+ class ConversationCreate(BaseModel):
50
+ title: str = "New Chat"
51
+
52
+
53
+ class ConversationRename(BaseModel):
54
+ title: str = Field(min_length=1, max_length=200)
55
+
56
+
57
+ # ─── Messages ──────────────────────────────────────────────────────────────────
58
+
59
+ class SourceChunk(BaseModel):
60
+ doc_id: str
61
+ filename: str
62
+ title: str
63
+ chunk_index: int
64
+ page_number: Optional[int] = None
65
+ text: str
66
+ score: float
67
+ retrieval_method: Optional[str] = None
68
+
69
+
70
+ class MessageOut(BaseModel):
71
+ id: str
72
+ conversation_id: str
73
+ role: str
74
+ content: str
75
+ sources: Optional[List[SourceChunk]] = None
76
+ meta: Optional[dict[str, Any]] = None
77
+ created_at: datetime
78
+ model_config = {"from_attributes": True}
79
+
80
+
81
+ # ─── RAG / Query ───────────────────────────────────────────────────────────────
82
+
83
+ class QueryRequest(BaseModel):
84
+ query: str = Field(min_length=3, max_length=2000)
85
+ conversation_id: str # must exist in user's chat DB
86
+ top_k: Optional[int] = Field(default=5, ge=1, le=20)
87
+
88
+
89
+ class QueryResponse(BaseModel):
90
+ message_id: str
91
+ conversation_id: str
92
+ query: str
93
+ search_query: str
94
+ answer: str
95
+ sources: List[SourceChunk]
96
+ model: str
97
+ retrieval_strategy: str
98
+ conversation_turns_used: int
99
+ retrieval_ms: float
100
+ rerank_ms: float
101
+ generation_ms: float
102
+
103
+
104
+ class SpeechToTextResponse(BaseModel):
105
+ text: str
106
+ language: Optional[str] = None
107
+ model: str
108
+ filename: str
109
+
110
+
111
+ class TextToSpeechRequest(BaseModel):
112
+ text: str = Field(min_length=1, max_length=12000)
113
+ voice: Optional[str] = None
114
+ speed: float = Field(default=1.0, ge=0.5, le=2.0)
115
+
116
+
117
+ # ─── Documents ─────────────────────────────────────────────────────────────────
118
+
119
+ class DocumentOut(BaseModel):
120
+ id: str
121
+ filename: str
122
+ title: str
123
+ source: Optional[str]
124
+ chunk_count: int
125
+ status: str
126
+ created_at: datetime
127
+ uploaded_by: str
128
+ model_config = {"from_attributes": True}
129
+
130
+
131
+ class IngestResponse(BaseModel):
132
+ document_id: str
133
+ message: str
134
+
135
+
136
+ class BatchIngestResponse(BaseModel):
137
+ document_ids: List[str]
138
+ message: str
139
+
140
+
141
+ # ─── Health ────────────────────────────────────────────────────────────────────
142
+
143
+ class HealthResponse(BaseModel):
144
+ status: str
145
+ qdrant: str
146
+ llm: str
147
+ version: str
app/services/__init__.py ADDED
File without changes
app/services/embedding_service.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import asyncio
3
+ from functools import lru_cache
4
+ from typing import List
5
+
6
+ from sentence_transformers import SentenceTransformer
7
+ from loguru import logger
8
+ from app.core.config import get_settings
9
+
10
+ settings = get_settings()
11
+
12
+
13
+ class EmbeddingService:
14
+ _model: SentenceTransformer | None = None
15
+
16
+ def _load(self) -> SentenceTransformer:
17
+ if self._model is None:
18
+ logger.info(f"Loading embedding model: {settings.embedding_model}")
19
+ self._model = SentenceTransformer(settings.embedding_model)
20
+ return self._model
21
+
22
+ async def embed_texts(self, texts: List[str]) -> List[List[float]]:
23
+ loop = asyncio.get_running_loop()
24
+ model = self._load()
25
+ return await loop.run_in_executor(
26
+ None,
27
+ lambda: model.encode(texts, normalize_embeddings=True, show_progress_bar=False).tolist()
28
+ )
29
+
30
+ async def embed_query(self, query: str) -> List[float]:
31
+ vecs = await self.embed_texts([query])
32
+ return vecs[0]
33
+
34
+
35
+ @lru_cache(maxsize=1)
36
+ def get_embedding_service() -> EmbeddingService:
37
+ return EmbeddingService()
app/services/gemini_service.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import google.generativeai as genai
2
+ from app.core.config import get_settings
3
+ from loguru import logger
4
+
5
+ settings = get_settings()
6
+
7
+ class GeminiService:
8
+ def __init__(self):
9
+ if not settings.google_api_key:
10
+ logger.warning("GOOGLE_API_KEY not found in settings")
11
+
12
+ genai.configure(api_key=settings.google_api_key)
13
+ self.model = genai.GenerativeModel(settings.gemini_model)
14
+
15
+ async def generate_response(self, prompt: str, system_instruction: str = None) -> str:
16
+ """
17
+ Generates a response from Gemini based on the provided prompt.
18
+ """
19
+ try:
20
+ # Re-initialize model if system_instruction is provided
21
+ model = self.model
22
+ if system_instruction:
23
+ model = genai.GenerativeModel(
24
+ settings.gemini_model,
25
+ system_instruction=system_instruction
26
+ )
27
+
28
+ response = await model.generate_content_async(prompt)
29
+ return response.text
30
+ except Exception as e:
31
+ logger.error(f"Error calling Gemini API: {e}")
32
+ return f"Error: {e}"
33
+
34
+ # Global instance
35
+ gemini_service = None
36
+
37
+ def get_gemini_service():
38
+ global gemini_service
39
+ if gemini_service is None:
40
+ gemini_service = GeminiService()
41
+ return gemini_service
app/services/ingestion_service.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import asyncio
3
+ import io
4
+ import uuid
5
+ from typing import Any, List
6
+
7
+ from fastapi import UploadFile
8
+ from loguru import logger
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+ from sqlalchemy import select
11
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
12
+
13
+ from app.core.config import get_settings
14
+ from app.db.database import AsyncSessionLocal, DocumentRecord
15
+ from app.services.embedding_service import get_embedding_service
16
+ from app.services.qdrant_service import get_qdrant_service
17
+
18
+ settings = get_settings()
19
+ _ingestion_tasks: set[asyncio.Task] = set()
20
+
21
+ MIN_MEDICAL_TEXT_CHARS = 120
22
+ MIN_MEDICAL_KEYWORD_HITS = 3
23
+ MIN_MEDICAL_KEYWORD_DENSITY = 1.5
24
+ MEDICAL_KEYWORDS = {
25
+ "acute",
26
+ "anatomy",
27
+ "antibiotic",
28
+ "artery",
29
+ "blood",
30
+ "cancer",
31
+ "cardiac",
32
+ "cardiology",
33
+ "care",
34
+ "cell",
35
+ "chronic",
36
+ "clinical",
37
+ "clinic",
38
+ "condition",
39
+ "diagnosis",
40
+ "diagnostic",
41
+ "disease",
42
+ "disorder",
43
+ "dosage",
44
+ "dose",
45
+ "drug",
46
+ "emergency",
47
+ "epidemiology",
48
+ "examination",
49
+ "guideline",
50
+ "health",
51
+ "healthcare",
52
+ "heart",
53
+ "hospital",
54
+ "immune",
55
+ "infection",
56
+ "inflammation",
57
+ "injury",
58
+ "laboratory",
59
+ "medical",
60
+ "medicine",
61
+ "mortality",
62
+ "nurse",
63
+ "oncology",
64
+ "organ",
65
+ "pathology",
66
+ "patient",
67
+ "pharmacology",
68
+ "physician",
69
+ "physiology",
70
+ "prescription",
71
+ "prognosis",
72
+ "radiology",
73
+ "risk",
74
+ "screening",
75
+ "surgery",
76
+ "surgical",
77
+ "symptom",
78
+ "syndrome",
79
+ "therapy",
80
+ "tissue",
81
+ "treatment",
82
+ "trial",
83
+ "vaccine",
84
+ }
85
+
86
+
87
+ def _extract_pdf(data: bytes) -> List[dict[str, Any]]:
88
+ import fitz
89
+ with fitz.open(stream=data, filetype="pdf") as doc:
90
+ return [
91
+ {"text": page.get_text("text") or "", "page_number": page.number + 1}
92
+ for page in doc
93
+ ]
94
+
95
+
96
+ def _extract_docx(data: bytes) -> str:
97
+ import docx
98
+ doc = docx.Document(io.BytesIO(data))
99
+ return "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
100
+
101
+
102
+ def _extract_txt(data: bytes) -> str:
103
+ return data.decode("utf-8", errors="replace")
104
+
105
+
106
+ def extract_segments(filename: str, data: bytes) -> List[dict[str, Any]]:
107
+ ext = filename.rsplit(".", 1)[-1].lower()
108
+ if ext == "pdf":
109
+ return _extract_pdf(data)
110
+ elif ext in ("docx", "doc"):
111
+ return [{"text": _extract_docx(data), "page_number": None}]
112
+ elif ext == "txt":
113
+ return [{"text": _extract_txt(data), "page_number": None}]
114
+ raise ValueError(f"Unsupported file type: .{ext}")
115
+
116
+
117
+ def chunk_text(text: str) -> List[str]:
118
+ splitter = RecursiveCharacterTextSplitter(
119
+ chunk_size=settings.rag_chunk_size,
120
+ chunk_overlap=settings.rag_chunk_overlap,
121
+ separators=["\n\n", "\n", ". ", " ", ""],
122
+ )
123
+ return [c for c in splitter.split_text(text) if c.strip()]
124
+
125
+
126
+ def chunk_segments(segments: List[dict[str, Any]]) -> List[dict[str, Any]]:
127
+ chunks: List[dict[str, Any]] = []
128
+ for segment in segments:
129
+ text = segment.get("text", "")
130
+ if not text.strip():
131
+ continue
132
+ for chunk in chunk_text(text):
133
+ chunks.append(
134
+ {
135
+ "text": chunk,
136
+ "page_number": segment.get("page_number"),
137
+ }
138
+ )
139
+ return chunks
140
+
141
+
142
+ def validate_medical_document(filename: str, data: bytes) -> None:
143
+ segments = extract_segments(filename, data)
144
+ text = "\n".join(segment.get("text") or "" for segment in segments).strip()
145
+ if len(text) < MIN_MEDICAL_TEXT_CHARS:
146
+ raise ValueError("Not enough readable text found to verify this as a medical document.")
147
+
148
+ words = [
149
+ word.strip(".,;:!?()[]{}\"'").lower()
150
+ for word in text.split()
151
+ ]
152
+ words = [word for word in words if word]
153
+ if not words:
154
+ raise ValueError("No readable text found to verify this as a medical document.")
155
+
156
+ normalized_words = {
157
+ word[:-1] if word.endswith("s") else word
158
+ for word in words
159
+ }
160
+ keyword_hits = sum(
161
+ 1 for word in words
162
+ if word in MEDICAL_KEYWORDS or (word.endswith("s") and word[:-1] in MEDICAL_KEYWORDS)
163
+ )
164
+ unique_keyword_hits = len(normalized_words & MEDICAL_KEYWORDS)
165
+ keyword_density = (keyword_hits / len(words)) * 1000
166
+
167
+ if unique_keyword_hits < MIN_MEDICAL_KEYWORD_HITS or keyword_density < MIN_MEDICAL_KEYWORD_DENSITY:
168
+ raise ValueError(
169
+ "This does not look like a medical document. Upload clinical notes, reports, "
170
+ "guidelines, research, or other healthcare material."
171
+ )
172
+
173
+
174
+ async def ingest_document(
175
+ file: UploadFile,
176
+ title: str,
177
+ source: str | None,
178
+ user_id: str,
179
+ conversation_id: str,
180
+ db: AsyncSession,
181
+ ) -> str:
182
+ doc_id = str(uuid.uuid4())
183
+ filename = file.filename or "unknown"
184
+
185
+ record = DocumentRecord(
186
+ id=doc_id, filename=filename, title=title,
187
+ source=source, chunk_count=0, uploaded_by=user_id, conversation_id=conversation_id, status="processing",
188
+ )
189
+ db.add(record)
190
+ await db.commit()
191
+
192
+ try:
193
+ raw_bytes = await file.read()
194
+ logger.info(f"Ingesting '{filename}' ({len(raw_bytes):,} bytes)")
195
+ segments = extract_segments(filename, raw_bytes)
196
+ if not any((segment.get("text") or "").strip() for segment in segments):
197
+ raise ValueError("No extractable text found β€” file may be image-only.")
198
+
199
+ chunks = chunk_segments(segments)
200
+ logger.info(f"Split '{filename}' into {len(chunks)} chunks")
201
+
202
+ emb_svc = get_embedding_service()
203
+ embeddings = await emb_svc.embed_texts([chunk["text"] for chunk in chunks])
204
+
205
+ qdrant = get_qdrant_service()
206
+ await qdrant.ensure_collection()
207
+ n = await qdrant.upsert_chunks(
208
+ doc_id=doc_id, chunks=chunks, embeddings=embeddings,
209
+ metadata={
210
+ "filename": filename,
211
+ "title": title,
212
+ "source": source or "",
213
+ "conversation_id": record.conversation_id,
214
+ },
215
+ )
216
+
217
+ record.chunk_count = n
218
+ record.status = "ready"
219
+ await db.commit()
220
+ logger.success(f"Document {doc_id} ready ({n} chunks)")
221
+
222
+ except Exception as exc:
223
+ record.status = "error"
224
+ record.error_message = str(exc)
225
+ await db.commit()
226
+ logger.error(f"Ingestion failed for {doc_id}: {exc}")
227
+ raise
228
+
229
+ return doc_id
230
+
231
+
232
+ async def create_document_record(
233
+ *,
234
+ filename: str,
235
+ title: str,
236
+ source: str | None,
237
+ user_id: str,
238
+ conversation_id: str,
239
+ db: AsyncSession,
240
+ ) -> str:
241
+ doc_id = str(uuid.uuid4())
242
+ record = DocumentRecord(
243
+ id=doc_id,
244
+ filename=filename,
245
+ title=title,
246
+ source=source,
247
+ chunk_count=0,
248
+ uploaded_by=user_id,
249
+ conversation_id=conversation_id,
250
+ status="processing",
251
+ )
252
+ db.add(record)
253
+ await db.commit()
254
+ return doc_id
255
+
256
+
257
+ async def ingest_document_bytes(
258
+ *,
259
+ doc_id: str,
260
+ filename: str,
261
+ title: str,
262
+ source: str | None,
263
+ raw_bytes: bytes,
264
+ db: AsyncSession,
265
+ ) -> None:
266
+ record = await db.get(DocumentRecord, doc_id)
267
+ if not record:
268
+ raise ValueError(f"Document record not found: {doc_id}")
269
+
270
+ try:
271
+ logger.info(f"Ingesting '{filename}' ({len(raw_bytes):,} bytes)")
272
+ segments = extract_segments(filename, raw_bytes)
273
+ if not any((segment.get("text") or "").strip() for segment in segments):
274
+ raise ValueError("No extractable text found - file may be image-only.")
275
+
276
+ chunks = chunk_segments(segments)
277
+ logger.info(f"Split '{filename}' into {len(chunks)} chunks")
278
+
279
+ emb_svc = get_embedding_service()
280
+ embeddings = await emb_svc.embed_texts([chunk["text"] for chunk in chunks])
281
+
282
+ qdrant = get_qdrant_service()
283
+ await qdrant.ensure_collection()
284
+ n = await qdrant.upsert_chunks(
285
+ doc_id=doc_id, chunks=chunks, embeddings=embeddings,
286
+ metadata={
287
+ "filename": filename,
288
+ "title": title,
289
+ "source": source or "",
290
+ "conversation_id": record.conversation_id,
291
+ },
292
+ )
293
+
294
+ record.chunk_count = n
295
+ record.status = "ready"
296
+ record.error_message = None
297
+ await db.commit()
298
+ logger.success(f"Document {doc_id} ready ({n} chunks)")
299
+
300
+ except Exception as exc:
301
+ record.status = "error"
302
+ record.error_message = str(exc)
303
+ await db.commit()
304
+ logger.error(f"Ingestion failed for {doc_id}: {exc}")
305
+ raise
306
+
307
+
308
+ async def ingest_document_background(
309
+ *,
310
+ doc_id: str,
311
+ filename: str,
312
+ title: str,
313
+ source: str | None,
314
+ raw_bytes: bytes,
315
+ ) -> None:
316
+ async with AsyncSessionLocal() as db:
317
+ await ingest_document_bytes(
318
+ doc_id=doc_id,
319
+ filename=filename,
320
+ title=title,
321
+ source=source,
322
+ raw_bytes=raw_bytes,
323
+ db=db,
324
+ )
325
+
326
+
327
+ async def _run_ingestion_job(
328
+ *,
329
+ doc_id: str,
330
+ filename: str,
331
+ title: str,
332
+ source: str | None,
333
+ raw_bytes: bytes,
334
+ ) -> None:
335
+ try:
336
+ await ingest_document_background(
337
+ doc_id=doc_id,
338
+ filename=filename,
339
+ title=title,
340
+ source=source,
341
+ raw_bytes=raw_bytes,
342
+ )
343
+ except Exception:
344
+ # Errors are persisted onto the document record inside ingest_document_bytes.
345
+ logger.exception(f"Background ingestion crashed for doc {doc_id}")
346
+
347
+
348
+ def schedule_ingestion(
349
+ *,
350
+ doc_id: str,
351
+ filename: str,
352
+ title: str,
353
+ source: str | None,
354
+ raw_bytes: bytes,
355
+ ) -> None:
356
+ task = asyncio.create_task(
357
+ _run_ingestion_job(
358
+ doc_id=doc_id,
359
+ filename=filename,
360
+ title=title,
361
+ source=source,
362
+ raw_bytes=raw_bytes,
363
+ )
364
+ )
365
+ _ingestion_tasks.add(task)
366
+ task.add_done_callback(_ingestion_tasks.discard)
367
+
368
+
369
+ async def get_all_documents(db: AsyncSession) -> List[DocumentRecord]:
370
+ result = await db.execute(select(DocumentRecord).order_by(DocumentRecord.created_at.desc()))
371
+ return result.scalars().all()
372
+
373
+
374
+ async def get_conversation_documents(conversation_id: str, db: AsyncSession) -> List[DocumentRecord]:
375
+ result = await db.execute(
376
+ select(DocumentRecord)
377
+ .where(DocumentRecord.conversation_id == conversation_id)
378
+ .order_by(DocumentRecord.created_at.desc())
379
+ )
380
+ return result.scalars().all()
381
+
382
+
383
+ async def get_conversation_ready_document_count(conversation_id: str, db: AsyncSession) -> int:
384
+ result = await db.execute(
385
+ select(DocumentRecord)
386
+ .where(
387
+ DocumentRecord.conversation_id == conversation_id,
388
+ DocumentRecord.status == "ready",
389
+ )
390
+ )
391
+ return len(result.scalars().all())
392
+
393
+
394
+ async def get_document(doc_id: str, db: AsyncSession) -> DocumentRecord | None:
395
+ result = await db.execute(select(DocumentRecord).where(DocumentRecord.id == doc_id))
396
+ return result.scalar_one_or_none()
app/services/llm_service.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM service β€” Groq API with Llama 4 Scout 17B.
3
+ """
4
+ from __future__ import annotations
5
+ from functools import lru_cache
6
+ from typing import List
7
+
8
+ from groq import AsyncGroq
9
+ from loguru import logger
10
+
11
+ from app.core.config import get_settings
12
+
13
+ settings = get_settings()
14
+
15
+ SYSTEM_PROMPT = """You are MedRAG, an expert medical knowledge assistant.
16
+ You must answer accurately, conservatively, and only from the provided context.
17
+
18
+ Rules:
19
+ - Ground every factual claim in the provided sources only.
20
+ - If the context is incomplete or conflicting, say so plainly.
21
+ - Synthesize across multiple sources when possible instead of relying on one excerpt.
22
+ - Prefer clear, clinically useful wording over vague summaries.
23
+ - Keep the answer short by default: 2-4 sentences for ordinary questions.
24
+ - Use bullets only when the user asks for a list or when brevity would be worse.
25
+ - Do not mention internal retrieval labels like "Source 1" or "Source 2" in the answer.
26
+ - Cite source titles inline only when needed for an important claim or ambiguity.
27
+ - Never invent guidelines, dosages, diagnoses, or recommendations not supported by context.
28
+ - Do not add a generic safety disclaimer unless the answer includes urgent-risk advice, diagnosis, treatment guidance, or the context specifically calls for it.
29
+
30
+ Style:
31
+ - Start with the direct answer immediately.
32
+ - Avoid headings like "Direct answer", "Key details", or "Source-backed notes".
33
+ - Avoid bracketed citations and source-number references in the prose.
34
+ - Avoid padded phrasing and repetition.
35
+ - If the user asks a simple definition, respond in one short paragraph."""
36
+
37
+
38
+ class LLMService:
39
+ def __init__(self) -> None:
40
+ self._client = AsyncGroq(api_key=settings.groq_api_key)
41
+
42
+ def _build_context(self, chunks: List[dict]) -> str:
43
+ parts = []
44
+ for i, c in enumerate(chunks, 1):
45
+ page_label = f" | page {c.get('page_number')}" if c.get("page_number") else ""
46
+ parts.append(
47
+ f"[Document {i}: {c['title']} | {c['filename']}{page_label} | chunk {c.get('chunk_index', 0)}]\n"
48
+ f"{c['text']}"
49
+ )
50
+ return "\n\n---\n\n".join(parts)
51
+
52
+ async def rewrite_query_for_retrieval(self, query: str, conversation_history: List[dict]) -> str:
53
+ if not conversation_history:
54
+ return query
55
+
56
+ history_text = "\n".join(
57
+ f"{item['role']}: {item['content']}" for item in conversation_history if item.get("content")
58
+ )
59
+ response = await self._client.chat.completions.create(
60
+ model=settings.llm_model,
61
+ messages=[
62
+ {
63
+ "role": "system",
64
+ "content": (
65
+ "Rewrite the user's latest question into a standalone medical search query. "
66
+ "Preserve key medical entities, symptoms, conditions, tests, and follow-up references. "
67
+ "Return only the rewritten search query."
68
+ ),
69
+ },
70
+ {
71
+ "role": "user",
72
+ "content": f"Conversation:\n{history_text}\n\nLatest user question:\n{query}",
73
+ },
74
+ ],
75
+ max_tokens=96,
76
+ temperature=0.0,
77
+ )
78
+ rewritten = (response.choices[0].message.content or "").strip()
79
+ return rewritten or query
80
+
81
+ async def generate_answer(self, query: str, context_chunks: List[dict]) -> str:
82
+ context = self._build_context(context_chunks)
83
+ try:
84
+ response = await self._client.chat.completions.create(
85
+ model=settings.llm_model,
86
+ messages=[
87
+ {"role": "system", "content": SYSTEM_PROMPT},
88
+ {
89
+ "role": "user",
90
+ "content": (
91
+ f"Question:\n{query}\n\n"
92
+ f"Retrieved medical context:\n{context}\n\n"
93
+ "Answer the question using only the retrieved context. Keep it concise unless the user explicitly asks for more detail."
94
+ ),
95
+ },
96
+ ],
97
+ max_tokens=settings.llm_max_tokens,
98
+ temperature=settings.llm_temperature,
99
+ )
100
+ answer = (response.choices[0].message.content or "").strip()
101
+ if not answer:
102
+ raise RuntimeError("LLM returned an empty answer")
103
+ return answer
104
+ except Exception as e:
105
+ logger.error(f"Error generating RAG answer: {e}")
106
+ raise
107
+
108
+ async def generate_simple_response(self, prompt: str, system_prompt: str = SYSTEM_PROMPT) -> str:
109
+ """
110
+ Generates a simple response without RAG context.
111
+ """
112
+ try:
113
+ response = await self._client.chat.completions.create(
114
+ model=settings.llm_model,
115
+ messages=[
116
+ {"role": "system", "content": system_prompt},
117
+ {"role": "user", "content": prompt},
118
+ ],
119
+ max_tokens=settings.llm_max_tokens,
120
+ temperature=settings.llm_temperature,
121
+ )
122
+ return response.choices[0].message.content.strip()
123
+ except Exception as e:
124
+ logger.error(f"Error calling Groq API: {e}")
125
+ return f"Error: {e}"
126
+
127
+ async def health(self) -> str:
128
+ try:
129
+ r = await self._client.chat.completions.create(
130
+ model=settings.llm_model,
131
+ messages=[{"role": "user", "content": "ping"}],
132
+ max_tokens=5,
133
+ )
134
+ return "ok"
135
+ except Exception as exc:
136
+ return f"error: {exc}"
137
+
138
+
139
+ @lru_cache(maxsize=1)
140
+ def get_llm_service() -> LLMService:
141
+ return LLMService()
app/services/qdrant_service.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import math
3
+ import re
4
+ import uuid
5
+ from dataclasses import dataclass, field
6
+ from functools import lru_cache
7
+ from typing import List, Dict, Any
8
+
9
+ from qdrant_client import AsyncQdrantClient
10
+ from qdrant_client.models import (
11
+ Distance, VectorParams, PointStruct,
12
+ Filter, FieldCondition, MatchValue, ScoredPoint, FilterSelector,
13
+ )
14
+ from loguru import logger
15
+ from app.core.config import get_settings
16
+
17
+ settings = get_settings()
18
+ TOKEN_RE = re.compile(r"[a-zA-Z0-9]{3,}")
19
+
20
+
21
+ @dataclass
22
+ class RetrievedChunk:
23
+ id: str
24
+ payload: Dict[str, Any]
25
+ vector_score: float | None = None
26
+ lexical_score: float | None = None
27
+ hybrid_score: float | None = None
28
+ retrieval_method: str = "vector"
29
+ score: float = 0.0
30
+ retrieval_methods: set[str] = field(default_factory=set)
31
+
32
+
33
+ def _tokenize(text: str) -> list[str]:
34
+ return TOKEN_RE.findall((text or "").lower())
35
+
36
+
37
+ class QdrantService:
38
+ def __init__(self) -> None:
39
+ if settings.qdrant_path:
40
+ self.client = AsyncQdrantClient(path=settings.qdrant_path)
41
+ elif settings.qdrant_api_key:
42
+ self.client = AsyncQdrantClient(url=f"https://{settings.qdrant_host}", api_key=settings.qdrant_api_key)
43
+ else:
44
+ self.client = AsyncQdrantClient(host=settings.qdrant_host, port=settings.qdrant_port)
45
+
46
+ async def ensure_collection(self) -> None:
47
+ exists = await self.client.collection_exists(settings.qdrant_collection_name)
48
+ if not exists:
49
+ await self.client.create_collection(
50
+ collection_name=settings.qdrant_collection_name,
51
+ vectors_config=VectorParams(size=settings.qdrant_vector_size, distance=Distance.COSINE),
52
+ )
53
+ logger.info(f"Created collection: {settings.qdrant_collection_name}")
54
+
55
+ async def upsert_chunks(self, doc_id: str, chunks: List[Dict[str, Any]], embeddings: List[List[float]], metadata: Dict[str, Any]) -> int:
56
+ points = [
57
+ PointStruct(
58
+ id=str(uuid.uuid4()),
59
+ vector=emb,
60
+ payload={
61
+ "doc_id": doc_id,
62
+ "chunk_index": i,
63
+ "text": chunk.get("text", ""),
64
+ "page_number": chunk.get("page_number"),
65
+ "filename": metadata.get("filename", ""),
66
+ "title": metadata.get("title", ""),
67
+ "source": metadata.get("source", ""),
68
+ "conversation_id": metadata.get("conversation_id", ""),
69
+ "namespace": settings.qdrant_namespace,
70
+ },
71
+ )
72
+ for i, (chunk, emb) in enumerate(zip(chunks, embeddings))
73
+ ]
74
+ await self.client.upsert(collection_name=settings.qdrant_collection_name, points=points)
75
+ logger.info(f"Upserted {len(points)} chunks for doc {doc_id}")
76
+ return len(points)
77
+
78
+ def _scope_filter(self, conversation_id: str | None = None) -> Filter:
79
+ must = [FieldCondition(key="namespace", match=MatchValue(value=settings.qdrant_namespace))]
80
+ if conversation_id:
81
+ must.append(FieldCondition(key="conversation_id", match=MatchValue(value=conversation_id)))
82
+ return Filter(must=must)
83
+
84
+ async def search(self, query_vector: List[float], top_k: int = 20, conversation_id: str | None = None) -> List[ScoredPoint]:
85
+ return await self.client.search(
86
+ collection_name=settings.qdrant_collection_name,
87
+ query_vector=query_vector,
88
+ limit=top_k,
89
+ with_payload=True,
90
+ query_filter=self._scope_filter(conversation_id),
91
+ )
92
+
93
+ async def vector_search(self, query_vector: List[float], top_k: int = 20, conversation_id: str | None = None) -> List[RetrievedChunk]:
94
+ points = await self.search(query_vector=query_vector, top_k=top_k, conversation_id=conversation_id)
95
+ return [
96
+ RetrievedChunk(
97
+ id=str(point.id),
98
+ payload=dict(point.payload or {}),
99
+ vector_score=float(point.score),
100
+ retrieval_method="vector",
101
+ score=float(point.score),
102
+ retrieval_methods={"vector"},
103
+ )
104
+ for point in points
105
+ ]
106
+
107
+ async def lexical_search(self, query: str, top_k: int = 20, conversation_id: str | None = None) -> List[RetrievedChunk]:
108
+ query_tokens = _tokenize(query)
109
+ if not query_tokens:
110
+ return []
111
+
112
+ query_counts: dict[str, int] = {}
113
+ for token in query_tokens:
114
+ query_counts[token] = query_counts.get(token, 0) + 1
115
+
116
+ scored: list[RetrievedChunk] = []
117
+ offset = None
118
+ while True:
119
+ points, offset = await self.client.scroll(
120
+ collection_name=settings.qdrant_collection_name,
121
+ scroll_filter=self._scope_filter(conversation_id),
122
+ with_payload=True,
123
+ with_vectors=False,
124
+ limit=256,
125
+ offset=offset,
126
+ )
127
+ for point in points:
128
+ payload = dict(point.payload or {})
129
+ text_tokens = _tokenize(payload.get("text", ""))
130
+ if not text_tokens:
131
+ continue
132
+ text_counts: dict[str, int] = {}
133
+ for token in text_tokens:
134
+ text_counts[token] = text_counts.get(token, 0) + 1
135
+
136
+ overlap = 0.0
137
+ for token, q_count in query_counts.items():
138
+ overlap += min(q_count, text_counts.get(token, 0))
139
+ if overlap <= 0:
140
+ continue
141
+
142
+ norm = math.sqrt(len(query_tokens) * len(text_tokens))
143
+ lexical_score = overlap / norm if norm else 0.0
144
+ scored.append(
145
+ RetrievedChunk(
146
+ id=str(point.id),
147
+ payload=payload,
148
+ lexical_score=lexical_score,
149
+ retrieval_method="lexical",
150
+ score=lexical_score,
151
+ retrieval_methods={"lexical"},
152
+ )
153
+ )
154
+ if offset is None:
155
+ break
156
+
157
+ scored.sort(key=lambda item: item.lexical_score or 0.0, reverse=True)
158
+ return scored[:top_k]
159
+
160
+ async def delete_by_doc_id(self, doc_id: str) -> None:
161
+ await self.client.delete(
162
+ collection_name=settings.qdrant_collection_name,
163
+ points_selector=FilterSelector(
164
+ filter=Filter(
165
+ must=[
166
+ FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
167
+ FieldCondition(key="namespace", match=MatchValue(value=settings.qdrant_namespace)),
168
+ ]
169
+ )
170
+ ),
171
+ )
172
+ logger.info(f"Deleted vectors for doc {doc_id}")
173
+
174
+ async def health(self) -> str:
175
+ try:
176
+ await self.client.get_collections()
177
+ return "ok"
178
+ except Exception as exc:
179
+ return f"error: {exc}"
180
+
181
+
182
+ @lru_cache(maxsize=1)
183
+ def get_qdrant_service() -> QdrantService:
184
+ return QdrantService()
app/services/rag_pipeline.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG pipeline: conversational rewrite -> hybrid retrieve -> rerank -> generate -> persist.
3
+ """
4
+ from __future__ import annotations
5
+ from collections import defaultdict
6
+ import json
7
+ import time
8
+
9
+ from app.core.config import get_settings
10
+ from app.schemas.schemas import QueryRequest, QueryResponse, SourceChunk
11
+ from app.services.embedding_service import get_embedding_service
12
+ from app.services.qdrant_service import RetrievedChunk, get_qdrant_service
13
+ from app.services.reranker_service import get_reranker_service
14
+ from app.services.llm_service import get_llm_service
15
+ from app.db import chat_db
16
+
17
+ settings = get_settings()
18
+
19
+
20
+ def _diversify_ranked_results(ranked: list[tuple], top_k: int) -> list[tuple]:
21
+ """Keep the final context from being dominated by one document."""
22
+ per_doc_counts = defaultdict(int)
23
+ diversified = []
24
+
25
+ for point, score in ranked:
26
+ doc_id = str(point.payload.get("doc_id", ""))
27
+ if per_doc_counts[doc_id] >= settings.reranker_max_chunks_per_doc:
28
+ continue
29
+ diversified.append((point, score))
30
+ per_doc_counts[doc_id] += 1
31
+ if len(diversified) >= top_k:
32
+ break
33
+
34
+ return diversified
35
+
36
+
37
+ def _filter_ranked_results_by_score(ranked: list[tuple], min_score: float) -> list[tuple]:
38
+ return [(point, score) for point, score in ranked if float(score) >= min_score]
39
+
40
+
41
+ def _filter_ranked_results_by_top_window(ranked: list[tuple], window: float) -> list[tuple]:
42
+ if not ranked:
43
+ return []
44
+ top_score = float(ranked[0][1])
45
+ min_allowed = top_score - window
46
+ return [(point, score) for point, score in ranked if float(score) >= min_allowed]
47
+
48
+
49
+ def _build_conversation_history(messages: list, max_turns: int) -> list[dict]:
50
+ recent = messages[-max_turns:] if max_turns > 0 else []
51
+ return [{"role": msg.role, "content": msg.content} for msg in recent]
52
+
53
+
54
+ def _hybrid_merge(vector_hits: list[RetrievedChunk], lexical_hits: list[RetrievedChunk]) -> list[RetrievedChunk]:
55
+ merged: dict[str, RetrievedChunk] = {}
56
+ rrf_k = settings.rag_hybrid_rrf_k
57
+
58
+ for rank, item in enumerate(vector_hits, start=1):
59
+ existing = merged.get(item.id)
60
+ contribution = 1.0 / (rrf_k + rank)
61
+ if existing is None:
62
+ item.hybrid_score = contribution
63
+ item.retrieval_methods = {"vector"}
64
+ merged[item.id] = item
65
+ else:
66
+ existing.vector_score = item.vector_score
67
+ existing.hybrid_score = (existing.hybrid_score or 0.0) + contribution
68
+ existing.retrieval_methods.add("vector")
69
+
70
+ for rank, item in enumerate(lexical_hits, start=1):
71
+ existing = merged.get(item.id)
72
+ contribution = 1.0 / (rrf_k + rank)
73
+ if existing is None:
74
+ item.hybrid_score = contribution
75
+ item.retrieval_methods = {"lexical"}
76
+ merged[item.id] = item
77
+ else:
78
+ existing.lexical_score = item.lexical_score
79
+ existing.hybrid_score = (existing.hybrid_score or 0.0) + contribution
80
+ existing.retrieval_methods.add("lexical")
81
+
82
+ merged_hits = list(merged.values())
83
+ for item in merged_hits:
84
+ item.score = float(item.hybrid_score or 0.0)
85
+ if item.retrieval_methods == {"vector", "lexical"}:
86
+ item.retrieval_method = "hybrid"
87
+ elif "lexical" in item.retrieval_methods:
88
+ item.retrieval_method = "lexical"
89
+ else:
90
+ item.retrieval_method = "vector"
91
+
92
+ merged_hits.sort(key=lambda item: item.hybrid_score or 0.0, reverse=True)
93
+ return merged_hits
94
+
95
+
96
+ async def run_rag_pipeline(request: QueryRequest, user_id: str) -> QueryResponse:
97
+ emb_svc = get_embedding_service()
98
+ qdrant = get_qdrant_service()
99
+ reranker = get_reranker_service()
100
+ llm = get_llm_service()
101
+
102
+ history_messages = await chat_db.get_messages(user_id, request.conversation_id)
103
+ conversation_history = _build_conversation_history(history_messages, settings.rag_conversation_history_turns)
104
+ search_query = await llm.rewrite_query_for_retrieval(request.query, conversation_history)
105
+
106
+ t0 = time.perf_counter()
107
+ query_vector = await emb_svc.embed_query(search_query)
108
+ vector_hits = await qdrant.vector_search(
109
+ query_vector=query_vector,
110
+ top_k=settings.rag_retrieval_top_k,
111
+ conversation_id=request.conversation_id,
112
+ )
113
+ lexical_hits = await qdrant.lexical_search(
114
+ query=search_query,
115
+ top_k=settings.rag_retrieval_top_k,
116
+ conversation_id=request.conversation_id,
117
+ )
118
+ candidates = _hybrid_merge(vector_hits, lexical_hits)
119
+ t_retrieval = (time.perf_counter() - t0) * 1000
120
+
121
+ t1 = time.perf_counter()
122
+ requested_top_k = request.top_k or settings.reranker_top_k
123
+ ranked = await reranker.rerank(
124
+ query=search_query,
125
+ candidates=candidates,
126
+ text_fn=lambda c: c.payload.get("text", ""),
127
+ top_k=max(requested_top_k * 3, settings.reranker_top_k),
128
+ )
129
+ ranked = _filter_ranked_results_by_score(ranked, settings.reranker_min_score)
130
+ ranked = _filter_ranked_results_by_top_window(ranked, settings.reranker_score_window)
131
+ ranked = _diversify_ranked_results(ranked, requested_top_k)
132
+ t_rerank = (time.perf_counter() - t1) * 1000
133
+
134
+ if ranked:
135
+ context_chunks = [
136
+ {
137
+ "text": point.payload.get("text", ""),
138
+ "title": point.payload.get("title", ""),
139
+ "filename": point.payload.get("filename", ""),
140
+ "doc_id": point.payload.get("doc_id", ""),
141
+ "chunk_index": point.payload.get("chunk_index", 0),
142
+ "page_number": point.payload.get("page_number"),
143
+ "score": float(score),
144
+ "retrieval_method": getattr(point, "retrieval_method", "hybrid"),
145
+ }
146
+ for point, score in ranked
147
+ ]
148
+ no_results = False
149
+ else:
150
+ context_chunks = []
151
+ no_results = True
152
+
153
+ t2 = time.perf_counter()
154
+ if no_results:
155
+ answer = (
156
+ "I couldn't find relevant information in the knowledge base "
157
+ "to answer your question. Please ensure relevant medical documents "
158
+ "have been uploaded, or rephrase your question."
159
+ )
160
+ else:
161
+ answer = await llm.generate_answer(request.query, context_chunks)
162
+ t_gen = (time.perf_counter() - t2) * 1000
163
+
164
+ sources = [
165
+ SourceChunk(
166
+ doc_id=c["doc_id"],
167
+ filename=c["filename"],
168
+ title=c["title"],
169
+ chunk_index=c["chunk_index"],
170
+ page_number=c.get("page_number"),
171
+ text=c["text"],
172
+ score=round(c["score"], 4),
173
+ retrieval_method=c.get("retrieval_method"),
174
+ )
175
+ for c in context_chunks
176
+ ]
177
+
178
+ await chat_db.add_message(
179
+ user_id=user_id,
180
+ conv_id=request.conversation_id,
181
+ role="user",
182
+ content=request.query,
183
+ )
184
+
185
+ sources_json = json.dumps([s.model_dump() for s in sources])
186
+ meta_json = json.dumps({
187
+ "model": settings.llm_model,
188
+ "search_query": search_query,
189
+ "retrieval_strategy": "conversational-hybrid-rag+rerank",
190
+ "conversation_turns_used": len(conversation_history),
191
+ "retrieval_ms": round(t_retrieval, 2),
192
+ "rerank_ms": round(t_rerank, 2),
193
+ "generation_ms": round(t_gen, 2),
194
+ "source_count": len(sources),
195
+ "vector_candidates": len(vector_hits),
196
+ "lexical_candidates": len(lexical_hits),
197
+ "reranker_min_score": settings.reranker_min_score,
198
+ "reranker_score_window": settings.reranker_score_window,
199
+ })
200
+ asst_msg = await chat_db.add_message(
201
+ user_id=user_id,
202
+ conv_id=request.conversation_id,
203
+ role="assistant",
204
+ content=answer,
205
+ sources_json=sources_json,
206
+ meta_json=meta_json,
207
+ )
208
+
209
+ messages = await chat_db.get_messages(user_id, request.conversation_id)
210
+ if len(messages) <= 2:
211
+ await chat_db.auto_title_conversation(user_id, request.conversation_id, request.query)
212
+
213
+ return QueryResponse(
214
+ message_id=asst_msg.id,
215
+ conversation_id=request.conversation_id,
216
+ query=request.query,
217
+ search_query=search_query,
218
+ answer=answer,
219
+ sources=sources,
220
+ model=settings.llm_model,
221
+ retrieval_strategy="conversational-hybrid-rag+rerank",
222
+ conversation_turns_used=len(conversation_history),
223
+ retrieval_ms=round(t_retrieval, 2),
224
+ rerank_ms=round(t_rerank, 2),
225
+ generation_ms=round(t_gen, 2),
226
+ )
app/services/reranker_service.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import asyncio
3
+ from functools import lru_cache
4
+ from typing import List, Tuple, TypeVar, Callable
5
+
6
+ from sentence_transformers.cross_encoder import CrossEncoder
7
+ from loguru import logger
8
+ from app.core.config import get_settings
9
+
10
+ settings = get_settings()
11
+ T = TypeVar("T")
12
+
13
+
14
+ class RerankerService:
15
+ _model: CrossEncoder | None = None
16
+
17
+ def _load(self) -> CrossEncoder:
18
+ if self._model is None:
19
+ logger.info(f"Loading reranker: {settings.reranker_model}")
20
+ self._model = CrossEncoder(settings.reranker_model, max_length=512)
21
+ return self._model
22
+
23
+ async def rerank(
24
+ self,
25
+ query: str,
26
+ candidates: List[T],
27
+ text_fn: Callable[[T], str],
28
+ top_k: int | None = None,
29
+ ) -> List[Tuple[T, float]]:
30
+ if not candidates:
31
+ return []
32
+ k = top_k or settings.reranker_top_k
33
+ model = self._load()
34
+ pairs = [(query, text_fn(c)) for c in candidates]
35
+ loop = asyncio.get_running_loop()
36
+ scores: List[float] = await loop.run_in_executor(None, lambda: model.predict(pairs).tolist())
37
+ ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
38
+ return ranked[:k]
39
+
40
+
41
+ @lru_cache(maxsize=1)
42
+ def get_reranker_service() -> RerankerService:
43
+ return RerankerService()
app/services/speech_service.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from qwen_asr import Qwen3ASRModel
3
+ from loguru import logger
4
+ import os
5
+ from functools import lru_cache
6
+ from app.core.config import get_settings
7
+
8
+ settings = get_settings()
9
+
10
+ class SpeechService:
11
+ def __init__(self, model_name: str = "Qwen/Qwen3-ASR-1.7B", device: str = None):
12
+ """
13
+ Initializes the Qwen-ASR model for speech-to-text.
14
+ """
15
+ if device is None:
16
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
17
+ else:
18
+ self.device = device
19
+
20
+ logger.info(f"Initializing SpeechService with model {model_name} on {self.device}")
21
+
22
+ try:
23
+ # Load the model with bfloat16 for better performance if on CUDA
24
+ dtype = torch.bfloat16 if self.device == "cuda" else torch.float32
25
+
26
+ self.model = Qwen3ASRModel.from_pretrained(
27
+ model_name,
28
+ dtype=dtype,
29
+ device_map=self.device if self.device == "cuda" else None
30
+ )
31
+ logger.success("SpeechService initialized successfully")
32
+ except Exception as e:
33
+ logger.error(f"Failed to initialize SpeechService: {e}")
34
+ raise
35
+
36
+ def transcribe(self, audio_data, language: str = None) -> str:
37
+ """
38
+ Transcribes audio (file path or numpy array) to text.
39
+
40
+ Args:
41
+ audio_data: Path to the audio file OR numpy array of audio data.
42
+ language: Optional language hint.
43
+
44
+ Returns:
45
+ The transcribed text.
46
+ """
47
+ import numpy as np
48
+ import soundfile as sf
49
+ import tempfile
50
+
51
+ temp_file = None
52
+ try:
53
+ if isinstance(audio_data, str):
54
+ if not os.path.exists(audio_data):
55
+ logger.error(f"Audio file not found: {audio_data}")
56
+ raise FileNotFoundError(f"Audio file not found: {audio_data}")
57
+ logger.info(f"Transcribing audio file: {audio_data}")
58
+ input_audio = [audio_data]
59
+ elif isinstance(audio_data, np.ndarray):
60
+ logger.info("Transcribing audio buffer (NumPy array)")
61
+ # Save to a temporary file as Qwen-ASR expects file paths
62
+ temp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
63
+ sf.write(temp_file.name, audio_data, 16000)
64
+ temp_file.close() # Close so the model can read it
65
+ input_audio = [temp_file.name]
66
+ else:
67
+ # If it's already a list or something else, pass it through
68
+ input_audio = audio_data
69
+
70
+ results = self.model.transcribe(
71
+ audio=input_audio,
72
+ language=[language] if language else None
73
+ )
74
+
75
+ if results and len(results) > 0:
76
+ transcription = results[0].text
77
+ logger.info(f"Transcription successful: {transcription[:50]}...")
78
+ return transcription
79
+ else:
80
+ logger.warning("No transcription results returned")
81
+ return ""
82
+ except Exception as e:
83
+ logger.error(f"Error during transcription: {e}")
84
+ raise
85
+ finally:
86
+ # Clean up temporary file
87
+ if temp_file and os.path.exists(temp_file.name):
88
+ try:
89
+ os.unlink(temp_file.name)
90
+ except Exception as cleanup_err:
91
+ logger.warning(f"Failed to delete temp file {temp_file.name}: {cleanup_err}")
92
+
93
+ def record_and_transcribe(self, duration: int = 5, sample_rate: int = 16000) -> str:
94
+ """
95
+ Records audio from the microphone for a fixed duration and transcribes it.
96
+
97
+ Args:
98
+ duration: Recording duration in seconds.
99
+ sample_rate: Sample rate for recording.
100
+
101
+ Returns:
102
+ The transcribed text.
103
+ """
104
+ import sounddevice as sd
105
+ import numpy as np
106
+
107
+ logger.info(f"Recording for {duration} seconds...")
108
+ # sd.rec is non-blocking, so we need to wait
109
+ recording = sd.rec(int(duration * sample_rate), samplerate=sample_rate, channels=1)
110
+ sd.wait() # Wait until recording is finished
111
+
112
+ audio_data = recording.flatten()
113
+ return self.transcribe(audio_data)
114
+
115
+ def transcribe_stream(self, callback_fn, chunk_duration: int = 5, sample_rate: int = 16000):
116
+ """
117
+ Captures audio from the microphone and transcribes it in real-time.
118
+
119
+ Args:
120
+ callback_fn: Function to call with each transcription result.
121
+ chunk_duration: Duration of each audio chunk in seconds.
122
+ sample_rate: Sample rate for recording (default 16000Hz as required by Qwen-ASR).
123
+ """
124
+ import sounddevice as sd
125
+ import numpy as np
126
+
127
+ logger.info(f"Starting microphone stream ({chunk_duration}s chunks)...")
128
+
129
+ def sd_callback(indata, frames, time, status):
130
+ if status:
131
+ logger.warning(f"Sounddevice status: {status}")
132
+
133
+ audio_chunk = indata.copy().flatten()
134
+ try:
135
+ # Transcribe the chunk
136
+ text = self.transcribe(audio_chunk)
137
+ if text.strip():
138
+ callback_fn(text)
139
+ except Exception as e:
140
+ logger.error(f"Error in stream transcription: {e}")
141
+
142
+ block_size = int(sample_rate * chunk_duration)
143
+
144
+ try:
145
+ with sd.InputStream(samplerate=sample_rate,
146
+ channels=1,
147
+ callback=sd_callback,
148
+ blocksize=block_size):
149
+ logger.info("Microphone is live. Press Ctrl+C to stop.")
150
+ while True:
151
+ sd.sleep(1000)
152
+ except KeyboardInterrupt:
153
+ logger.info("Microphone stream stopped by user.")
154
+ except Exception as e:
155
+ logger.error(f"Error in microphone stream: {e}")
156
+ raise
157
+
158
+
159
+ class KokoroTTSService:
160
+ SAMPLE_RATE = 24000
161
+
162
+ def __init__(self, device: str = None):
163
+ if device is None:
164
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
165
+ else:
166
+ self.device = device
167
+
168
+ self._pipelines = {}
169
+ logger.info(f"Initializing KokoroTTSService on {self.device}")
170
+
171
+ def preload(self, voice: str = "af_heart") -> None:
172
+ """Preloads the TTS model pipeline."""
173
+ logger.info(f"Preloading Kokoro TTS pipeline for voice: {voice}")
174
+ self._get_pipeline(voice)
175
+
176
+ def _get_pipeline(self, voice: str):
177
+ lang_code = (voice or "af_heart")[0].lower()
178
+ pipeline = self._pipelines.get(lang_code)
179
+ if pipeline is not None:
180
+ return pipeline
181
+
182
+ try:
183
+ from kokoro import KPipeline
184
+ except ImportError as exc:
185
+ logger.error(f"Kokoro TTS dependency is unavailable: {exc}")
186
+ raise RuntimeError(
187
+ "Kokoro TTS is not installed. Run pip install -r requirements.txt."
188
+ ) from exc
189
+
190
+ pipeline = KPipeline(lang_code=lang_code, device=self.device)
191
+ self._pipelines[lang_code] = pipeline
192
+ return pipeline
193
+
194
+ def speak(self, text: str, voice: str = "af_heart", speed: float = 1.0, play: bool = False):
195
+ import numpy as np
196
+
197
+ if not text or not text.strip():
198
+ raise ValueError("Text-to-speech requires non-empty text")
199
+
200
+ pipeline = self._get_pipeline(voice)
201
+ chunks = []
202
+ for result in pipeline(text.strip(), voice=voice, speed=speed, split_pattern=r"\n+"):
203
+ audio = result.audio
204
+ if audio is None:
205
+ continue
206
+ if hasattr(audio, "detach"):
207
+ audio = audio.detach()
208
+ if hasattr(audio, "cpu"):
209
+ audio = audio.cpu()
210
+ if hasattr(audio, "numpy"):
211
+ audio = audio.numpy()
212
+ chunks.append(np.asarray(audio, dtype=np.float32).reshape(-1))
213
+
214
+ if not chunks:
215
+ raise RuntimeError("Text-to-speech produced no audio")
216
+
217
+ waveform = np.concatenate(chunks)
218
+
219
+ if play:
220
+ try:
221
+ import sounddevice as sd
222
+
223
+ sd.play(waveform, self.SAMPLE_RATE)
224
+ sd.wait()
225
+ except Exception as exc:
226
+ logger.warning(f"Unable to play generated audio: {exc}")
227
+
228
+ return waveform, self.SAMPLE_RATE
229
+
230
+ # Global instance for easy access
231
+ speech_service = None
232
+
233
+ def get_speech_service():
234
+ global speech_service
235
+ if speech_service is None:
236
+ speech_service = SpeechService(
237
+ model_name=settings.speech_model,
238
+ device=settings.speech_device
239
+ )
240
+ return speech_service
241
+
242
+
243
+ @lru_cache()
244
+ def get_tts_service():
245
+ return KokoroTTSService()
app/services/stt_service.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import tempfile
6
+ from functools import lru_cache
7
+ from typing import Any
8
+
9
+ from fastapi import HTTPException, UploadFile, status
10
+
11
+ from app.core.config import Settings, get_settings
12
+
13
+
14
+ class QwenSTTService:
15
+ def __init__(self, settings: Settings):
16
+ self.settings = settings
17
+ self._model: Any | None = None
18
+
19
+ def _resolve_device(self) -> str:
20
+ if self.settings.stt_device != "auto":
21
+ return self.settings.stt_device
22
+
23
+ try:
24
+ import torch
25
+
26
+ return "cuda:0" if torch.cuda.is_available() else "cpu"
27
+ except Exception:
28
+ return "cpu"
29
+
30
+ def _resolve_dtype(self):
31
+ if self.settings.stt_dtype != "auto":
32
+ return self.settings.stt_dtype
33
+
34
+ try:
35
+ import torch
36
+
37
+ if self._resolve_device().startswith("cuda"):
38
+ return torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
39
+ return torch.float32
40
+ except Exception:
41
+ return "float32"
42
+
43
+ def _load_model(self):
44
+ if self._model is not None:
45
+ return self._model
46
+
47
+ try:
48
+ from qwen_asr import Qwen3ASRModel
49
+ except ImportError as exc:
50
+ raise HTTPException(
51
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
52
+ detail="Qwen ASR dependencies are not installed. Run pip install -r requirements.txt.",
53
+ ) from exc
54
+
55
+ self._model = Qwen3ASRModel.from_pretrained(
56
+ self.settings.stt_model,
57
+ device_map=self._resolve_device(),
58
+ dtype=self._resolve_dtype(),
59
+ max_new_tokens=self.settings.stt_max_new_tokens,
60
+ )
61
+ hf_model = getattr(self._model, "model", None)
62
+ processor = getattr(self._model, "processor", None)
63
+ tokenizer = getattr(processor, "tokenizer", None)
64
+
65
+ if hf_model is not None and hasattr(hf_model, "generation_config"):
66
+ generation_config = hf_model.generation_config
67
+ generation_config.max_new_tokens = self.settings.stt_max_new_tokens
68
+ generation_config.do_sample = False
69
+ if hasattr(generation_config, "temperature"):
70
+ generation_config.temperature = None
71
+ if tokenizer is not None:
72
+ eos_token_id = getattr(tokenizer, "eos_token_id", None)
73
+ pad_token_id = getattr(tokenizer, "pad_token_id", None) or eos_token_id
74
+ if pad_token_id is not None:
75
+ generation_config.pad_token_id = pad_token_id
76
+ if getattr(tokenizer, "pad_token_id", None) is None:
77
+ tokenizer.pad_token_id = pad_token_id
78
+ if eos_token_id is not None:
79
+ generation_config.eos_token_id = eos_token_id
80
+
81
+ return self._model
82
+
83
+ def _transcribe_sync(self, audio_path: str) -> dict[str, Any]:
84
+ model = self._load_model()
85
+ result = model.transcribe(
86
+ audio=audio_path,
87
+ context=self.settings.stt_context,
88
+ language=self.settings.stt_language,
89
+ )
90
+
91
+ if isinstance(result, (list, tuple)) and result:
92
+ first = result[0]
93
+ return {
94
+ "text": str(getattr(first, "text", "")).strip(),
95
+ "language": getattr(first, "language", None) or self.settings.stt_language,
96
+ "duration_seconds": getattr(first, "duration_seconds", None),
97
+ }
98
+
99
+ if isinstance(result, str):
100
+ return {"text": result.strip(), "language": self.settings.stt_language}
101
+
102
+ if isinstance(result, dict):
103
+ text = str(result.get("text", "")).strip()
104
+ return {
105
+ "text": text,
106
+ "language": result.get("language") or self.settings.stt_language,
107
+ "duration_seconds": result.get("duration_seconds"),
108
+ }
109
+
110
+ if hasattr(result, "text"):
111
+ return {
112
+ "text": str(getattr(result, "text", "")).strip(),
113
+ "language": getattr(result, "language", None) or self.settings.stt_language,
114
+ "duration_seconds": getattr(result, "duration_seconds", None),
115
+ }
116
+
117
+ text = str(result).strip()
118
+ return {"text": text, "language": self.settings.stt_language}
119
+
120
+ async def transcribe_upload(self, audio_file: UploadFile) -> dict[str, Any]:
121
+ if not self.settings.stt_enabled:
122
+ raise HTTPException(status_code=503, detail="Speech-to-text is disabled")
123
+
124
+ suffix = os.path.splitext(audio_file.filename or "audio.webm")[1] or ".webm"
125
+ raw_audio = await audio_file.read()
126
+ if not raw_audio:
127
+ raise HTTPException(status_code=400, detail="Uploaded audio file is empty")
128
+
129
+ temp_path = None
130
+ try:
131
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
132
+ temp_file.write(raw_audio)
133
+ temp_path = temp_file.name
134
+
135
+ transcript = await asyncio.to_thread(self._transcribe_sync, temp_path)
136
+ except HTTPException:
137
+ raise
138
+ except Exception as exc:
139
+ raise HTTPException(
140
+ status_code=500,
141
+ detail=f"Speech transcription failed: {exc}",
142
+ ) from exc
143
+ finally:
144
+ if temp_path and os.path.exists(temp_path):
145
+ try:
146
+ os.remove(temp_path)
147
+ except OSError:
148
+ pass
149
+
150
+ if not transcript.get("text"):
151
+ raise HTTPException(status_code=422, detail="No speech was detected in the audio")
152
+
153
+ return transcript
154
+
155
+ async def preload(self) -> None:
156
+ await asyncio.to_thread(self._load_model)
157
+
158
+
159
+ @lru_cache()
160
+ def get_stt_service() -> QwenSTTService:
161
+ return QwenSTTService(get_settings())
frontend/index.html ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>MedRAG β€” Medical Knowledge Assistant</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>πŸ₯</text></svg>" />
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.jsx"></script>
15
+ </body>
16
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "medrag-frontend",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "react": "^18.3.1",
13
+ "react-dom": "^18.3.1",
14
+ "react-router-dom": "^6.28.0",
15
+ "zustand": "^5.0.2",
16
+ "axios": "^1.7.9",
17
+ "react-markdown": "^9.0.1",
18
+ "remark-gfm": "^4.0.0",
19
+ "lucide-react": "^0.468.0",
20
+ "clsx": "^2.1.1",
21
+ "react-hot-toast": "^2.4.1",
22
+ "framer-motion": "^11.15.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "^18.3.17",
26
+ "@types/react-dom": "^18.3.5",
27
+ "@vitejs/plugin-react": "^4.3.4",
28
+ "autoprefixer": "^10.4.20",
29
+ "postcss": "^8.4.49",
30
+ "tailwindcss": "^3.4.17",
31
+ "vite": "^6.0.5"
32
+ }
33
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export default {
2
+ plugins: { tailwindcss: {}, autoprefixer: {} },
3
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
2
+ import { Toaster } from 'react-hot-toast'
3
+ import { useEffect } from 'react'
4
+ import { useAuthStore } from './store'
5
+ import { RequireAuth, RequireAdmin } from './components/ui/ProtectedRoute'
6
+ import AuthPage from './pages/AuthPage'
7
+ import ChatPage from './pages/ChatPage'
8
+ import AdminPage from './pages/AdminPage'
9
+
10
+ function AppInit({ children }) {
11
+ const { isAuthenticated, loadMe } = useAuthStore()
12
+ useEffect(() => {
13
+ if (isAuthenticated) loadMe()
14
+ }, [])
15
+ return children
16
+ }
17
+
18
+ export default function App() {
19
+ return (
20
+ <BrowserRouter>
21
+ <AppInit>
22
+ <Routes>
23
+ <Route path="/login" element={<AuthPage />} />
24
+ <Route
25
+ path="/"
26
+ element={
27
+ <RequireAuth>
28
+ <ChatPage />
29
+ </RequireAuth>
30
+ }
31
+ />
32
+ <Route
33
+ path="/admin"
34
+ element={
35
+ <RequireAdmin>
36
+ <AdminPage />
37
+ </RequireAdmin>
38
+ }
39
+ />
40
+ <Route path="*" element={<Navigate to="/" replace />} />
41
+ </Routes>
42
+ </AppInit>
43
+
44
+ <Toaster
45
+ position="top-right"
46
+ toastOptions={{
47
+ style: {
48
+ background: '#18181f',
49
+ color: '#e8e8f0',
50
+ border: '1px solid rgba(255,255,255,0.08)',
51
+ borderRadius: '12px',
52
+ fontSize: '13px',
53
+ },
54
+ success: { iconTheme: { primary: '#00c9a7', secondary: '#18181f' } },
55
+ error: { iconTheme: { primary: '#f87171', secondary: '#18181f' } },
56
+ }}
57
+ />
58
+ </BrowserRouter>
59
+ )
60
+ }
frontend/src/api/client.js ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios'
2
+
3
+ const api = axios.create({ baseURL: '/api/v1' })
4
+ const AUTH_PATHS = ['/auth/login', '/auth/register', '/auth/refresh']
5
+
6
+ // Attach access token to every request
7
+ api.interceptors.request.use((config) => {
8
+ const token = localStorage.getItem('access_token')
9
+ if (token) config.headers.Authorization = `Bearer ${token}`
10
+ return config
11
+ })
12
+
13
+ // Auto-refresh on 401
14
+ api.interceptors.response.use(
15
+ (res) => res,
16
+ async (err) => {
17
+ const original = err.config || {}
18
+ const requestUrl = original.url || ''
19
+ const isAuthRequest = AUTH_PATHS.some((path) => requestUrl.includes(path))
20
+
21
+ if (err.response?.status === 401 && !original._retry && !isAuthRequest) {
22
+ original._retry = true
23
+ try {
24
+ const refresh = localStorage.getItem('refresh_token')
25
+ if (!refresh) throw new Error('Missing refresh token')
26
+ const { data } = await axios.post('/api/v1/auth/refresh', { refresh_token: refresh })
27
+ localStorage.setItem('access_token', data.access_token)
28
+ localStorage.setItem('refresh_token', data.refresh_token)
29
+ original.headers.Authorization = `Bearer ${data.access_token}`
30
+ return api(original)
31
+ } catch {
32
+ localStorage.clear()
33
+ window.location.href = '/login'
34
+ }
35
+ }
36
+ return Promise.reject(err)
37
+ }
38
+ )
39
+
40
+ export function getApiErrorMessage(err, fallback = 'Something went wrong') {
41
+ const detail = err?.response?.data?.detail
42
+ if (typeof detail === 'string' && detail.trim()) return detail
43
+ if (Array.isArray(detail) && detail.length) {
44
+ return detail.map((item) => item?.msg || item).filter(Boolean).join(', ')
45
+ }
46
+ return fallback
47
+ }
48
+
49
+ // ─── Auth ──────────────────────────────────────────────────────────────────────
50
+ export const authApi = {
51
+ register: (d) => api.post('/auth/register', d).then(r => r.data),
52
+ login: (d) => api.post('/auth/login', d).then(r => r.data),
53
+ me: () => api.get('/auth/me').then(r => r.data),
54
+ }
55
+
56
+ // ─── Conversations ─────────────────────────────────────────────────────────────
57
+ export const convApi = {
58
+ list: () => api.get('/conversations').then(r => r.data),
59
+ create: (title = 'New Chat') => api.post('/conversations', { title }).then(r => r.data),
60
+ messages: (id) => api.get(`/conversations/${id}/messages`).then(r => r.data),
61
+ rename: (id, title) => api.patch(`/conversations/${id}`, { title }).then(r => r.data),
62
+ delete: (id) => api.delete(`/conversations/${id}`),
63
+ }
64
+
65
+ // ─── Query ─────────────────────────────────────────────────────────────────────
66
+ export const queryApi = {
67
+ ask: (payload) => api.post('/query', payload).then(r => r.data),
68
+ }
69
+
70
+ export const audioApi = {
71
+ transcribe: (formData) => api.post('/audio/transcribe', formData, {
72
+ headers: { 'Content-Type': 'multipart/form-data' }
73
+ }).then(r => r.data),
74
+ speak: (payload) => api.post('/audio/speak', payload, {
75
+ responseType: 'blob',
76
+ }).then(r => r.data),
77
+ }
78
+
79
+ // ─── Documents ─────────────────────────────────────────────────────────────────
80
+ export const docsApi = {
81
+ list: (conversationId) => api.get('/documents', {
82
+ params: conversationId ? { conversation_id: conversationId } : undefined,
83
+ }).then(r => r.data),
84
+ upload: (formData) => api.post('/documents/upload', formData, {
85
+ headers: { 'Content-Type': 'multipart/form-data' }
86
+ }).then(r => r.data),
87
+ clearConversation: (conversationId) => api.delete(`/documents/conversation/${conversationId}`),
88
+ delete: (id) => api.delete(`/documents/${id}`),
89
+ }
90
+
91
+ // ─── Admin ─────────────────────────────────────────────────────────────────────
92
+ export const adminApi = {
93
+ stats: () => api.get('/admin/stats').then(r => r.data),
94
+ users: () => api.get('/admin/users').then(r => r.data),
95
+ setRole: (id, role) => api.patch(`/admin/users/${id}/role`, null, { params: { role } }).then(r => r.data),
96
+ toggle: (id) => api.patch(`/admin/users/${id}/toggle`).then(r => r.data),
97
+ }
98
+
99
+ // ─── Health ────────────────────────────────────────────────────────────────────
100
+ export const healthApi = {
101
+ check: () => api.get('/health').then(r => r.data),
102
+ }
103
+
104
+ export default api
frontend/src/components/chat/ChatInput.jsx ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { LoaderCircle, Mic, Paperclip, Plus, Send, Square } from 'lucide-react'
3
+ import clsx from 'clsx'
4
+ import toast from 'react-hot-toast'
5
+
6
+ import { audioApi, getApiErrorMessage } from '../../api/client'
7
+
8
+ const SAMPLE_RATE = 16000
9
+ const MIN_RECORDING_SECONDS = 0.5
10
+ const TARGET_PEAK = 0.92
11
+ const LOW_INPUT_LEVEL = 0.015
12
+
13
+ function floatTo16BitPCM(view, offset, input) {
14
+ for (let i = 0; i < input.length; i += 1) {
15
+ const sample = Math.max(-1, Math.min(1, input[i]))
16
+ view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true)
17
+ offset += 2
18
+ }
19
+ }
20
+
21
+ function encodeWav(samples, sampleRate) {
22
+ const buffer = new ArrayBuffer(44 + samples.length * 2)
23
+ const view = new DataView(buffer)
24
+ const writeString = (offset, value) => {
25
+ for (let i = 0; i < value.length; i += 1) {
26
+ view.setUint8(offset + i, value.charCodeAt(i))
27
+ }
28
+ }
29
+
30
+ writeString(0, 'RIFF')
31
+ view.setUint32(4, 36 + samples.length * 2, true)
32
+ writeString(8, 'WAVE')
33
+ writeString(12, 'fmt ')
34
+ view.setUint32(16, 16, true)
35
+ view.setUint16(20, 1, true)
36
+ view.setUint16(22, 1, true)
37
+ view.setUint32(24, sampleRate, true)
38
+ view.setUint32(28, sampleRate * 2, true)
39
+ view.setUint16(32, 2, true)
40
+ view.setUint16(34, 16, true)
41
+ writeString(36, 'data')
42
+ view.setUint32(40, samples.length * 2, true)
43
+ floatTo16BitPCM(view, 44, samples)
44
+ return new Blob([buffer], { type: 'audio/wav' })
45
+ }
46
+
47
+ function downsampleBuffer(buffer, inputSampleRate, outputSampleRate) {
48
+ if (outputSampleRate >= inputSampleRate) return buffer
49
+
50
+ const sampleRateRatio = inputSampleRate / outputSampleRate
51
+ const newLength = Math.round(buffer.length / sampleRateRatio)
52
+ const result = new Float32Array(newLength)
53
+ let offsetResult = 0
54
+ let offsetBuffer = 0
55
+
56
+ while (offsetResult < result.length) {
57
+ const nextOffsetBuffer = Math.round((offsetResult + 1) * sampleRateRatio)
58
+ let accum = 0
59
+ let count = 0
60
+ for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i += 1) {
61
+ accum += buffer[i]
62
+ count += 1
63
+ }
64
+ result[offsetResult] = count > 0 ? accum / count : 0
65
+ offsetResult += 1
66
+ offsetBuffer = nextOffsetBuffer
67
+ }
68
+
69
+ return result
70
+ }
71
+
72
+ function normalizeSamples(samples, targetPeak = TARGET_PEAK) {
73
+ let peak = 0
74
+ for (let i = 0; i < samples.length; i += 1) {
75
+ peak = Math.max(peak, Math.abs(samples[i]))
76
+ }
77
+ if (peak === 0) return samples
78
+
79
+ const gain = Math.min(targetPeak / peak, 8)
80
+ if (gain <= 1.05) return samples
81
+
82
+ const normalized = new Float32Array(samples.length)
83
+ for (let i = 0; i < samples.length; i += 1) {
84
+ normalized[i] = Math.max(-1, Math.min(1, samples[i] * gain))
85
+ }
86
+ return normalized
87
+ }
88
+
89
+ function getPeak(samples) {
90
+ let peak = 0
91
+ for (let i = 0; i < samples.length; i += 1) {
92
+ peak = Math.max(peak, Math.abs(samples[i]))
93
+ }
94
+ return peak
95
+ }
96
+
97
+ function mixToMono(audioBuffer) {
98
+ const channelCount = audioBuffer.numberOfChannels
99
+ if (channelCount === 1) return new Float32Array(audioBuffer.getChannelData(0))
100
+
101
+ const mono = new Float32Array(audioBuffer.length)
102
+ for (let channel = 0; channel < channelCount; channel += 1) {
103
+ const data = audioBuffer.getChannelData(channel)
104
+ for (let i = 0; i < data.length; i += 1) {
105
+ mono[i] += data[i] / channelCount
106
+ }
107
+ }
108
+ return mono
109
+ }
110
+
111
+ async function decodeBlobToMonoWav(blob) {
112
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext
113
+ const audioContext = new AudioContextClass()
114
+ try {
115
+ if (audioContext.state === 'suspended') await audioContext.resume()
116
+ const arrayBuffer = await blob.arrayBuffer()
117
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
118
+ const mono = mixToMono(audioBuffer)
119
+ const downsampled = downsampleBuffer(mono, audioBuffer.sampleRate, SAMPLE_RATE)
120
+ const peakBefore = getPeak(downsampled)
121
+ const normalized = normalizeSamples(downsampled)
122
+ return {
123
+ wavBlob: encodeWav(normalized, SAMPLE_RATE),
124
+ durationSeconds: normalized.length / SAMPLE_RATE,
125
+ sampleCount: normalized.length,
126
+ peakBefore,
127
+ }
128
+ } finally {
129
+ if (audioContext.state !== 'closed') await audioContext.close()
130
+ }
131
+ }
132
+
133
+ export default function ChatInput({ onSend, onUploadClick, isLoading, disabled, disabledReason }) {
134
+ const [text, setText] = useState('')
135
+ const [isRecording, setIsRecording] = useState(false)
136
+ const [isTranscribing, setIsTranscribing] = useState(false)
137
+ const [inputLevel, setInputLevel] = useState(0)
138
+ const textareaRef = useRef(null)
139
+ const streamRef = useRef(null)
140
+ const mediaRecorderRef = useRef(null)
141
+ const chunksRef = useRef([])
142
+ const monitorAudioContextRef = useRef(null)
143
+ const analyserRef = useRef(null)
144
+ const monitorFrameRef = useRef(null)
145
+
146
+ useEffect(() => {
147
+ const el = textareaRef.current
148
+ if (!el) return
149
+ el.style.height = 'auto'
150
+ el.style.height = Math.min(el.scrollHeight, 200) + 'px'
151
+ }, [text])
152
+
153
+ useEffect(() => () => {
154
+ if (mediaRecorderRef.current?.state && mediaRecorderRef.current.state !== 'inactive') {
155
+ mediaRecorderRef.current.stop()
156
+ }
157
+ if (monitorFrameRef.current) cancelAnimationFrame(monitorFrameRef.current)
158
+ if (monitorAudioContextRef.current && monitorAudioContextRef.current.state !== 'closed') {
159
+ monitorAudioContextRef.current.close().catch(() => {})
160
+ }
161
+ if (streamRef.current) {
162
+ streamRef.current.getTracks().forEach((track) => track.stop())
163
+ }
164
+ }, [])
165
+
166
+ const submit = () => {
167
+ const q = text.trim()
168
+ if (!q || isLoading || disabled || isRecording || isTranscribing) return
169
+ onSend(q)
170
+ setText('')
171
+ if (textareaRef.current) textareaRef.current.style.height = 'auto'
172
+ }
173
+
174
+ const handleKey = (e) => {
175
+ if (e.key === 'Enter' && !e.shiftKey) {
176
+ e.preventDefault()
177
+ submit()
178
+ }
179
+ }
180
+
181
+ const stopInputMonitor = async () => {
182
+ if (monitorFrameRef.current) {
183
+ cancelAnimationFrame(monitorFrameRef.current)
184
+ monitorFrameRef.current = null
185
+ }
186
+ analyserRef.current = null
187
+ if (monitorAudioContextRef.current && monitorAudioContextRef.current.state !== 'closed') {
188
+ await monitorAudioContextRef.current.close()
189
+ }
190
+ monitorAudioContextRef.current = null
191
+ setInputLevel(0)
192
+ }
193
+
194
+ const stopStream = async () => {
195
+ await stopInputMonitor()
196
+ if (!streamRef.current) return
197
+ streamRef.current.getTracks().forEach((track) => track.stop())
198
+ streamRef.current = null
199
+ }
200
+
201
+ const startInputMonitor = async (stream) => {
202
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext
203
+ const audioContext = new AudioContextClass()
204
+ if (audioContext.state === 'suspended') await audioContext.resume()
205
+
206
+ const source = audioContext.createMediaStreamSource(stream)
207
+ const analyser = audioContext.createAnalyser()
208
+ analyser.fftSize = 2048
209
+ source.connect(analyser)
210
+
211
+ monitorAudioContextRef.current = audioContext
212
+ analyserRef.current = analyser
213
+
214
+ const buffer = new Float32Array(analyser.fftSize)
215
+ const tick = () => {
216
+ if (!analyserRef.current) return
217
+ analyserRef.current.getFloatTimeDomainData(buffer)
218
+ let sumSquares = 0
219
+ for (let i = 0; i < buffer.length; i += 1) {
220
+ sumSquares += buffer[i] * buffer[i]
221
+ }
222
+ const rms = Math.sqrt(sumSquares / buffer.length)
223
+ setInputLevel(Math.min(1, rms * 12))
224
+ monitorFrameRef.current = requestAnimationFrame(tick)
225
+ }
226
+ tick()
227
+ }
228
+
229
+ const transcribeWavBlob = async (blob) => {
230
+ const formData = new FormData()
231
+ formData.append('file', new File([blob], 'recording.wav', { type: 'audio/wav' }))
232
+
233
+ setIsTranscribing(true)
234
+ try {
235
+ const result = await audioApi.transcribe(formData)
236
+ if (!result.text?.trim()) {
237
+ toast.error('No speech was detected in that recording')
238
+ return
239
+ }
240
+ setText((prev) => [prev.trim(), result.text.trim()].filter(Boolean).join(prev.trim() ? '\n' : ''))
241
+ toast.success('Transcript added to the message box')
242
+ } catch (err) {
243
+ toast.error(getApiErrorMessage(err, 'Failed to transcribe audio'))
244
+ } finally {
245
+ setIsTranscribing(false)
246
+ }
247
+ }
248
+
249
+ const handleRecordedBlob = async (blob) => {
250
+ try {
251
+ const { wavBlob, durationSeconds, sampleCount, peakBefore } = await decodeBlobToMonoWav(blob)
252
+ if (sampleCount === 0) {
253
+ toast.error('Recording was empty')
254
+ return
255
+ }
256
+ if (durationSeconds < MIN_RECORDING_SECONDS) {
257
+ toast.error('Recording is too short. Please speak for a bit longer.')
258
+ return
259
+ }
260
+ if (peakBefore < LOW_INPUT_LEVEL) {
261
+ toast.error('The selected browser microphone is almost silent. Check the browser mic permission and selected input device.')
262
+ return
263
+ }
264
+ await transcribeWavBlob(wavBlob)
265
+ } catch (err) {
266
+ toast.error(err?.message || 'Could not process the microphone recording')
267
+ }
268
+ }
269
+
270
+ const toggleRecording = async () => {
271
+ if (disabled || isLoading || isTranscribing) return
272
+
273
+ if (isRecording) {
274
+ mediaRecorderRef.current?.stop()
275
+ setIsRecording(false)
276
+ return
277
+ }
278
+
279
+ if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
280
+ toast.error('Your browser does not support microphone recording')
281
+ return
282
+ }
283
+
284
+ try {
285
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
286
+ streamRef.current = stream
287
+ await startInputMonitor(stream)
288
+
289
+ const track = stream.getAudioTracks()[0]
290
+ const label = track?.label?.trim()
291
+ if (label) {
292
+ toast.success(`Using microphone: ${label}`)
293
+ } else {
294
+ toast.success('Recording started. Press the mic again to stop.')
295
+ }
296
+
297
+ const mimeType = [
298
+ 'audio/webm;codecs=opus',
299
+ 'audio/webm',
300
+ 'audio/ogg;codecs=opus',
301
+ ].find((type) => MediaRecorder.isTypeSupported(type))
302
+
303
+ if (!mimeType) {
304
+ await stopStream()
305
+ toast.error('This browser cannot record in a supported audio format')
306
+ return
307
+ }
308
+
309
+ const recorder = new MediaRecorder(stream, { mimeType })
310
+ mediaRecorderRef.current = recorder
311
+ chunksRef.current = []
312
+
313
+ recorder.ondataavailable = (event) => {
314
+ if (event.data?.size) chunksRef.current.push(event.data)
315
+ }
316
+ recorder.onerror = async () => {
317
+ await stopStream()
318
+ setIsRecording(false)
319
+ toast.error('Microphone recording failed')
320
+ }
321
+ recorder.onstop = async () => {
322
+ const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType })
323
+ chunksRef.current = []
324
+ await stopStream()
325
+ await handleRecordedBlob(blob)
326
+ }
327
+
328
+ recorder.start()
329
+ setIsRecording(true)
330
+ } catch (err) {
331
+ await stopStream()
332
+ toast.error(err?.message || 'Could not access the microphone')
333
+ }
334
+ }
335
+
336
+ return (
337
+ <div className="border border-white/8 bg-surface-2 rounded-2xl shadow-xl overflow-hidden transition-all duration-150 focus-within:border-accent/30 focus-within:shadow-accent/5">
338
+ <textarea
339
+ ref={textareaRef}
340
+ value={text}
341
+ onChange={(e) => setText(e.target.value)}
342
+ onKeyDown={handleKey}
343
+ disabled={disabled || isLoading || isTranscribing}
344
+ placeholder={disabled && disabledReason ? disabledReason : 'Ask a medical question...'}
345
+ rows={1}
346
+ className="w-full bg-transparent text-white text-sm placeholder-gray-600 px-4 pt-4 pb-2 resize-none outline-none leading-relaxed disabled:opacity-50"
347
+ />
348
+
349
+ <div className="flex items-center justify-between px-3 pb-3 pt-1">
350
+ <div className="flex items-center gap-1">
351
+ <button
352
+ onClick={onUploadClick}
353
+ title="Upload PDF / document"
354
+ className={clsx(
355
+ 'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all duration-150',
356
+ 'text-gray-400 hover:text-accent hover:bg-accent/10 border border-white/5 hover:border-accent/20'
357
+ )}
358
+ >
359
+ <Plus size={13} />
360
+ <Paperclip size={12} />
361
+ </button>
362
+ <button
363
+ onClick={toggleRecording}
364
+ disabled={disabled || isLoading || isTranscribing}
365
+ title={
366
+ isTranscribing
367
+ ? 'Transcribing audio...'
368
+ : isRecording
369
+ ? 'Stop recording'
370
+ : 'Record audio for transcription'
371
+ }
372
+ className={clsx(
373
+ 'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all duration-150 border',
374
+ isRecording
375
+ ? 'text-red-300 bg-red-500/10 border-red-400/30 hover:bg-red-500/15'
376
+ : 'text-gray-400 hover:text-accent hover:bg-accent/10 border-white/5 hover:border-accent/20',
377
+ (disabled || isLoading || isTranscribing) && 'opacity-60 cursor-not-allowed'
378
+ )}
379
+ >
380
+ {isTranscribing
381
+ ? <LoaderCircle size={13} className="animate-spin" />
382
+ : <Mic size={13} className={isRecording ? 'animate-pulse' : ''} />
383
+ }
384
+ </button>
385
+ </div>
386
+
387
+ <div className="flex items-center gap-2">
388
+ {(text || isRecording || isTranscribing) && (
389
+ <span className="text-[10px] text-gray-600 select-none">
390
+ {isRecording
391
+ ? 'Recording... press mic to stop'
392
+ : isTranscribing
393
+ ? 'Transcribing audio...'
394
+ : 'Enter send | Shift+Enter newline'}
395
+ </span>
396
+ )}
397
+ <button
398
+ onClick={submit}
399
+ disabled={!text.trim() || disabled || isRecording || isTranscribing}
400
+ title={disabled && disabledReason ? disabledReason : (isLoading ? 'Processing...' : 'Send')}
401
+ className={clsx(
402
+ 'w-8 h-8 rounded-lg flex items-center justify-center transition-all duration-150',
403
+ text.trim() && !disabled && !isRecording && !isTranscribing
404
+ ? 'bg-accent hover:bg-accent-hover text-white shadow-md shadow-accent/20 active:scale-95'
405
+ : 'bg-surface-3 text-gray-600 cursor-not-allowed'
406
+ )}
407
+ >
408
+ {isLoading
409
+ ? <Square size={12} className="fill-current" />
410
+ : <Send size={13} className={text.trim() ? '' : 'opacity-40'} />
411
+ }
412
+ </button>
413
+ </div>
414
+ </div>
415
+
416
+ {(isRecording || inputLevel > 0) && (
417
+ <div className="px-4 pb-3">
418
+ <div className="flex items-center gap-2">
419
+ <div className="h-1.5 flex-1 rounded-full bg-surface-3 overflow-hidden">
420
+ <div
421
+ className={clsx(
422
+ 'h-full transition-[width] duration-100',
423
+ inputLevel < LOW_INPUT_LEVEL ? 'bg-amber-400/80' : 'bg-emerald-400/90'
424
+ )}
425
+ style={{ width: `${Math.max(4, inputLevel * 100)}%` }}
426
+ />
427
+ </div>
428
+ <span className="text-[10px] text-gray-500 w-12 text-right">
429
+ {inputLevel < LOW_INPUT_LEVEL ? 'Low mic' : 'Mic ok'}
430
+ </span>
431
+ </div>
432
+ </div>
433
+ )}
434
+
435
+ {disabled && disabledReason && (
436
+ <div className="px-4 pb-3 text-[11px] text-amber-300/80">
437
+ {disabledReason}
438
+ </div>
439
+ )}
440
+ </div>
441
+ )
442
+ }
frontend/src/components/chat/MessageBubble.jsx ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useState } from 'react'
2
+ import ReactMarkdown from 'react-markdown'
3
+ import remarkGfm from 'remark-gfm'
4
+ import { motion } from 'framer-motion'
5
+ import { ChevronDown, ChevronUp, BookOpen, Hash, LoaderCircle, Volume2, VolumeX } from 'lucide-react'
6
+ import { Stethoscope, User } from 'lucide-react'
7
+ import clsx from 'clsx'
8
+ import toast from 'react-hot-toast'
9
+
10
+ import { audioApi, getApiErrorMessage } from '../../api/client'
11
+
12
+ function TypingDots() {
13
+ return (
14
+ <div className="flex items-center gap-1 py-1">
15
+ <span className="typing-dot" />
16
+ <span className="typing-dot" />
17
+ <span className="typing-dot" />
18
+ </div>
19
+ )
20
+ }
21
+
22
+ function SourceCard({ source, index }) {
23
+ const [open, setOpen] = useState(false)
24
+ return (
25
+ <div className="border border-white/8 rounded-lg overflow-hidden text-xs">
26
+ <button
27
+ onClick={() => setOpen(o => !o)}
28
+ className="w-full flex items-center gap-2 px-3 py-2 bg-surface-3 hover:bg-surface-4 transition-colors text-left"
29
+ >
30
+ <span className="w-5 h-5 rounded-full bg-accent/20 text-accent flex items-center justify-center font-bold text-[10px] shrink-0">{index}</span>
31
+ <div className="flex-1 min-w-0">
32
+ <p className="text-white/80 font-medium truncate">{source.title}</p>
33
+ <p className="text-gray-500 truncate">
34
+ {source.filename}
35
+ {source.page_number ? ` | page ${source.page_number}` : ''}
36
+ {source.retrieval_method ? ` | ${source.retrieval_method}` : ''}
37
+ </p>
38
+ </div>
39
+ <span className="text-gray-500 shrink-0 font-mono">{(source.score * 100).toFixed(0)}%</span>
40
+ {open ? <ChevronUp size={12} className="text-gray-500 shrink-0" /> : <ChevronDown size={12} className="text-gray-500 shrink-0" />}
41
+ </button>
42
+ {open && (
43
+ <div className="px-3 py-2 bg-surface-2 border-t border-white/5 text-gray-400 leading-relaxed text-[11px] max-h-40 overflow-y-auto">
44
+ <div className="flex items-center gap-3 text-[10px] text-gray-500 mb-2">
45
+ <span className="flex items-center gap-1"><Hash size={10} /> chunk {source.chunk_index}</span>
46
+ {source.page_number ? <span>page {source.page_number}</span> : null}
47
+ <span className="font-mono">{source.doc_id.slice(0, 8)}</span>
48
+ </div>
49
+ {source.text}
50
+ </div>
51
+ )}
52
+ </div>
53
+ )
54
+ }
55
+
56
+ export default function MessageBubble({ message, isStreaming = false }) {
57
+ const [showSources, setShowSources] = useState(false)
58
+ const [isSpeaking, setIsSpeaking] = useState(false)
59
+ const isUser = message.role === 'user'
60
+ const sources = message.sources || []
61
+ const audioRef = useRef(null)
62
+
63
+ const handleSpeak = async () => {
64
+ if (!message.content?.trim()) return
65
+
66
+ if (audioRef.current && !audioRef.current.paused) {
67
+ audioRef.current.pause()
68
+ audioRef.current.currentTime = 0
69
+ setIsSpeaking(false)
70
+ return
71
+ }
72
+
73
+ setIsSpeaking(true)
74
+ try {
75
+ const blob = await audioApi.speak({ text: message.content })
76
+ const url = URL.createObjectURL(blob)
77
+ const audio = new Audio(url)
78
+ audioRef.current = audio
79
+ audio.onended = () => {
80
+ URL.revokeObjectURL(url)
81
+ setIsSpeaking(false)
82
+ }
83
+ audio.onerror = () => {
84
+ URL.revokeObjectURL(url)
85
+ setIsSpeaking(false)
86
+ }
87
+ await audio.play()
88
+ } catch (err) {
89
+ setIsSpeaking(false)
90
+ toast.error(getApiErrorMessage(err, 'Failed to generate speech'))
91
+ }
92
+ }
93
+
94
+ return (
95
+ <motion.div
96
+ initial={{ opacity: 0, y: 10 }}
97
+ animate={{ opacity: 1, y: 0 }}
98
+ transition={{ duration: 0.2 }}
99
+ className={clsx('flex gap-3 group', isUser && 'flex-row-reverse')}
100
+ >
101
+ <div className={clsx(
102
+ 'w-8 h-8 rounded-xl flex items-center justify-center shrink-0 mt-0.5',
103
+ isUser ? 'bg-accent/20 border border-accent/30' : 'bg-med-teal/10 border border-med-teal/20'
104
+ )}>
105
+ {isUser
106
+ ? <User size={14} className="text-accent" />
107
+ : <Stethoscope size={14} className="text-med-teal" />
108
+ }
109
+ </div>
110
+
111
+ <div className={clsx('flex-1 min-w-0 max-w-2xl', isUser && 'flex flex-col items-end')}>
112
+ {isUser ? (
113
+ <div className="bg-accent/10 border border-accent/20 rounded-2xl rounded-tr-sm px-4 py-3 text-sm text-white leading-relaxed">
114
+ {message.content}
115
+ </div>
116
+ ) : (
117
+ <div className="space-y-3">
118
+ <div className="bg-surface-2 border border-white/5 rounded-2xl rounded-tl-sm px-4 py-3">
119
+ {isStreaming ? (
120
+ <TypingDots />
121
+ ) : (
122
+ <div className="space-y-3">
123
+ <div className="prose-dark text-sm leading-relaxed">
124
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
125
+ {message.content}
126
+ </ReactMarkdown>
127
+ </div>
128
+ <div className="flex items-center justify-end">
129
+ <button
130
+ onClick={handleSpeak}
131
+ className="flex items-center gap-1.5 text-xs text-gray-400 hover:text-white transition-colors"
132
+ title={isSpeaking ? 'Stop audio' : 'Play answer audio'}
133
+ >
134
+ {isSpeaking
135
+ ? <LoaderCircle size={12} className="animate-spin" />
136
+ : audioRef.current && !audioRef.current.paused
137
+ ? <VolumeX size={12} />
138
+ : <Volume2 size={12} />
139
+ }
140
+ {isSpeaking ? 'Speaking...' : 'Speak'}
141
+ </button>
142
+ </div>
143
+ </div>
144
+ )}
145
+ </div>
146
+
147
+ {!isStreaming && sources.length > 0 && (
148
+ <div className="space-y-2">
149
+ <button
150
+ onClick={() => setShowSources(s => !s)}
151
+ className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
152
+ >
153
+ <BookOpen size={11} />
154
+ {sources.length} source{sources.length !== 1 ? 's' : ''}
155
+ {showSources ? <ChevronUp size={11} /> : <ChevronDown size={11} />}
156
+ </button>
157
+
158
+ {showSources && (
159
+ <motion.div
160
+ initial={{ opacity: 0, height: 0 }}
161
+ animate={{ opacity: 1, height: 'auto' }}
162
+ exit={{ opacity: 0, height: 0 }}
163
+ className="space-y-1.5"
164
+ >
165
+ {sources.map((s, i) => (
166
+ <SourceCard key={i} source={s} index={i + 1} />
167
+ ))}
168
+ </motion.div>
169
+ )}
170
+ </div>
171
+ )}
172
+ </div>
173
+ )}
174
+ </div>
175
+ </motion.div>
176
+ )
177
+ }
frontend/src/components/chat/UploadModal.jsx ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useState } from 'react'
2
+ import { motion, AnimatePresence } from 'framer-motion'
3
+ import { X, Upload, FileText, CheckCircle, AlertCircle, Loader } from 'lucide-react'
4
+ import { docsApi } from '../../api/client'
5
+ import toast from 'react-hot-toast'
6
+
7
+ const ALLOWED_TYPES = [
8
+ 'application/pdf',
9
+ 'text/plain',
10
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
11
+ ]
12
+
13
+ export default function UploadModal({ onClose, conversationId }) {
14
+ const [files, setFiles] = useState([])
15
+ const [title, setTitle] = useState('')
16
+ const [source, setSource] = useState('')
17
+ const [status, setStatus] = useState('idle')
18
+ const [errorMsg, setErrorMsg] = useState('')
19
+ const fileRef = useRef()
20
+ const isSingleFile = files.length === 1
21
+
22
+ const selectFiles = (fileList) => {
23
+ const picked = Array.from(fileList || []).filter((file) => {
24
+ const ok = ALLOWED_TYPES.includes(file.type) || /\.(pdf|txt|docx)$/i.test(file.name)
25
+ if (!ok) toast.error(`Unsupported file skipped: ${file.name}`)
26
+ return ok
27
+ })
28
+ if (!picked.length) return
29
+ setFiles(picked)
30
+ if (picked.length === 1) {
31
+ setTitle((prev) => prev || picked[0].name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' '))
32
+ } else {
33
+ setTitle('')
34
+ }
35
+ }
36
+
37
+ const handleDrop = (e) => {
38
+ e.preventDefault()
39
+ selectFiles(e.dataTransfer.files)
40
+ }
41
+
42
+ const submit = async () => {
43
+ if (!conversationId) {
44
+ setErrorMsg('Create a conversation before uploading documents')
45
+ setStatus('error')
46
+ return
47
+ }
48
+ if (!files.length || (isSingleFile && !title.trim())) return
49
+ setStatus('uploading')
50
+ setErrorMsg('')
51
+ try {
52
+ const fd = new FormData()
53
+ files.forEach((file) => fd.append('files', file))
54
+ fd.append('conversation_id', conversationId)
55
+ if (isSingleFile) fd.append('title', title.trim())
56
+ if (source.trim()) fd.append('source', source.trim())
57
+ const result = await docsApi.upload(fd)
58
+ setStatus('success')
59
+ toast.success(result.message || 'Documents queued for ingestion')
60
+ setTimeout(onClose, 1800)
61
+ } catch (err) {
62
+ setErrorMsg(err.response?.data?.detail || 'Upload failed')
63
+ setStatus('error')
64
+ }
65
+ }
66
+
67
+ const totalSizeMb = (files.reduce((sum, file) => sum + file.size, 0) / 1024 / 1024).toFixed(2)
68
+
69
+ return (
70
+ <AnimatePresence>
71
+ <motion.div
72
+ initial={{ opacity: 0 }}
73
+ animate={{ opacity: 1 }}
74
+ exit={{ opacity: 0 }}
75
+ className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4"
76
+ onClick={(e) => e.target === e.currentTarget && onClose()}
77
+ >
78
+ <motion.div
79
+ initial={{ opacity: 0, scale: 0.95, y: 16 }}
80
+ animate={{ opacity: 1, scale: 1, y: 0 }}
81
+ exit={{ opacity: 0, scale: 0.95, y: 16 }}
82
+ transition={{ duration: 0.2 }}
83
+ className="bg-surface-2 border border-white/8 rounded-2xl w-full max-w-md p-6 shadow-2xl"
84
+ >
85
+ <div className="flex items-center justify-between mb-5">
86
+ <div>
87
+ <h2 className="text-white font-semibold">Upload Medical Documents</h2>
88
+ <p className="text-xs text-gray-500 mt-0.5">PDF, DOCX, or TXT. One or many files.</p>
89
+ </div>
90
+ <button onClick={onClose} className="text-gray-500 hover:text-white p-1 rounded-lg hover:bg-white/5">
91
+ <X size={18} />
92
+ </button>
93
+ </div>
94
+
95
+ <div
96
+ onDragOver={(e) => e.preventDefault()}
97
+ onDrop={handleDrop}
98
+ onClick={() => fileRef.current?.click()}
99
+ className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200 mb-4 ${
100
+ files.length ? 'border-med-teal/40 bg-med-teal/5' : 'border-white/10 hover:border-accent/40 hover:bg-accent/5'
101
+ }`}
102
+ >
103
+ <input
104
+ ref={fileRef}
105
+ type="file"
106
+ multiple
107
+ accept=".pdf,.docx,.txt"
108
+ className="hidden"
109
+ onChange={(e) => selectFiles(e.target.files)}
110
+ />
111
+ {files.length ? (
112
+ <div className="space-y-3">
113
+ <div className="flex items-center gap-3 justify-center">
114
+ <FileText className="text-med-teal" size={22} />
115
+ <div className="text-left">
116
+ <p className="text-white text-sm font-medium">
117
+ {isSingleFile ? files[0].name : `${files.length} files selected`}
118
+ </p>
119
+ <p className="text-gray-500 text-xs">{totalSizeMb} MB total</p>
120
+ </div>
121
+ </div>
122
+ {files.length > 1 && (
123
+ <div className="max-h-28 overflow-y-auto text-left text-xs text-gray-400 space-y-1">
124
+ {files.map((file) => (
125
+ <div key={`${file.name}-${file.size}`} className="truncate">
126
+ {file.name}
127
+ </div>
128
+ ))}
129
+ </div>
130
+ )}
131
+ </div>
132
+ ) : (
133
+ <div>
134
+ <Upload size={24} className="mx-auto text-gray-500 mb-2" />
135
+ <p className="text-gray-400 text-sm">Drop files here or <span className="text-accent">browse</span></p>
136
+ </div>
137
+ )}
138
+ </div>
139
+
140
+ <div className="space-y-3 mb-5">
141
+ {isSingleFile && (
142
+ <div>
143
+ <label className="block text-xs text-gray-400 mb-1.5 font-medium">Document Title *</label>
144
+ <input
145
+ className="input-field"
146
+ placeholder="e.g. Harrison's Principles of Internal Medicine"
147
+ value={title}
148
+ onChange={(e) => setTitle(e.target.value)}
149
+ />
150
+ </div>
151
+ )}
152
+ {files.length > 1 && (
153
+ <div className="text-xs text-gray-500">
154
+ Titles will be generated automatically from the filenames.
155
+ </div>
156
+ )}
157
+ <div>
158
+ <label className="block text-xs text-gray-400 mb-1.5 font-medium">Source / Reference <span className="text-gray-600">(optional)</span></label>
159
+ <input
160
+ className="input-field"
161
+ placeholder="e.g. PubMed, WHO Guidelines 2024"
162
+ value={source}
163
+ onChange={(e) => setSource(e.target.value)}
164
+ />
165
+ </div>
166
+ </div>
167
+
168
+ {status === 'error' && (
169
+ <div className="flex items-start gap-2 bg-red-500/10 border border-red-500/20 rounded-lg p-3 mb-4 text-xs text-red-400">
170
+ <AlertCircle size={14} className="shrink-0 mt-0.5" />
171
+ {errorMsg}
172
+ </div>
173
+ )}
174
+ {status === 'success' && (
175
+ <div className="flex items-center gap-2 bg-med-teal/10 border border-med-teal/20 rounded-lg p-3 mb-4 text-xs text-med-teal">
176
+ <CheckCircle size={14} />
177
+ Upload accepted and queued for background ingestion. Closing...
178
+ </div>
179
+ )}
180
+
181
+ <div className="flex gap-3">
182
+ <button onClick={onClose} className="btn-ghost flex-1 text-sm">Cancel</button>
183
+ <button
184
+ onClick={submit}
185
+ disabled={!files.length || (isSingleFile && !title.trim()) || status === 'uploading' || status === 'success'}
186
+ className="btn-primary flex-1 text-sm flex items-center justify-center gap-2"
187
+ >
188
+ {status === 'uploading'
189
+ ? <><Loader size={14} className="animate-spin" /> Queueing...</>
190
+ : <><Upload size={14} /> Upload</>
191
+ }
192
+ </button>
193
+ </div>
194
+ </motion.div>
195
+ </motion.div>
196
+ </AnimatePresence>
197
+ )
198
+ }
frontend/src/components/chat/WelcomeScreen.jsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Stethoscope, FileSearch, Brain, BookOpen } from 'lucide-react'
2
+
3
+ const SUGGESTIONS = [
4
+ { icon: Stethoscope, text: 'What are the first-line treatments for hypertension?' },
5
+ { icon: FileSearch, text: 'Explain the pathophysiology of type 2 diabetes.' },
6
+ { icon: Brain, text: 'What are the diagnostic criteria for major depressive disorder?' },
7
+ { icon: BookOpen, text: 'Summarize the mechanism of action of beta-blockers.' },
8
+ ]
9
+
10
+ export default function WelcomeScreen({ onSuggest }) {
11
+ return (
12
+ <div className="flex flex-col items-center justify-center h-full px-4 pb-16 animate-fade-in">
13
+ {/* Icon */}
14
+ <div className="w-16 h-16 rounded-2xl bg-accent/10 border border-accent/20 flex items-center justify-center mb-6">
15
+ <Stethoscope className="text-accent w-8 h-8" />
16
+ </div>
17
+
18
+ <h2 className="text-2xl font-bold text-white mb-2">MedRAG Assistant</h2>
19
+ <p className="text-gray-500 text-sm text-center max-w-sm mb-10 leading-relaxed">
20
+ Ask clinical questions grounded in your uploaded medical knowledge base.
21
+ </p>
22
+
23
+ {/* Suggestion cards */}
24
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-lg">
25
+ {SUGGESTIONS.map(({ icon: Icon, text }) => (
26
+ <button
27
+ key={text}
28
+ onClick={() => onSuggest(text)}
29
+ className="flex items-start gap-3 p-3.5 rounded-xl bg-surface-2 border border-white/5 hover:border-accent/20 hover:bg-surface-3 transition-all duration-150 text-left group"
30
+ >
31
+ <div className="w-7 h-7 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-0.5 group-hover:bg-accent/20 transition-colors">
32
+ <Icon size={13} className="text-accent" />
33
+ </div>
34
+ <span className="text-xs text-gray-400 group-hover:text-gray-200 transition-colors leading-relaxed">{text}</span>
35
+ </button>
36
+ ))}
37
+ </div>
38
+
39
+ <p className="mt-10 text-[10px] text-gray-700 text-center max-w-xs">
40
+ Answers are grounded in indexed documents only.
41
+ Always verify with a licensed healthcare professional.
42
+ </p>
43
+ </div>
44
+ )
45
+ }
frontend/src/components/layout/Sidebar.jsx ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from 'react'
2
+ import { motion, AnimatePresence } from 'framer-motion'
3
+ import { useChatStore, useAuthStore } from '../../store'
4
+ import {
5
+ Plus, MessageSquare, Trash2, Pencil, Check, X,
6
+ Stethoscope, LogOut, Shield, ChevronRight, Search
7
+ } from 'lucide-react'
8
+ import { useNavigate } from 'react-router-dom'
9
+ import toast from 'react-hot-toast'
10
+ import clsx from 'clsx'
11
+
12
+ function ConvItem({ conv, isActive, onSelect, onDelete, onRename }) {
13
+ const [editing, setEditing] = useState(false)
14
+ const [title, setTitle] = useState(conv.title)
15
+ const [showActions, setShowActions] = useState(false)
16
+ const inputRef = useRef()
17
+
18
+ useEffect(() => { if (editing) inputRef.current?.focus() }, [editing])
19
+
20
+ const saveRename = async () => {
21
+ if (title.trim() && title !== conv.title) await onRename(conv.id, title.trim())
22
+ setEditing(false)
23
+ }
24
+
25
+ const handleKey = (e) => {
26
+ if (e.key === 'Enter') saveRename()
27
+ if (e.key === 'Escape') { setTitle(conv.title); setEditing(false) }
28
+ }
29
+
30
+ return (
31
+ <div
32
+ className={clsx('sidebar-item relative', isActive && 'active')}
33
+ onClick={() => !editing && onSelect(conv.id)}
34
+ onMouseEnter={() => setShowActions(true)}
35
+ onMouseLeave={() => setShowActions(false)}
36
+ >
37
+ <MessageSquare size={14} className="shrink-0 opacity-60" />
38
+
39
+ {editing ? (
40
+ <input
41
+ ref={inputRef}
42
+ value={title}
43
+ onChange={e => setTitle(e.target.value)}
44
+ onKeyDown={handleKey}
45
+ onBlur={saveRename}
46
+ onClick={e => e.stopPropagation()}
47
+ className="flex-1 bg-surface-4 text-white text-sm px-2 py-0.5 rounded outline-none border border-accent/40 min-w-0"
48
+ />
49
+ ) : (
50
+ <span className="flex-1 truncate text-sm">{conv.title}</span>
51
+ )}
52
+
53
+ <AnimatePresence>
54
+ {showActions && !editing && (
55
+ <motion.div
56
+ initial={{ opacity: 0 }}
57
+ animate={{ opacity: 1 }}
58
+ exit={{ opacity: 0 }}
59
+ className="flex items-center gap-0.5 shrink-0"
60
+ onClick={e => e.stopPropagation()}
61
+ >
62
+ <button
63
+ onClick={() => setEditing(true)}
64
+ className="p-1 hover:text-white text-gray-500 rounded hover:bg-white/10"
65
+ >
66
+ <Pencil size={12} />
67
+ </button>
68
+ <button
69
+ onClick={() => onDelete(conv.id)}
70
+ className="p-1 hover:text-red-400 text-gray-500 rounded hover:bg-white/10"
71
+ >
72
+ <Trash2 size={12} />
73
+ </button>
74
+ </motion.div>
75
+ )}
76
+ </AnimatePresence>
77
+ </div>
78
+ )
79
+ }
80
+
81
+ export default function Sidebar({ onClose }) {
82
+ const { conversations, activeConvId, createConversation, selectConversation, deleteConversation, renameConversation, fetchConversations } = useChatStore()
83
+ const { user, logout } = useAuthStore()
84
+ const navigate = useNavigate()
85
+ const [search, setSearch] = useState('')
86
+
87
+ useEffect(() => { fetchConversations() }, [])
88
+
89
+ const handleNew = async () => {
90
+ await createConversation()
91
+ onClose?.()
92
+ }
93
+
94
+ const handleDelete = async (id) => {
95
+ if (!confirm('Delete this conversation?')) return
96
+ try { await deleteConversation(id) }
97
+ catch { toast.error('Could not delete conversation') }
98
+ }
99
+
100
+ const handleRename = async (id, title) => {
101
+ try { await renameConversation(id, title) }
102
+ catch { toast.error('Could not rename') }
103
+ }
104
+
105
+ const filtered = conversations.filter(c =>
106
+ c.title.toLowerCase().includes(search.toLowerCase())
107
+ )
108
+
109
+ // Group by date
110
+ const now = new Date()
111
+ const groups = { Today: [], 'Last 7 days': [], Older: [] }
112
+ filtered.forEach(c => {
113
+ const d = new Date(c.updated_at)
114
+ const diffDays = (now - d) / 86400000
115
+ if (diffDays < 1) groups['Today'].push(c)
116
+ else if (diffDays < 7) groups['Last 7 days'].push(c)
117
+ else groups['Older'].push(c)
118
+ })
119
+
120
+ return (
121
+ <div className="flex flex-col h-full bg-surface-1 w-64 border-r border-white/5">
122
+ {/* Logo */}
123
+ <div className="px-4 pt-5 pb-3 flex items-center gap-2.5">
124
+ <div className="w-7 h-7 rounded-lg bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0">
125
+ <Stethoscope size={14} className="text-accent" />
126
+ </div>
127
+ <span className="font-bold text-white text-sm tracking-tight">MedRAG</span>
128
+ </div>
129
+
130
+ {/* New Chat button */}
131
+ <div className="px-3 mb-3">
132
+ <button
133
+ onClick={handleNew}
134
+ className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl bg-accent/10 hover:bg-accent/20 border border-accent/20 text-accent text-sm font-medium transition-all duration-150 group"
135
+ >
136
+ <Plus size={15} className="group-hover:rotate-90 transition-transform duration-200" />
137
+ New Chat
138
+ </button>
139
+ </div>
140
+
141
+ {/* Search */}
142
+ <div className="px-3 mb-2">
143
+ <div className="flex items-center gap-2 bg-surface-3 rounded-lg px-3 py-2 border border-white/5">
144
+ <Search size={13} className="text-gray-500 shrink-0" />
145
+ <input
146
+ value={search}
147
+ onChange={e => setSearch(e.target.value)}
148
+ placeholder="Search chats…"
149
+ className="bg-transparent text-sm text-white placeholder-gray-600 outline-none flex-1 min-w-0"
150
+ />
151
+ </div>
152
+ </div>
153
+
154
+ {/* Conversation list */}
155
+ <div className="flex-1 overflow-y-auto px-2 pb-2 space-y-4">
156
+ {Object.entries(groups).map(([label, convs]) =>
157
+ convs.length > 0 && (
158
+ <div key={label}>
159
+ <p className="px-2 py-1 text-[10px] font-semibold text-gray-600 uppercase tracking-wider">{label}</p>
160
+ {convs.map(c => (
161
+ <ConvItem
162
+ key={c.id}
163
+ conv={c}
164
+ isActive={c.id === activeConvId}
165
+ onSelect={selectConversation}
166
+ onDelete={handleDelete}
167
+ onRename={handleRename}
168
+ />
169
+ ))}
170
+ </div>
171
+ )
172
+ )}
173
+ {filtered.length === 0 && (
174
+ <p className="text-center text-gray-600 text-xs py-8">
175
+ {search ? 'No chats found' : 'No conversations yet'}
176
+ </p>
177
+ )}
178
+ </div>
179
+
180
+ {/* Bottom user area */}
181
+ <div className="border-t border-white/5 p-3 space-y-1">
182
+ {user?.role === 'admin' && (
183
+ <button
184
+ onClick={() => navigate('/admin')}
185
+ className="sidebar-item w-full text-med-purple"
186
+ >
187
+ <Shield size={14} className="shrink-0" />
188
+ Admin Dashboard
189
+ <ChevronRight size={12} className="ml-auto" />
190
+ </button>
191
+ )}
192
+ <div className="sidebar-item">
193
+ <div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center text-accent text-xs font-bold shrink-0">
194
+ {user?.full_name?.[0]?.toUpperCase() ?? 'U'}
195
+ </div>
196
+ <div className="flex-1 min-w-0">
197
+ <p className="text-xs text-white font-medium truncate">{user?.full_name}</p>
198
+ <p className="text-[10px] text-gray-500 truncate">{user?.email}</p>
199
+ </div>
200
+ <button
201
+ onClick={logout}
202
+ className="text-gray-500 hover:text-red-400 p-1 rounded shrink-0"
203
+ title="Sign out"
204
+ >
205
+ <LogOut size={13} />
206
+ </button>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ )
211
+ }
frontend/src/components/ui/ProtectedRoute.jsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Navigate } from 'react-router-dom'
2
+ import { useAuthStore } from '../../store'
3
+
4
+ export function RequireAuth({ children }) {
5
+ const { isAuthenticated } = useAuthStore()
6
+ if (!isAuthenticated) return <Navigate to="/login" replace />
7
+ return children
8
+ }
9
+
10
+ export function RequireAdmin({ children }) {
11
+ const { isAuthenticated, user } = useAuthStore()
12
+ if (!isAuthenticated) return <Navigate to="/login" replace />
13
+ if (user?.role !== 'admin') return <Navigate to="/" replace />
14
+ return children
15
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ * { box-sizing: border-box; }
7
+
8
+ html, body, #root {
9
+ height: 100%;
10
+ margin: 0;
11
+ padding: 0;
12
+ background: #0a0a0f;
13
+ color: #e8e8f0;
14
+ font-family: 'Inter', system-ui, sans-serif;
15
+ -webkit-font-smoothing: antialiased;
16
+ }
17
+
18
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
19
+ ::-webkit-scrollbar-track { background: transparent; }
20
+ ::-webkit-scrollbar-thumb { background: #2a2a38; border-radius: 4px; }
21
+ ::-webkit-scrollbar-thumb:hover { background: #3b3b50; }
22
+
23
+ ::selection { background: rgba(59,127,255,0.3); }
24
+ }
25
+
26
+ @layer components {
27
+ .btn-primary {
28
+ @apply bg-accent hover:bg-accent-hover text-white font-medium px-4 py-2 rounded-lg
29
+ transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed
30
+ active:scale-95;
31
+ }
32
+
33
+ .btn-ghost {
34
+ @apply text-gray-400 hover:text-white hover:bg-surface-3 px-3 py-2 rounded-lg
35
+ transition-all duration-150 active:scale-95;
36
+ }
37
+
38
+ .input-field {
39
+ @apply bg-surface-3 border border-white/10 rounded-xl px-4 py-3 text-white
40
+ placeholder-gray-500 focus:outline-none focus:border-accent/60 focus:ring-1
41
+ focus:ring-accent/30 transition-all duration-150 w-full;
42
+ }
43
+
44
+ .sidebar-item {
45
+ @apply flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-400
46
+ hover:text-white hover:bg-surface-3 cursor-pointer transition-all duration-150
47
+ truncate;
48
+ }
49
+
50
+ .sidebar-item.active {
51
+ @apply bg-surface-3 text-white;
52
+ }
53
+
54
+ .glass-card {
55
+ @apply bg-surface-2/80 backdrop-blur-sm border border-white/5 rounded-2xl;
56
+ }
57
+ }
58
+
59
+ /* Typing animation dots */
60
+ .typing-dot {
61
+ width: 6px; height: 6px;
62
+ border-radius: 50%;
63
+ background: #3b7fff;
64
+ display: inline-block;
65
+ animation: pulseDot 1.4s ease-in-out infinite;
66
+ }
67
+ .typing-dot:nth-child(2) { animation-delay: 0.2s; }
68
+ .typing-dot:nth-child(3) { animation-delay: 0.4s; }
69
+
70
+ @keyframes pulseDot {
71
+ 0%, 80%, 100% { transform: scale(0.4); opacity: 0.4; }
72
+ 40% { transform: scale(1); opacity: 1; }
73
+ }
74
+
75
+ /* Markdown prose overrides for dark theme */
76
+ .prose-dark { color: #d4d4e8; }
77
+ .prose-dark h1,.prose-dark h2,.prose-dark h3 { color: #fff; }
78
+ .prose-dark strong { color: #fff; }
79
+ .prose-dark code {
80
+ background: #1f1f28; color: #00c9a7;
81
+ padding: 2px 6px; border-radius: 4px;
82
+ font-family: 'JetBrains Mono', monospace;
83
+ font-size: 0.85em;
84
+ }
85
+ .prose-dark pre {
86
+ background: #111118; border: 1px solid rgba(255,255,255,0.06);
87
+ border-radius: 10px; padding: 16px; overflow-x: auto;
88
+ }
89
+ .prose-dark a { color: #3b7fff; }
90
+ .prose-dark ul { list-style: disc; padding-left: 1.4em; }
91
+ .prose-dark ol { list-style: decimal; padding-left: 1.4em; }
92
+ .prose-dark li { margin: 4px 0; }
93
+ .prose-dark blockquote {
94
+ border-left: 3px solid #3b7fff; padding-left: 12px;
95
+ color: #9999b8; margin: 12px 0;
96
+ }
97
+ .prose-dark hr { border-color: rgba(255,255,255,0.08); margin: 20px 0; }
frontend/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>
10
+ )
frontend/src/pages/AdminPage.jsx ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { motion } from 'framer-motion'
4
+ import { adminApi, docsApi } from '../api/client'
5
+ import { useAuthStore } from '../store'
6
+ import {
7
+ Users, FileText, Database, Activity, ArrowLeft,
8
+ Shield, ShieldOff, UserCheck, UserX, Trash2,
9
+ RefreshCw, CheckCircle, XCircle, Clock
10
+ } from 'lucide-react'
11
+ import toast from 'react-hot-toast'
12
+ import clsx from 'clsx'
13
+
14
+ function StatCard({ label, value, icon: Icon, color = 'accent' }) {
15
+ const colors = {
16
+ accent: 'bg-accent/10 text-accent border-accent/20',
17
+ teal: 'bg-med-teal/10 text-med-teal border-med-teal/20',
18
+ purple: 'bg-med-purple/10 text-med-purple border-med-purple/20',
19
+ yellow: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
20
+ }
21
+ return (
22
+ <div className="glass-card p-5 flex items-center gap-4">
23
+ <div className={clsx('w-10 h-10 rounded-xl flex items-center justify-center border', colors[color])}>
24
+ <Icon size={18} />
25
+ </div>
26
+ <div>
27
+ <p className="text-2xl font-bold text-white">{value ?? 'β€”'}</p>
28
+ <p className="text-xs text-gray-500">{label}</p>
29
+ </div>
30
+ </div>
31
+ )
32
+ }
33
+
34
+ function StatusBadge({ status }) {
35
+ const map = {
36
+ ready: { icon: CheckCircle, cls: 'text-med-teal bg-med-teal/10', label: 'Ready' },
37
+ processing: { icon: Clock, cls: 'text-yellow-400 bg-yellow-500/10', label: 'Processing' },
38
+ error: { icon: XCircle, cls: 'text-red-400 bg-red-500/10', label: 'Error' },
39
+ }
40
+ const { icon: Icon, cls, label } = map[status] || map.error
41
+ return (
42
+ <span className={clsx('flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium', cls)}>
43
+ <Icon size={10} />
44
+ {label}
45
+ </span>
46
+ )
47
+ }
48
+
49
+ export default function AdminPage() {
50
+ const [stats, setStats] = useState(null)
51
+ const [users, setUsers] = useState([])
52
+ const [docs, setDocs] = useState([])
53
+ const [tab, setTab] = useState('overview')
54
+ const [loading, setLoading] = useState(true)
55
+ const { user } = useAuthStore()
56
+ const navigate = useNavigate()
57
+
58
+ const load = async () => {
59
+ setLoading(true)
60
+ try {
61
+ const [s, u, d] = await Promise.all([adminApi.stats(), adminApi.users(), docsApi.list()])
62
+ setStats(s); setUsers(u); setDocs(d)
63
+ } catch { toast.error('Failed to load data') }
64
+ finally { setLoading(false) }
65
+ }
66
+
67
+ useEffect(() => { load() }, [])
68
+
69
+ const toggleRole = async (u) => {
70
+ const newRole = u.role === 'admin' ? 'user' : 'admin'
71
+ await adminApi.setRole(u.id, newRole)
72
+ setUsers(prev => prev.map(x => x.id === u.id ? { ...x, role: newRole } : x))
73
+ toast.success(`${u.full_name} is now ${newRole}`)
74
+ }
75
+
76
+ const toggleActive = async (u) => {
77
+ await adminApi.toggle(u.id)
78
+ setUsers(prev => prev.map(x => x.id === u.id ? { ...x, is_active: !x.is_active } : x))
79
+ }
80
+
81
+ const deleteDoc = async (id, title) => {
82
+ if (!confirm(`Delete "${title}"? This will remove all indexed vectors.`)) return
83
+ await docsApi.delete(id)
84
+ setDocs(prev => prev.filter(d => d.id !== id))
85
+ setStats(s => ({ ...s, total_documents: s.total_documents - 1 }))
86
+ toast.success('Document deleted')
87
+ }
88
+
89
+ const TABS = ['overview', 'users', 'documents']
90
+
91
+ return (
92
+ <div className="min-h-screen bg-surface-0 text-white">
93
+ {/* Header */}
94
+ <div className="border-b border-white/5 bg-surface-1/60 backdrop-blur-sm sticky top-0 z-10">
95
+ <div className="max-w-6xl mx-auto px-6 py-4 flex items-center gap-4">
96
+ <button onClick={() => navigate('/')} className="btn-ghost p-2 -ml-2">
97
+ <ArrowLeft size={16} />
98
+ </button>
99
+ <div className="flex items-center gap-2">
100
+ <Shield size={16} className="text-med-purple" />
101
+ <h1 className="font-semibold text-white">Admin Dashboard</h1>
102
+ </div>
103
+ <div className="flex-1" />
104
+ <button onClick={load} className="btn-ghost p-2" title="Refresh">
105
+ <RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
106
+ </button>
107
+ </div>
108
+ {/* Tabs */}
109
+ <div className="max-w-6xl mx-auto px-6 flex gap-1 pb-0">
110
+ {TABS.map(t => (
111
+ <button
112
+ key={t}
113
+ onClick={() => setTab(t)}
114
+ className={clsx(
115
+ 'px-4 py-2 text-sm font-medium capitalize border-b-2 transition-colors',
116
+ tab === t
117
+ ? 'border-accent text-accent'
118
+ : 'border-transparent text-gray-500 hover:text-gray-300'
119
+ )}
120
+ >
121
+ {t}
122
+ </button>
123
+ ))}
124
+ </div>
125
+ </div>
126
+
127
+ <div className="max-w-6xl mx-auto px-6 py-8">
128
+ {/* ── Overview tab ── */}
129
+ {tab === 'overview' && (
130
+ <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="space-y-6">
131
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
132
+ <StatCard label="Total Users" value={stats?.total_users} icon={Users} color="accent" />
133
+ <StatCard label="Documents" value={stats?.total_documents} icon={FileText} color="teal" />
134
+ <StatCard label="Chunks Indexed" value={stats?.total_chunks_indexed?.toLocaleString()} icon={Database} color="purple" />
135
+ <StatCard label="Qdrant Status" value={stats?.qdrant_status === 'ok' ? 'Healthy' : 'Error'} icon={Activity} color={stats?.qdrant_status === 'ok' ? 'teal' : 'yellow'} />
136
+ </div>
137
+
138
+ <div className="glass-card p-5">
139
+ <h3 className="text-sm font-semibold text-white mb-4">System Information</h3>
140
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
141
+ {[
142
+ ['LLM Model', 'Llama 4 Scout 17B (Groq)'],
143
+ ['Embedding Model', 'PubMedBERT'],
144
+ ['Reranker', 'ms-marco-MiniLM-L-6-v2'],
145
+ ['Vector Store', 'Qdrant (local path)'],
146
+ ['Chunk Size', '512 tokens / 64 overlap'],
147
+ ['Auth', 'JWT (access + refresh)'],
148
+ ].map(([k, v]) => (
149
+ <div key={k} className="flex items-center justify-between py-2 border-b border-white/5">
150
+ <span className="text-gray-500">{k}</span>
151
+ <span className="text-white font-mono text-xs bg-surface-3 px-2 py-0.5 rounded">{v}</span>
152
+ </div>
153
+ ))}
154
+ </div>
155
+ </div>
156
+ </motion.div>
157
+ )}
158
+
159
+ {/* ── Users tab ── */}
160
+ {tab === 'users' && (
161
+ <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}>
162
+ <div className="glass-card overflow-hidden">
163
+ <table className="w-full text-sm">
164
+ <thead>
165
+ <tr className="border-b border-white/5 text-xs text-gray-500 uppercase tracking-wide">
166
+ {['Name', 'Email', 'Role', 'Status', 'Joined', 'Actions'].map(h => (
167
+ <th key={h} className="text-left px-4 py-3 font-medium">{h}</th>
168
+ ))}
169
+ </tr>
170
+ </thead>
171
+ <tbody>
172
+ {users.map((u, i) => (
173
+ <tr key={u.id} className={clsx('border-b border-white/5 hover:bg-white/2 transition-colors', i === users.length - 1 && 'border-b-0')}>
174
+ <td className="px-4 py-3">
175
+ <div className="flex items-center gap-2">
176
+ <div className="w-7 h-7 rounded-full bg-accent/20 flex items-center justify-center text-accent text-xs font-bold">
177
+ {u.full_name[0].toUpperCase()}
178
+ </div>
179
+ <span className="text-white font-medium truncate max-w-[140px]">{u.full_name}</span>
180
+ </div>
181
+ </td>
182
+ <td className="px-4 py-3 text-gray-400 text-xs">{u.email}</td>
183
+ <td className="px-4 py-3">
184
+ <span className={clsx('px-2 py-0.5 rounded-full text-xs font-medium',
185
+ u.role === 'admin' ? 'bg-med-purple/15 text-med-purple' : 'bg-white/5 text-gray-400'
186
+ )}>
187
+ {u.role}
188
+ </span>
189
+ </td>
190
+ <td className="px-4 py-3">
191
+ <span className={clsx('px-2 py-0.5 rounded-full text-xs',
192
+ u.is_active ? 'bg-med-teal/10 text-med-teal' : 'bg-red-500/10 text-red-400'
193
+ )}>
194
+ {u.is_active ? 'Active' : 'Disabled'}
195
+ </span>
196
+ </td>
197
+ <td className="px-4 py-3 text-gray-500 text-xs">
198
+ {new Date(u.created_at).toLocaleDateString()}
199
+ </td>
200
+ <td className="px-4 py-3">
201
+ {u.id !== user?.id && (
202
+ <div className="flex items-center gap-1">
203
+ <button
204
+ onClick={() => toggleRole(u)}
205
+ title={u.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
206
+ className="p-1.5 hover:bg-white/5 rounded text-gray-500 hover:text-med-purple transition-colors"
207
+ >
208
+ {u.role === 'admin' ? <ShieldOff size={13} /> : <Shield size={13} />}
209
+ </button>
210
+ <button
211
+ onClick={() => toggleActive(u)}
212
+ title={u.is_active ? 'Disable user' : 'Enable user'}
213
+ className="p-1.5 hover:bg-white/5 rounded text-gray-500 hover:text-yellow-400 transition-colors"
214
+ >
215
+ {u.is_active ? <UserX size={13} /> : <UserCheck size={13} />}
216
+ </button>
217
+ </div>
218
+ )}
219
+ </td>
220
+ </tr>
221
+ ))}
222
+ </tbody>
223
+ </table>
224
+ </div>
225
+ </motion.div>
226
+ )}
227
+
228
+ {/* ── Documents tab ── */}
229
+ {tab === 'documents' && (
230
+ <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}>
231
+ <div className="glass-card overflow-hidden">
232
+ <table className="w-full text-sm">
233
+ <thead>
234
+ <tr className="border-b border-white/5 text-xs text-gray-500 uppercase tracking-wide">
235
+ {['Title', 'File', 'Chunks', 'Status', 'Uploaded', 'Actions'].map(h => (
236
+ <th key={h} className="text-left px-4 py-3 font-medium">{h}</th>
237
+ ))}
238
+ </tr>
239
+ </thead>
240
+ <tbody>
241
+ {docs.length === 0 && (
242
+ <tr>
243
+ <td colSpan={6} className="text-center text-gray-600 py-12 text-sm">
244
+ No documents ingested yet. Upload via the chat interface.
245
+ </td>
246
+ </tr>
247
+ )}
248
+ {docs.map((d, i) => (
249
+ <tr key={d.id} className={clsx('border-b border-white/5 hover:bg-white/2 transition-colors', i === docs.length - 1 && 'border-b-0')}>
250
+ <td className="px-4 py-3 text-white font-medium max-w-[200px] truncate">{d.title}</td>
251
+ <td className="px-4 py-3 text-gray-400 text-xs font-mono max-w-[160px] truncate">{d.filename}</td>
252
+ <td className="px-4 py-3 text-gray-300">{d.chunk_count.toLocaleString()}</td>
253
+ <td className="px-4 py-3"><StatusBadge status={d.status} /></td>
254
+ <td className="px-4 py-3 text-gray-500 text-xs">{new Date(d.created_at).toLocaleDateString()}</td>
255
+ <td className="px-4 py-3">
256
+ <button
257
+ onClick={() => deleteDoc(d.id, d.title)}
258
+ className="p-1.5 hover:bg-red-500/10 rounded text-gray-500 hover:text-red-400 transition-colors"
259
+ >
260
+ <Trash2 size={13} />
261
+ </button>
262
+ </td>
263
+ </tr>
264
+ ))}
265
+ </tbody>
266
+ </table>
267
+ </div>
268
+ </motion.div>
269
+ )}
270
+ </div>
271
+ </div>
272
+ )
273
+ }
frontend/src/pages/AuthPage.jsx ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { motion, AnimatePresence } from 'framer-motion'
4
+ import { useAuthStore } from '../store'
5
+ import toast from 'react-hot-toast'
6
+ import { Activity, Eye, EyeOff, Stethoscope } from 'lucide-react'
7
+ import { getApiErrorMessage } from '../api/client'
8
+
9
+ export default function AuthPage() {
10
+ const [mode, setMode] = useState('login') // 'login' | 'register'
11
+ const [form, setForm] = useState({ email: '', password: '', full_name: '' })
12
+ const [showPw, setShowPw] = useState(false)
13
+ const [loading, setLoading] = useState(false)
14
+ const { login, register } = useAuthStore()
15
+ const navigate = useNavigate()
16
+
17
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
18
+
19
+ const submit = async (e) => {
20
+ e.preventDefault()
21
+ setLoading(true)
22
+ try {
23
+ if (mode === 'login') {
24
+ await login(form.email, form.password)
25
+ } else {
26
+ await register(form.email, form.password, form.full_name)
27
+ }
28
+ toast.success(mode === 'login' ? 'Welcome back!' : 'Account created!')
29
+ navigate('/')
30
+ } catch (err) {
31
+ toast.error(getApiErrorMessage(err))
32
+ } finally {
33
+ setLoading(false)
34
+ }
35
+ }
36
+
37
+ return (
38
+ <div className="min-h-screen bg-surface-0 flex items-center justify-center p-4 relative overflow-hidden">
39
+ {/* Background glow */}
40
+ <div className="absolute inset-0 pointer-events-none">
41
+ <div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[600px] h-[600px] bg-accent/5 rounded-full blur-[120px]" />
42
+ <div className="absolute bottom-0 right-1/4 w-[400px] h-[400px] bg-med-teal/5 rounded-full blur-[100px]" />
43
+ </div>
44
+
45
+ <motion.div
46
+ initial={{ opacity: 0, y: 24 }}
47
+ animate={{ opacity: 1, y: 0 }}
48
+ transition={{ duration: 0.4 }}
49
+ className="w-full max-w-md relative z-10"
50
+ >
51
+ {/* Logo */}
52
+ <div className="text-center mb-8">
53
+ <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-accent/10 border border-accent/20 mb-4">
54
+ <Stethoscope className="w-8 h-8 text-accent" />
55
+ </div>
56
+ <h1 className="text-2xl font-bold text-white">MedRAG</h1>
57
+ <p className="text-gray-500 text-sm mt-1">Medical Knowledge Intelligence</p>
58
+ </div>
59
+
60
+ {/* Card */}
61
+ <div className="glass-card p-8">
62
+ {/* Tab switcher */}
63
+ <div className="flex bg-surface-0 rounded-lg p-1 mb-6">
64
+ {['login', 'register'].map(m => (
65
+ <button
66
+ key={m}
67
+ onClick={() => setMode(m)}
68
+ className={`flex-1 py-2 text-sm font-medium rounded-md transition-all duration-200 capitalize ${
69
+ mode === m ? 'bg-accent text-white shadow-sm' : 'text-gray-400 hover:text-white'
70
+ }`}
71
+ >
72
+ {m === 'login' ? 'Sign In' : 'Create Account'}
73
+ </button>
74
+ ))}
75
+ </div>
76
+
77
+ <form onSubmit={submit} className="space-y-4">
78
+ <AnimatePresence mode="wait">
79
+ {mode === 'register' && (
80
+ <motion.div
81
+ key="name"
82
+ initial={{ opacity: 0, height: 0 }}
83
+ animate={{ opacity: 1, height: 'auto' }}
84
+ exit={{ opacity: 0, height: 0 }}
85
+ transition={{ duration: 0.2 }}
86
+ >
87
+ <label className="block text-xs text-gray-400 mb-1.5 font-medium">Full Name</label>
88
+ <input
89
+ className="input-field"
90
+ placeholder="Dr. Jane Smith"
91
+ value={form.full_name}
92
+ onChange={e => set('full_name', e.target.value)}
93
+ required={mode === 'register'}
94
+ />
95
+ </motion.div>
96
+ )}
97
+ </AnimatePresence>
98
+
99
+ <div>
100
+ <label className="block text-xs text-gray-400 mb-1.5 font-medium">Email</label>
101
+ <input
102
+ className="input-field"
103
+ type="email"
104
+ placeholder="you@hospital.com"
105
+ value={form.email}
106
+ onChange={e => set('email', e.target.value)}
107
+ required
108
+ />
109
+ </div>
110
+
111
+ <div>
112
+ <label className="block text-xs text-gray-400 mb-1.5 font-medium">Password</label>
113
+ <div className="relative">
114
+ <input
115
+ className="input-field pr-10"
116
+ type={showPw ? 'text' : 'password'}
117
+ placeholder={mode === 'register' ? 'Min. 8 characters' : 'β€’β€’β€’β€’β€’β€’β€’β€’'}
118
+ value={form.password}
119
+ onChange={e => set('password', e.target.value)}
120
+ required
121
+ minLength={mode === 'register' ? 8 : undefined}
122
+ />
123
+ <button
124
+ type="button"
125
+ onClick={() => setShowPw(p => !p)}
126
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
127
+ >
128
+ {showPw ? <EyeOff size={16} /> : <Eye size={16} />}
129
+ </button>
130
+ </div>
131
+ </div>
132
+
133
+ <button
134
+ type="submit"
135
+ disabled={loading}
136
+ className="btn-primary w-full mt-2 py-3 text-sm font-semibold"
137
+ >
138
+ {loading
139
+ ? <span className="flex items-center justify-center gap-2"><Activity size={15} className="animate-spin" /> Processing…</span>
140
+ : mode === 'login' ? 'Sign In' : 'Create Account'
141
+ }
142
+ </button>
143
+ </form>
144
+ </div>
145
+
146
+ <p className="text-center text-xs text-gray-600 mt-6">
147
+ Medical AI assistant Β· Not a substitute for professional medical advice
148
+ </p>
149
+ </motion.div>
150
+ </div>
151
+ )
152
+ }
frontend/src/pages/ChatPage.jsx ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { motion, AnimatePresence } from 'framer-motion'
3
+ import { docsApi } from '../api/client'
4
+ import { useChatStore } from '../store'
5
+ import Sidebar from '../components/layout/Sidebar'
6
+ import MessageBubble from '../components/chat/MessageBubble'
7
+ import ChatInput from '../components/chat/ChatInput'
8
+ import WelcomeScreen from '../components/chat/WelcomeScreen'
9
+ import UploadModal from '../components/chat/UploadModal'
10
+ import { Menu, Trash2, X } from 'lucide-react'
11
+ import toast from 'react-hot-toast'
12
+
13
+ export default function ChatPage() {
14
+ const {
15
+ messages, isLoading, isFetchingMsgs,
16
+ activeConvId, createConversation, sendMessage,
17
+ } = useChatStore()
18
+ const [sidebarOpen, setSidebarOpen] = useState(true)
19
+ const [uploadOpen, setUploadOpen] = useState(false)
20
+ const [pendingIngestionCount, setPendingIngestionCount] = useState(0)
21
+ const [readyDocumentCount, setReadyDocumentCount] = useState(0)
22
+ const bottomRef = useRef()
23
+
24
+ useEffect(() => {
25
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
26
+ }, [messages, isLoading])
27
+
28
+ useEffect(() => {
29
+ let cancelled = false
30
+ let intervalId = null
31
+
32
+ const loadDocumentStatus = async () => {
33
+ try {
34
+ if (!activeConvId) {
35
+ setPendingIngestionCount(0)
36
+ setReadyDocumentCount(0)
37
+ return
38
+ }
39
+ const docs = await docsApi.list(activeConvId)
40
+ if (cancelled) return
41
+ const pending = docs.filter((doc) => doc.status === 'processing').length
42
+ const ready = docs.filter((doc) => doc.status === 'ready').length
43
+ setPendingIngestionCount(pending)
44
+ setReadyDocumentCount(ready)
45
+ } catch {
46
+ if (!cancelled) {
47
+ setPendingIngestionCount(0)
48
+ setReadyDocumentCount(0)
49
+ }
50
+ }
51
+ }
52
+
53
+ loadDocumentStatus()
54
+ intervalId = window.setInterval(loadDocumentStatus, 3000)
55
+
56
+ return () => {
57
+ cancelled = true
58
+ if (intervalId) window.clearInterval(intervalId)
59
+ }
60
+ }, [activeConvId])
61
+
62
+ const handleSend = async (query) => {
63
+ if (pendingIngestionCount > 0) {
64
+ toast.error('Please wait until document ingestion and embeddings finish')
65
+ return
66
+ }
67
+ if (activeConvId && readyDocumentCount === 0) {
68
+ toast.error('Upload documents to this chat first')
69
+ return
70
+ }
71
+
72
+ let convId = activeConvId
73
+ if (!convId) {
74
+ const conv = await createConversation()
75
+ convId = conv.id
76
+ }
77
+ try {
78
+ await sendMessage(query)
79
+ } catch (err) {
80
+ toast.error(err.response?.data?.detail || 'Failed to get a response')
81
+ }
82
+ }
83
+
84
+ const handleSuggest = async (text) => {
85
+ if (pendingIngestionCount > 0) return
86
+ await handleSend(text)
87
+ }
88
+
89
+ const handleUploadOpen = async () => {
90
+ if (pendingIngestionCount > 0) {
91
+ toast.error('Wait for the current document ingestion to finish first')
92
+ return
93
+ }
94
+ if (!activeConvId) {
95
+ const conv = await createConversation()
96
+ if (!conv?.id) {
97
+ toast.error('Could not create a conversation for document upload')
98
+ return
99
+ }
100
+ }
101
+ setUploadOpen(true)
102
+ }
103
+
104
+ const handleClearDocuments = async () => {
105
+ if (!activeConvId) return
106
+ if (!window.confirm('Clear all uploaded documents for this chat?')) return
107
+ try {
108
+ await docsApi.clearConversation(activeConvId)
109
+ setPendingIngestionCount(0)
110
+ setReadyDocumentCount(0)
111
+ toast.success('Cleared documents for this chat')
112
+ } catch (err) {
113
+ toast.error(err.response?.data?.detail || 'Failed to clear chat documents')
114
+ }
115
+ }
116
+
117
+ return (
118
+ <div className="flex h-screen bg-surface-0 overflow-hidden">
119
+ <AnimatePresence initial={false}>
120
+ {sidebarOpen && (
121
+ <motion.div
122
+ initial={{ width: 0, opacity: 0 }}
123
+ animate={{ width: 256, opacity: 1 }}
124
+ exit={{ width: 0, opacity: 0 }}
125
+ transition={{ duration: 0.2, ease: 'easeInOut' }}
126
+ className="overflow-hidden shrink-0"
127
+ >
128
+ <Sidebar onClose={() => setSidebarOpen(false)} />
129
+ </motion.div>
130
+ )}
131
+ </AnimatePresence>
132
+
133
+ <div className="flex flex-col flex-1 min-w-0">
134
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-white/5 bg-surface-1/50 backdrop-blur-sm shrink-0">
135
+ <button
136
+ onClick={() => setSidebarOpen(s => !s)}
137
+ className="btn-ghost p-2"
138
+ >
139
+ {sidebarOpen ? <X size={16} /> : <Menu size={16} />}
140
+ </button>
141
+ <div className="flex-1 min-w-0">
142
+ <h1 className="text-sm font-medium text-white truncate">
143
+ {activeConvId
144
+ ? useChatStore.getState().conversations.find(c => c.id === activeConvId)?.title || 'Chat'
145
+ : 'MedRAG Assistant'
146
+ }
147
+ </h1>
148
+ </div>
149
+ {activeConvId && (
150
+ <button
151
+ onClick={handleClearDocuments}
152
+ className="btn-ghost p-2 text-gray-500 hover:text-red-400"
153
+ title="Clear documents for this chat"
154
+ >
155
+ <Trash2 size={14} />
156
+ </button>
157
+ )}
158
+ <div className="text-xs text-gray-600 hidden sm:block">
159
+ MedRAG by HET SHETA
160
+ </div>
161
+ </div>
162
+
163
+ <div className="flex-1 overflow-y-auto">
164
+ {isFetchingMsgs ? (
165
+ <div className="flex items-center justify-center h-full">
166
+ <div className="flex gap-1">
167
+ <span className="typing-dot" />
168
+ <span className="typing-dot" />
169
+ <span className="typing-dot" />
170
+ </div>
171
+ </div>
172
+ ) : messages.length === 0 ? (
173
+ <WelcomeScreen onSuggest={handleSuggest} />
174
+ ) : (
175
+ <div className="max-w-3xl mx-auto px-4 py-6 space-y-6">
176
+ {messages.map((msg) => (
177
+ <MessageBubble key={msg.id} message={msg} />
178
+ ))}
179
+
180
+ {isLoading && (
181
+ <MessageBubble
182
+ message={{ id: 'loading', role: 'assistant', content: '', sources: null }}
183
+ isStreaming
184
+ />
185
+ )}
186
+ <div ref={bottomRef} />
187
+ </div>
188
+ )}
189
+ </div>
190
+
191
+ <div className="shrink-0 px-4 pb-4 pt-2 bg-gradient-to-t from-surface-0 via-surface-0/90 to-transparent">
192
+ <div className="max-w-3xl mx-auto">
193
+ <ChatInput
194
+ onSend={handleSend}
195
+ onUploadClick={handleUploadOpen}
196
+ isLoading={isLoading}
197
+ disabled={pendingIngestionCount > 0}
198
+ disabledReason={
199
+ pendingIngestionCount > 0
200
+ ? `Waiting for ${pendingIngestionCount} uploaded document${pendingIngestionCount !== 1 ? 's' : ''} to finish ingestion and embeddings`
201
+ : ''
202
+ }
203
+ />
204
+ <p className="text-center text-[10px] text-gray-700 mt-2">
205
+ MedRAG can make mistakes. Always verify medical information with a licensed professional.
206
+ </p>
207
+ </div>
208
+ </div>
209
+ </div>
210
+
211
+ {uploadOpen && <UploadModal conversationId={activeConvId} onClose={() => setUploadOpen(false)} />}
212
+ </div>
213
+ )
214
+ }
frontend/src/store/index.js ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from 'zustand'
2
+ import { persist } from 'zustand/middleware'
3
+ import { authApi, convApi } from '../api/client'
4
+
5
+ // ─── Auth store ───────────────────────────────────────────────────────────────
6
+ export const useAuthStore = create(
7
+ persist(
8
+ (set, get) => ({
9
+ user: null,
10
+ isAuthenticated: false,
11
+
12
+ login: async (email, password) => {
13
+ const tokens = await authApi.login({ email, password })
14
+ localStorage.setItem('access_token', tokens.access_token)
15
+ localStorage.setItem('refresh_token', tokens.refresh_token)
16
+ const user = await authApi.me()
17
+ set({ user, isAuthenticated: true })
18
+ return user
19
+ },
20
+
21
+ register: async (email, password, full_name) => {
22
+ await authApi.register({ email, password, full_name })
23
+ return get().login(email, password)
24
+ },
25
+
26
+ logout: () => {
27
+ localStorage.removeItem('access_token')
28
+ localStorage.removeItem('refresh_token')
29
+ set({ user: null, isAuthenticated: false })
30
+ },
31
+
32
+ loadMe: async () => {
33
+ try {
34
+ const user = await authApi.me()
35
+ set({ user, isAuthenticated: true })
36
+ } catch {
37
+ get().logout()
38
+ }
39
+ },
40
+ }),
41
+ { name: 'auth-store', partialize: (s) => ({ user: s.user, isAuthenticated: s.isAuthenticated }) }
42
+ )
43
+ )
44
+
45
+ // ─── Chat store ───────────────────────────────────────────────────────────────
46
+ export const useChatStore = create((set, get) => ({
47
+ conversations: [], // sidebar list
48
+ activeConvId: null,
49
+ messages: [], // messages of active conversation
50
+ isLoading: false, // waiting for RAG response
51
+ isFetchingMsgs: false,
52
+
53
+ fetchConversations: async () => {
54
+ try {
55
+ const convs = await convApi.list()
56
+ set({ conversations: convs })
57
+ } catch {}
58
+ },
59
+
60
+ createConversation: async () => {
61
+ const conv = await convApi.create('New Chat')
62
+ set((s) => ({ conversations: [conv, ...s.conversations], activeConvId: conv.id, messages: [] }))
63
+ return conv
64
+ },
65
+
66
+ selectConversation: async (id) => {
67
+ if (get().activeConvId === id) return
68
+ set({ activeConvId: id, messages: [], isFetchingMsgs: true })
69
+ try {
70
+ const msgs = await convApi.messages(id)
71
+ set({ messages: msgs })
72
+ } finally {
73
+ set({ isFetchingMsgs: false })
74
+ }
75
+ },
76
+
77
+ deleteConversation: async (id) => {
78
+ await convApi.delete(id)
79
+ const convs = get().conversations.filter(c => c.id !== id)
80
+ const activeConvId = get().activeConvId === id
81
+ ? (convs[0]?.id ?? null)
82
+ : get().activeConvId
83
+ set({ conversations: convs, activeConvId })
84
+ if (activeConvId && activeConvId !== get().activeConvId) {
85
+ await get().selectConversation(activeConvId)
86
+ } else if (!activeConvId) {
87
+ set({ messages: [] })
88
+ }
89
+ },
90
+
91
+ renameConversation: async (id, title) => {
92
+ const updated = await convApi.rename(id, title)
93
+ set((s) => ({
94
+ conversations: s.conversations.map(c => c.id === id ? { ...c, title: updated.title } : c)
95
+ }))
96
+ },
97
+
98
+ // Optimistically add user message, then add assistant response
99
+ sendMessage: async (query, topK = 5) => {
100
+ const { activeConvId } = get()
101
+ if (!activeConvId) return
102
+
103
+ // Optimistic user bubble
104
+ const tmpUserMsg = { id: `tmp-u-${Date.now()}`, role: 'user', content: query, conversation_id: activeConvId, created_at: new Date().toISOString(), sources: null }
105
+ set((s) => ({ messages: [...s.messages, tmpUserMsg], isLoading: true }))
106
+
107
+ try {
108
+ const { queryApi } = await import('../api/client')
109
+ const result = await queryApi.ask({ query, conversation_id: activeConvId, top_k: topK })
110
+
111
+ const assistantMsg = {
112
+ id: result.message_id,
113
+ role: 'assistant',
114
+ content: result.answer,
115
+ sources: result.sources,
116
+ conversation_id: activeConvId,
117
+ created_at: new Date().toISOString(),
118
+ meta: {
119
+ retrieval_ms: result.retrieval_ms,
120
+ rerank_ms: result.rerank_ms,
121
+ generation_ms: result.generation_ms,
122
+ model: result.model,
123
+ source_count: result.sources?.length || 0,
124
+ search_query: result.search_query,
125
+ retrieval_strategy: result.retrieval_strategy,
126
+ conversation_turns_used: result.conversation_turns_used,
127
+ },
128
+ }
129
+
130
+ // Replace tmp user message with real ones, update conversation title in sidebar
131
+ const msgs = await convApi.messages(activeConvId)
132
+ const convs = await convApi.list()
133
+ set({ messages: msgs, conversations: convs })
134
+
135
+ return result
136
+ } catch (err) {
137
+ // Remove optimistic message on error
138
+ set((s) => ({ messages: s.messages.filter(m => m.id !== tmpUserMsg.id) }))
139
+ throw err
140
+ } finally {
141
+ set({ isLoading: false })
142
+ }
143
+ },
144
+ }))
frontend/tailwind.config.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: ['./index.html', './src/**/*.{js,jsx}'],
4
+ darkMode: 'class',
5
+ theme: {
6
+ extend: {
7
+ fontFamily: {
8
+ sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
9
+ mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
10
+ },
11
+ colors: {
12
+ surface: {
13
+ 0: '#0a0a0f',
14
+ 1: '#111118',
15
+ 2: '#18181f',
16
+ 3: '#1f1f28',
17
+ 4: '#262630',
18
+ },
19
+ accent: {
20
+ DEFAULT: '#3b7fff',
21
+ hover: '#5a94ff',
22
+ muted: 'rgba(59,127,255,0.15)',
23
+ },
24
+ med: {
25
+ teal: '#00c9a7',
26
+ blue: '#3b7fff',
27
+ purple: '#8b5cf6',
28
+ },
29
+ },
30
+ animation: {
31
+ 'fade-in': 'fadeIn 0.2s ease-out',
32
+ 'slide-up': 'slideUp 0.25s ease-out',
33
+ 'pulse-dot': 'pulseDot 1.4s ease-in-out infinite',
34
+ },
35
+ keyframes: {
36
+ fadeIn: { from: { opacity: 0 }, to: { opacity: 1 } },
37
+ slideUp: { from: { opacity: 0, transform: 'translateY(8px)' }, to: { opacity: 1, transform: 'translateY(0)' } },
38
+ pulseDot: { '0%,80%,100%': { transform: 'scale(0)', opacity: 0.5 }, '40%': { transform: 'scale(1)', opacity: 1 } },
39
+ },
40
+ },
41
+ },
42
+ plugins: [],
43
+ }