Commit Β·
ea50fb7
0
Parent(s):
Initial commit
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .env.example +40 -0
- .gitignore +45 -0
- README.md +197 -0
- app/__init__.py +0 -0
- app/api/__init__.py +0 -0
- app/api/routes/__init__.py +0 -0
- app/api/routes/audio.py +101 -0
- app/api/routes/auth.py +71 -0
- app/api/routes/conversations.py +103 -0
- app/api/routes/documents.py +151 -0
- app/api/routes/health_admin.py +103 -0
- app/api/routes/query.py +61 -0
- app/core/__init__.py +0 -0
- app/core/config.py +67 -0
- app/core/security.py +56 -0
- app/db/__init__.py +0 -0
- app/db/chat_db.py +182 -0
- app/db/database.py +72 -0
- app/main.py +74 -0
- app/schemas/__init__.py +0 -0
- app/schemas/schemas.py +147 -0
- app/services/__init__.py +0 -0
- app/services/embedding_service.py +37 -0
- app/services/gemini_service.py +41 -0
- app/services/ingestion_service.py +396 -0
- app/services/llm_service.py +141 -0
- app/services/qdrant_service.py +184 -0
- app/services/rag_pipeline.py +226 -0
- app/services/reranker_service.py +43 -0
- app/services/speech_service.py +245 -0
- app/services/stt_service.py +161 -0
- frontend/index.html +16 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +33 -0
- frontend/postcss.config.js +3 -0
- frontend/src/App.jsx +60 -0
- frontend/src/api/client.js +104 -0
- frontend/src/components/chat/ChatInput.jsx +442 -0
- frontend/src/components/chat/MessageBubble.jsx +177 -0
- frontend/src/components/chat/UploadModal.jsx +198 -0
- frontend/src/components/chat/WelcomeScreen.jsx +45 -0
- frontend/src/components/layout/Sidebar.jsx +211 -0
- frontend/src/components/ui/ProtectedRoute.jsx +15 -0
- frontend/src/index.css +97 -0
- frontend/src/main.jsx +10 -0
- frontend/src/pages/AdminPage.jsx +273 -0
- frontend/src/pages/AuthPage.jsx +152 -0
- frontend/src/pages/ChatPage.jsx +214 -0
- frontend/src/store/index.js +144 -0
- 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 |
+
}
|