Spaces:
Sleeping
Sleeping
Commit ·
69524c2
0
Parent(s):
Initial clean commit
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +32 -0
- .env.example +58 -0
- .gitattributes +1 -0
- .gitignore +60 -0
- Dockerfile +33 -0
- README.md +70 -0
- backend/.dockerignore +33 -0
- backend/Dockerfile +33 -0
- backend/app/__init__.py +0 -0
- backend/app/api/__init__.py +0 -0
- backend/app/api/api_v1/__init__.py +0 -0
- backend/app/api/api_v1/api.py +11 -0
- backend/app/api/api_v1/endpoints/__init__.py +0 -0
- backend/app/api/api_v1/endpoints/auth.py +58 -0
- backend/app/api/api_v1/endpoints/chat.py +231 -0
- backend/app/api/api_v1/endpoints/documents.py +131 -0
- backend/app/api/deps.py +49 -0
- backend/app/core/__init__.py +0 -0
- backend/app/core/config.py +85 -0
- backend/app/core/security.py +44 -0
- backend/app/core/utils.py +19 -0
- backend/app/db/__init__.py +0 -0
- backend/app/db/base_class.py +11 -0
- backend/app/db/init_db.py +122 -0
- backend/app/db/session.py +26 -0
- backend/app/main.py +30 -0
- backend/app/models/__init__.py +0 -0
- backend/app/models/chat.py +20 -0
- backend/app/models/document.py +13 -0
- backend/app/models/user.py +10 -0
- backend/app/schemas/__init__.py +0 -0
- backend/app/schemas/chat.py +33 -0
- backend/app/schemas/document.py +16 -0
- backend/app/schemas/token.py +9 -0
- backend/app/schemas/user.py +40 -0
- backend/app/services/__init__.py +0 -0
- backend/app/services/pdf_processor.py +76 -0
- backend/app/services/rag_service.py +238 -0
- backend/app/services/tts.py +331 -0
- backend/app/services/voice_service.py +159 -0
- docker-compose.yml +84 -0
- frontend-react/.dockerignore +7 -0
- frontend-react/Dockerfile +27 -0
- frontend-react/index.html +16 -0
- frontend-react/nginx.conf +32 -0
- frontend-react/package-lock.json +0 -0
- frontend-react/package.json +31 -0
- frontend-react/src/App.css +1459 -0
- frontend-react/src/App.jsx +124 -0
- frontend-react/src/api.js +228 -0
.dockerignore
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python artefacts
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
.Python
|
| 7 |
+
*.egg-info/
|
| 8 |
+
|
| 9 |
+
# Virtual environments
|
| 10 |
+
myenv/
|
| 11 |
+
venv/
|
| 12 |
+
env/
|
| 13 |
+
|
| 14 |
+
# Node
|
| 15 |
+
frontend-react/node_modules/
|
| 16 |
+
frontend-react/dist/
|
| 17 |
+
|
| 18 |
+
# Git
|
| 19 |
+
.git/
|
| 20 |
+
.gitignore
|
| 21 |
+
|
| 22 |
+
# Local data (mounted as Docker volumes)
|
| 23 |
+
backend/qdrant_storage/
|
| 24 |
+
qdrant_storage/
|
| 25 |
+
backend/app/data/
|
| 26 |
+
|
| 27 |
+
# Dev / IDE
|
| 28 |
+
*.log
|
| 29 |
+
.pytest_cache/
|
| 30 |
+
.mypy_cache/
|
| 31 |
+
scratch/
|
| 32 |
+
README.md
|
.env.example
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =========================================================================
|
| 2 |
+
# Legal Case Law RAG - Environment Variables Template
|
| 3 |
+
# =========================================================================
|
| 4 |
+
# Copy this file to a new file named '.env' and fill in your values.
|
| 5 |
+
# Do NOT commit your actual '.env' file with secrets to Git.
|
| 6 |
+
|
| 7 |
+
# LLM Providers Configuration
|
| 8 |
+
# Supported Options: "gemini", "groq"
|
| 9 |
+
ACTIVE_LLM="groq"
|
| 10 |
+
|
| 11 |
+
# --- 1. Google Gemini ---
|
| 12 |
+
# Get your API key from Google AI Studio: https://aistudio.google.com/
|
| 13 |
+
GEMINI_API_KEY="your-gemini-api-key-here"
|
| 14 |
+
LLM_MODEL_NAME="gemini-2.5-flash"
|
| 15 |
+
|
| 16 |
+
LLM_TEMPERATURE=0.5
|
| 17 |
+
LLM_MAX_RETRIES=5
|
| 18 |
+
|
| 19 |
+
# --- 2. Groq ---
|
| 20 |
+
# Get your API key from Groq Console: https://console.groq.com/
|
| 21 |
+
GROQ_API_KEY="your-groq-api-key-here"
|
| 22 |
+
GROQ_MODEL_NAME="meta-llama/llama-4-scout-17b-16e-instruct"
|
| 23 |
+
|
| 24 |
+
# RAG & Legal Embeddings Configuration
|
| 25 |
+
# law-ai/InLegalBERT is optimized for Indian legal texts
|
| 26 |
+
EMBEDDING_MODEL_NAME="law-ai/InLegalBERT"
|
| 27 |
+
RERANKER_MODEL_NAME="BAAI/bge-reranker-v2-m3"
|
| 28 |
+
|
| 29 |
+
# Chunking & Retrieval Parameters
|
| 30 |
+
PARENT_CHUNK_SIZE=1500
|
| 31 |
+
PARENT_CHUNK_OVERLAP=200
|
| 32 |
+
CHILD_CHUNK_SIZE=400
|
| 33 |
+
CHILD_CHUNK_OVERLAP=100
|
| 34 |
+
SEARCH_K=10
|
| 35 |
+
FETCH_K=40
|
| 36 |
+
RERANK_TOP_K=5
|
| 37 |
+
|
| 38 |
+
# Database Configuration (PostgreSQL or SQLite)
|
| 39 |
+
USE_POSTGRES=false
|
| 40 |
+
POSTGRES_USER=postgres
|
| 41 |
+
POSTGRES_PASSWORD=your_db_password
|
| 42 |
+
POSTGRES_SERVER=localhost
|
| 43 |
+
POSTGRES_PORT=5432
|
| 44 |
+
POSTGRES_DB=legal_rag
|
| 45 |
+
|
| 46 |
+
# Security Options (FastAPI session tokens)
|
| 47 |
+
SECRET_KEY="generate-a-secure-random-string-here"
|
| 48 |
+
|
| 49 |
+
# Webhooks & External Services (Optional)
|
| 50 |
+
N8N_WEBHOOK_URL=""
|
| 51 |
+
PINECONE_API_KEY=""
|
| 52 |
+
PINECONE_INDEX_NAME="legal-rag"
|
| 53 |
+
|
| 54 |
+
# LangSmith Tracing & Observability (Optional)
|
| 55 |
+
LANGSMITH_TRACING=false
|
| 56 |
+
LANGSMITH_ENDPOINT="https://api.smith.langchain.com"
|
| 57 |
+
LANGSMITH_API_KEY=""
|
| 58 |
+
LANGSMITH_PROJECT="legal-case-law-rag"
|
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
env/
|
| 8 |
+
build/
|
| 9 |
+
develop-eggs/
|
| 10 |
+
dist/
|
| 11 |
+
downloads/
|
| 12 |
+
eggs/
|
| 13 |
+
.eggs/
|
| 14 |
+
lib/
|
| 15 |
+
lib64/
|
| 16 |
+
parts/
|
| 17 |
+
sdist/
|
| 18 |
+
var/
|
| 19 |
+
wheels/
|
| 20 |
+
*.egg-info/
|
| 21 |
+
.installed.cfg
|
| 22 |
+
*.egg
|
| 23 |
+
myenv/
|
| 24 |
+
|
| 25 |
+
# Node (Frontend)
|
| 26 |
+
frontend-react/node_modules/
|
| 27 |
+
frontend-react/dist/
|
| 28 |
+
frontend-react/dist-ssr/
|
| 29 |
+
frontend-react/.vite/
|
| 30 |
+
frontend-react/*.local
|
| 31 |
+
|
| 32 |
+
# Environment & Secrets
|
| 33 |
+
.env
|
| 34 |
+
.venv
|
| 35 |
+
pip-log.txt
|
| 36 |
+
pip-delete-this-directory.txt
|
| 37 |
+
|
| 38 |
+
# Databases and Vector Storage
|
| 39 |
+
backend/app/data/*.db
|
| 40 |
+
data/*.db
|
| 41 |
+
qdrant_storage/
|
| 42 |
+
|
| 43 |
+
# Temporary Files
|
| 44 |
+
temp_*
|
| 45 |
+
*.log
|
| 46 |
+
|
| 47 |
+
# OS files
|
| 48 |
+
.DS_Store
|
| 49 |
+
Thumbs.db
|
| 50 |
+
|
| 51 |
+
inspect_qdrant.py
|
| 52 |
+
Case_Overview_Questions_Answers.csv
|
| 53 |
+
generate_embeddings.py
|
| 54 |
+
brain/
|
| 55 |
+
backend/scratch/
|
| 56 |
+
|
| 57 |
+
# Case Files
|
| 58 |
+
Vadraj_Cement_Limited_vs_Union_Of_India_on_18_March_2026.PDF
|
| 59 |
+
*.pdf
|
| 60 |
+
*.PDF
|
Dockerfile
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# System dependencies: PyAudio, psycopg2, build tools
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
build-essential \
|
| 8 |
+
portaudio19-dev \
|
| 9 |
+
libpq-dev \
|
| 10 |
+
libpq5 \
|
| 11 |
+
curl \
|
| 12 |
+
pkg-config \
|
| 13 |
+
cmake \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
# Copy requirements.txt from the root
|
| 17 |
+
COPY requirements.txt .
|
| 18 |
+
|
| 19 |
+
RUN pip install --upgrade pip setuptools wheel && \
|
| 20 |
+
pip install --no-cache-dir --no-build-isolation pyaudio && \
|
| 21 |
+
pip install --no-cache-dir -r requirements.txt && \
|
| 22 |
+
pip install --no-cache-dir --no-deps qwen-asr qwen-tts
|
| 23 |
+
|
| 24 |
+
# Copy backend application source
|
| 25 |
+
COPY backend/app ./app
|
| 26 |
+
|
| 27 |
+
# Create necessary directories
|
| 28 |
+
RUN mkdir -p /app/app/data /app/qdrant_storage
|
| 29 |
+
|
| 30 |
+
EXPOSE 8001
|
| 31 |
+
EXPOSE 7860
|
| 32 |
+
|
| 33 |
+
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8001}"]
|
README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Legal Case Law RAG Assistant ⚖️
|
| 2 |
+
|
| 3 |
+
A professional, high-performance Retrieval-Augmented Generation (RAG) platform tailored for legal researchers. This application combines state-of-the-art LLMs with a proprietary legal search engine to provide precise, citation-backed analysis of case law.
|
| 4 |
+
|
| 5 |
+
## 🌟 Key Features
|
| 6 |
+
|
| 7 |
+
- **Legal-Specific RAG**: High-fidelity retrieval utilizing **Qdrant** and specialized legal embeddings (`InLegalBERT`).
|
| 8 |
+
- **Parent-Child Chunking**: Maintains document context while allowing for granular retrieval of specific legal clauses.
|
| 9 |
+
- **Premium Voice Experience**:
|
| 10 |
+
- **Seamless TTS Highlighting**: Real-time "karaoke-style" word highlighting synchronized with audio playback.
|
| 11 |
+
- **Kokoro-82M Engine**: Ultra-natural, low-latency speech generation.
|
| 12 |
+
- **Citation Filtering**: Audio automatically skips citations and Markdown symbols for a clean listening experience.
|
| 13 |
+
- **Streaming Intelligence**: Multi-document analysis with real-time response streaming and deterministic citations.
|
| 14 |
+
- **Modern Architecture**:
|
| 15 |
+
- **Backend**: FastAPI with async execution and JWT security.
|
| 16 |
+
- **Frontend**: React-based Glassmorphism UI with persistent session management.
|
| 17 |
+
|
| 18 |
+
## 🛠️ Technology Stack
|
| 19 |
+
|
| 20 |
+
- **Python**: 3.12.10
|
| 21 |
+
- **Vector Store**: Qdrant (Persistent Storage)
|
| 22 |
+
- **Frameworks**: FastAPI, React.js (Vite)
|
| 23 |
+
- **AI Models**: Gemini 2.0/2.5, Groq (Llama 3)
|
| 24 |
+
- **Audio Stack**: Kokoro-82M (TTS), Qwen-ASR (Speech Recognition)
|
| 25 |
+
|
| 26 |
+
## 🚀 Getting Started
|
| 27 |
+
|
| 28 |
+
### Prerequisites
|
| 29 |
+
- Python 3.12+
|
| 30 |
+
- Node.js & npm
|
| 31 |
+
- Qdrant Instance (Local or Cloud)
|
| 32 |
+
|
| 33 |
+
### 1. Backend Setup
|
| 34 |
+
1. Create and activate a virtual environment:
|
| 35 |
+
```bash
|
| 36 |
+
python -m venv myenv
|
| 37 |
+
myenv\Scripts\activate
|
| 38 |
+
```
|
| 39 |
+
2. Install dependencies:
|
| 40 |
+
```bash
|
| 41 |
+
pip install -r requirements.txt
|
| 42 |
+
```
|
| 43 |
+
3. Configure your keys:
|
| 44 |
+
- Copy `.env.example` to `.env`.
|
| 45 |
+
- Fill in your `GEMINI_API_KEY` or `GROQ_API_KEY`.
|
| 46 |
+
4. Run the server:
|
| 47 |
+
```bash
|
| 48 |
+
cd backend
|
| 49 |
+
uvicorn app.main:app --reload
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
### 2. Frontend Setup
|
| 53 |
+
1. Navigate to the frontend:
|
| 54 |
+
```bash
|
| 55 |
+
cd frontend-react
|
| 56 |
+
```
|
| 57 |
+
2. Install & Start:
|
| 58 |
+
```bash
|
| 59 |
+
npm install
|
| 60 |
+
npm run dev
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
## 📂 Project Organization
|
| 64 |
+
- `/backend`: FastAPI source code and local data/vector storage.
|
| 65 |
+
- `/frontend-react`: React application source and styling.
|
| 66 |
+
- `/requirements.txt`: Unified dependency list with critical version locks.
|
| 67 |
+
- `/.env.example`: Clean template for environment configuration.
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
**License**: Internal Project / Proprietary
|
backend/.dockerignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python cache
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
.Python
|
| 7 |
+
|
| 8 |
+
# Virtual environments
|
| 9 |
+
myenv/
|
| 10 |
+
venv/
|
| 11 |
+
env/
|
| 12 |
+
.env
|
| 13 |
+
|
| 14 |
+
# Qdrant local storage (mounted as volume)
|
| 15 |
+
qdrant_storage/
|
| 16 |
+
|
| 17 |
+
# SQLite database (mounted as volume)
|
| 18 |
+
app/data/
|
| 19 |
+
|
| 20 |
+
# Test / dev artifacts
|
| 21 |
+
*.log
|
| 22 |
+
*.egg-info/
|
| 23 |
+
dist/
|
| 24 |
+
build/
|
| 25 |
+
.pytest_cache/
|
| 26 |
+
.mypy_cache/
|
| 27 |
+
|
| 28 |
+
# Scratch / temp
|
| 29 |
+
scratch/
|
| 30 |
+
|
| 31 |
+
# Git
|
| 32 |
+
.git/
|
| 33 |
+
.gitignore
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# System dependencies: PyAudio, psycopg2, build tools
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
build-essential \
|
| 8 |
+
portaudio19-dev \
|
| 9 |
+
libpq-dev \
|
| 10 |
+
libpq5 \
|
| 11 |
+
curl \
|
| 12 |
+
pkg-config \
|
| 13 |
+
cmake \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
# requirements.txt is copied from the project root (build context is root)
|
| 17 |
+
COPY requirements.txt .
|
| 18 |
+
|
| 19 |
+
RUN pip install --upgrade pip setuptools wheel && \
|
| 20 |
+
pip install --no-cache-dir --no-build-isolation pyaudio && \
|
| 21 |
+
pip install --no-cache-dir -r ../requirements.txt && \
|
| 22 |
+
pip install --no-cache-dir --no-deps qwen-asr qwen-tts
|
| 23 |
+
|
| 24 |
+
# Copy backend application source
|
| 25 |
+
COPY backend/app ./app
|
| 26 |
+
|
| 27 |
+
# Persistent data directories
|
| 28 |
+
RUN mkdir -p /app/app/data /app/qdrant_storage
|
| 29 |
+
|
| 30 |
+
EXPOSE 8001
|
| 31 |
+
EXPOSE 7860
|
| 32 |
+
|
| 33 |
+
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8001}"]
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/api/__init__.py
ADDED
|
File without changes
|
backend/app/api/api_v1/__init__.py
ADDED
|
File without changes
|
backend/app/api/api_v1/api.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from .endpoints import auth, documents, chat
|
| 3 |
+
from ...services.voice_service import voice_service
|
| 4 |
+
|
| 5 |
+
api_router = APIRouter()
|
| 6 |
+
api_router.include_router(auth.router, tags=["auth"])
|
| 7 |
+
api_router.include_router(documents.router, prefix="/documents", tags=["documents"])
|
| 8 |
+
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
| 9 |
+
|
| 10 |
+
# Voice router (matching legacy structure)
|
| 11 |
+
api_router.include_router(voice_service.create_voice_router() if hasattr(voice_service, 'create_voice_router') else None)
|
backend/app/api/api_v1/endpoints/__init__.py
ADDED
|
File without changes
|
backend/app/api/api_v1/endpoints/auth.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 3 |
+
from fastapi.security import OAuth2PasswordRequestForm
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from ... import deps
|
| 6 |
+
from ....core import security
|
| 7 |
+
from ....core.config import settings
|
| 8 |
+
from ....db.session import get_db
|
| 9 |
+
from ....models.user import User
|
| 10 |
+
from ....schemas.user import UserCreate, User as UserSchema
|
| 11 |
+
from ....schemas.token import Token
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
@router.post("/register", response_model=dict)
|
| 16 |
+
def register(user_in: UserCreate, db: Session = Depends(get_db)):
|
| 17 |
+
# Check if username exists
|
| 18 |
+
user = db.query(User).filter(User.username == user_in.username).first()
|
| 19 |
+
if user:
|
| 20 |
+
raise HTTPException(
|
| 21 |
+
status_code=400,
|
| 22 |
+
detail="The user with this username already exists in the system.",
|
| 23 |
+
)
|
| 24 |
+
# Check if email exists
|
| 25 |
+
user = db.query(User).filter(User.email == user_in.email).first()
|
| 26 |
+
if user:
|
| 27 |
+
raise HTTPException(
|
| 28 |
+
status_code=400,
|
| 29 |
+
detail="The user with this email already exists in the system.",
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
hashed_password = security.get_password_hash(user_in.password)
|
| 33 |
+
new_user = User(
|
| 34 |
+
username=user_in.username,
|
| 35 |
+
email=user_in.email,
|
| 36 |
+
hashed_password=hashed_password
|
| 37 |
+
)
|
| 38 |
+
db.add(new_user)
|
| 39 |
+
db.commit()
|
| 40 |
+
return {"message": "User registered successfully"}
|
| 41 |
+
|
| 42 |
+
@router.post("/token", response_model=Token)
|
| 43 |
+
def login_access_token(
|
| 44 |
+
db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
|
| 45 |
+
) -> Any:
|
| 46 |
+
# Treat the username field from the form as email
|
| 47 |
+
user = db.query(User).filter(User.email == form_data.username).first()
|
| 48 |
+
if not user or not security.verify_password(form_data.password, user.hashed_password):
|
| 49 |
+
raise HTTPException(
|
| 50 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 51 |
+
detail="Incorrect email or password",
|
| 52 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 53 |
+
)
|
| 54 |
+
elif not user.is_active:
|
| 55 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user")
|
| 56 |
+
|
| 57 |
+
access_token = security.create_access_token(subject=user.email)
|
| 58 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
backend/app/api/api_v1/endpoints/chat.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 4 |
+
from fastapi.responses import StreamingResponse
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from ... import deps
|
| 7 |
+
from ....core.config import settings
|
| 8 |
+
from ....models.user import User
|
| 9 |
+
from ....models.chat import ChatSession, ChatMessage
|
| 10 |
+
from ....schemas.chat import ChatQuery
|
| 11 |
+
from ....services.rag_service import rag_service
|
| 12 |
+
from ....db.init_db import q_client, COLLECTION_NAME, get_vector_store
|
| 13 |
+
from ....db.session import SessionLocal
|
| 14 |
+
from qdrant_client.http import models as rest
|
| 15 |
+
from ....services.tts import get_tts_wav, stream_tts_wav_chunks
|
| 16 |
+
import re
|
| 17 |
+
from pydantic import BaseModel
|
| 18 |
+
|
| 19 |
+
router = APIRouter()
|
| 20 |
+
|
| 21 |
+
class SpeakRequest(BaseModel):
|
| 22 |
+
text: str
|
| 23 |
+
|
| 24 |
+
async def build_chat_title(query: str) -> str:
|
| 25 |
+
cleaned = re.sub(r"\s+", " ", query or "").strip()
|
| 26 |
+
if not cleaned: return "New Chat"
|
| 27 |
+
try:
|
| 28 |
+
from langchain_core.messages import HumanMessage
|
| 29 |
+
prompt = f"Short title (2-5 words) for: {cleaned}"
|
| 30 |
+
messages = [HumanMessage(content=prompt)]
|
| 31 |
+
response = await rag_service.llm.ainvoke(messages)
|
| 32 |
+
return response.content.strip().strip('"').strip("'")[:60]
|
| 33 |
+
except:
|
| 34 |
+
return cleaned[:30] + "..."
|
| 35 |
+
|
| 36 |
+
@router.get("/sessions")
|
| 37 |
+
def list_sessions(db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user)):
|
| 38 |
+
user_id_str = str(current_user.id)
|
| 39 |
+
sessions = db.query(ChatSession).filter(ChatSession.user_id == user_id_str).order_by(ChatSession.created_at.desc()).all()
|
| 40 |
+
return {"sessions": [{"id": s.id, "title": s.title, "date": s.created_at} for s in sessions]}
|
| 41 |
+
|
| 42 |
+
@router.get("/history/{session_id}")
|
| 43 |
+
def get_history(session_id: str, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user)):
|
| 44 |
+
user_id_str = str(current_user.id)
|
| 45 |
+
messages = db.query(ChatMessage).filter(
|
| 46 |
+
ChatMessage.user_id == user_id_str,
|
| 47 |
+
ChatMessage.session_id == session_id
|
| 48 |
+
).order_by(ChatMessage.timestamp.asc()).all()
|
| 49 |
+
|
| 50 |
+
# Also fetch linked documents
|
| 51 |
+
from ....models.document import Document
|
| 52 |
+
docs = db.query(Document).filter(
|
| 53 |
+
Document.user_id == user_id_str,
|
| 54 |
+
Document.session_id == session_id
|
| 55 |
+
).all()
|
| 56 |
+
|
| 57 |
+
return {
|
| 58 |
+
"history": [{"role": m.role, "text": m.content, "sources": json.loads(m.sources) if m.sources else []} for m in messages],
|
| 59 |
+
"documents": [{"filename": d.filename, "chunks": d.chunk_count} for d in docs]
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
class ChatQuery(BaseModel):
|
| 63 |
+
query: str
|
| 64 |
+
session_id: str
|
| 65 |
+
filename: Optional[str] = None
|
| 66 |
+
filenames: Optional[List[str]] = None
|
| 67 |
+
|
| 68 |
+
@router.post("/")
|
| 69 |
+
async def query_chat(chat_data: ChatQuery, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user)):
|
| 70 |
+
user_id_str = str(current_user.id)
|
| 71 |
+
vector_store = get_vector_store(rag_service.embeddings)
|
| 72 |
+
user_id_f = rest.FieldCondition(key="metadata.user_id", match=rest.MatchValue(value=user_id_str))
|
| 73 |
+
|
| 74 |
+
# 1. Broad Session Search (Selection-Aware)
|
| 75 |
+
must_conditions = [user_id_f, rest.FieldCondition(key="metadata.session_id", match=rest.MatchValue(value=chat_data.session_id))]
|
| 76 |
+
if chat_data.filenames and len(chat_data.filenames) > 0:
|
| 77 |
+
must_conditions.append(rest.FieldCondition(key="metadata.filename", match=rest.MatchAny(any=chat_data.filenames)))
|
| 78 |
+
elif chat_data.filename:
|
| 79 |
+
must_conditions.append(rest.FieldCondition(key="metadata.filename", match=rest.MatchValue(value=chat_data.filename)))
|
| 80 |
+
|
| 81 |
+
search_results = vector_store.search(
|
| 82 |
+
query=chat_data.query,
|
| 83 |
+
search_type="mmr",
|
| 84 |
+
k=settings.SEARCH_K,
|
| 85 |
+
fetch_k=settings.FETCH_K,
|
| 86 |
+
filter=rest.Filter(must=must_conditions)
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
if not search_results:
|
| 90 |
+
async def empty_gen():
|
| 91 |
+
yield "I couldn't find any relevant information across your documents to answer this question."
|
| 92 |
+
return StreamingResponse(empty_gen(), media_type="text/plain")
|
| 93 |
+
|
| 94 |
+
# 2. Handle Session & Logging
|
| 95 |
+
session = db.query(ChatSession).filter(ChatSession.id == chat_data.session_id).first()
|
| 96 |
+
if not session:
|
| 97 |
+
title = await build_chat_title(chat_data.query)
|
| 98 |
+
session = ChatSession(id=chat_data.session_id, user_id=user_id_str, title=title)
|
| 99 |
+
db.add(session)
|
| 100 |
+
db.commit()
|
| 101 |
+
|
| 102 |
+
db.add(ChatMessage(user_id=user_id_str, session_id=chat_data.session_id, role="user", content=chat_data.query))
|
| 103 |
+
db.commit()
|
| 104 |
+
|
| 105 |
+
# 3. Intelligent Grouping
|
| 106 |
+
# Rerank first to ensure we are only using top relevant bits across all files
|
| 107 |
+
candidates = [doc.page_content for doc in search_results]
|
| 108 |
+
scores = rag_service.rerank_results(chat_data.query, candidates)
|
| 109 |
+
scored_hits = sorted(zip(search_results, scores), key=lambda x: x[1], reverse=True)[:settings.RERANK_TOP_K]
|
| 110 |
+
|
| 111 |
+
# Group the top hits by filename
|
| 112 |
+
grouped_hits = {}
|
| 113 |
+
all_sources_data = [] # For DB storage
|
| 114 |
+
consolidated_citations = {} # For final display
|
| 115 |
+
|
| 116 |
+
for hit, score in scored_hits:
|
| 117 |
+
fname = hit.metadata.get('filename', 'Unknown Document')
|
| 118 |
+
page = hit.metadata.get('page')
|
| 119 |
+
|
| 120 |
+
all_sources_data.append({"file": fname, "page": page})
|
| 121 |
+
if fname not in consolidated_citations: consolidated_citations[fname] = set()
|
| 122 |
+
if page: consolidated_citations[fname].add(page)
|
| 123 |
+
|
| 124 |
+
if fname not in grouped_hits: grouped_hits[fname] = []
|
| 125 |
+
grouped_hits[fname].append(f"[Page: {page}]\n{hit.page_content}")
|
| 126 |
+
|
| 127 |
+
unique_files_found = list(grouped_hits.keys())
|
| 128 |
+
is_sequential = len(unique_files_found) > 1
|
| 129 |
+
|
| 130 |
+
async def response_generator():
|
| 131 |
+
full_answer = ""
|
| 132 |
+
|
| 133 |
+
for idx, fname in enumerate(unique_files_found):
|
| 134 |
+
# A. Prepare section header
|
| 135 |
+
header = f"### [DOCUMENT: {fname}]\n\n" if is_sequential else ""
|
| 136 |
+
full_answer += header
|
| 137 |
+
if header: yield header
|
| 138 |
+
|
| 139 |
+
# B. Stream answer for THIS document's context
|
| 140 |
+
doc_context = grouped_hits[fname]
|
| 141 |
+
async for chunk in rag_service.generate_answer_stream(
|
| 142 |
+
chat_data.query,
|
| 143 |
+
doc_context,
|
| 144 |
+
brief=is_sequential,
|
| 145 |
+
trace_metadata={"user_id": user_id_str, "file": fname}
|
| 146 |
+
):
|
| 147 |
+
full_answer += chunk
|
| 148 |
+
yield chunk
|
| 149 |
+
|
| 150 |
+
# C. Separator
|
| 151 |
+
if is_sequential and idx < len(unique_files_found) - 1:
|
| 152 |
+
sep = "\n\n---\n\n"
|
| 153 |
+
full_answer += sep
|
| 154 |
+
yield sep
|
| 155 |
+
|
| 156 |
+
# 4. Deterministic Python Citations
|
| 157 |
+
citation_lines = []
|
| 158 |
+
for f, pages in consolidated_citations.items():
|
| 159 |
+
sorted_pages = sorted(list(pages))
|
| 160 |
+
pages_str = ", ".join(map(str, sorted_pages))
|
| 161 |
+
citation_lines.append(f"[Source: {f}, Pages: {pages_str}]")
|
| 162 |
+
|
| 163 |
+
python_citation_str = "\n\n***\n" + "\n".join(citation_lines) if citation_lines else ""
|
| 164 |
+
|
| 165 |
+
if python_citation_str:
|
| 166 |
+
full_answer += python_citation_str
|
| 167 |
+
yield python_citation_str
|
| 168 |
+
|
| 169 |
+
# 5. Final Save
|
| 170 |
+
with SessionLocal() as final_db:
|
| 171 |
+
final_db.add(ChatMessage(user_id=user_id_str, session_id=chat_data.session_id, role="assistant", content=full_answer, sources=json.dumps(all_sources_data)))
|
| 172 |
+
final_db.commit()
|
| 173 |
+
|
| 174 |
+
return StreamingResponse(response_generator(), media_type="text/plain")
|
| 175 |
+
|
| 176 |
+
@router.delete("/session/{session_id}")
|
| 177 |
+
def delete_session(session_id: str, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user)):
|
| 178 |
+
user_id_str = str(current_user.id)
|
| 179 |
+
|
| 180 |
+
# 1. Cleanup Documents and Embeddings associated with this session
|
| 181 |
+
from ....models.document import Document
|
| 182 |
+
db.query(Document).filter(Document.session_id == session_id, Document.user_id == user_id_str).delete()
|
| 183 |
+
|
| 184 |
+
q_client.delete(
|
| 185 |
+
collection_name=COLLECTION_NAME,
|
| 186 |
+
points_selector=rest.Filter(
|
| 187 |
+
must=[
|
| 188 |
+
rest.FieldCondition(key="metadata.user_id", match=rest.MatchValue(value=user_id_str)),
|
| 189 |
+
rest.FieldCondition(key="metadata.session_id", match=rest.MatchValue(value=session_id))
|
| 190 |
+
]
|
| 191 |
+
)
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
# 2. Cleanup Messages and Session
|
| 195 |
+
db.query(ChatMessage).filter(ChatMessage.session_id == session_id, ChatMessage.user_id == user_id_str).delete()
|
| 196 |
+
db.query(ChatSession).filter(ChatSession.id == session_id, ChatSession.user_id == user_id_str).delete()
|
| 197 |
+
|
| 198 |
+
db.commit()
|
| 199 |
+
return {"message": "Session and associated documents deleted"}
|
| 200 |
+
|
| 201 |
+
@router.post("/speak")
|
| 202 |
+
async def speak(request: Request, speak_data: SpeakRequest):
|
| 203 |
+
# Sanitize text for TTS
|
| 204 |
+
clean_text = speak_data.text
|
| 205 |
+
clean_text = re.sub(r'#+\s+', '', clean_text)
|
| 206 |
+
clean_text = re.sub(r'\*+', '', clean_text)
|
| 207 |
+
clean_text = re.sub(r'_{3,}', '', clean_text)
|
| 208 |
+
clean_text = re.sub(r'-{3,}', '', clean_text)
|
| 209 |
+
clean_text = re.sub(r'\[Source:.*?\]', '', clean_text)
|
| 210 |
+
|
| 211 |
+
# Internal stop signal for THIS specific request
|
| 212 |
+
import threading
|
| 213 |
+
disconnect_event = threading.Event()
|
| 214 |
+
|
| 215 |
+
# Generator wrapper to monitor disconnection
|
| 216 |
+
async def disconnect_monitor_gen():
|
| 217 |
+
generator = stream_tts_wav_chunks(clean_text, disconnect_event)
|
| 218 |
+
try:
|
| 219 |
+
for chunk in generator:
|
| 220 |
+
if await request.is_disconnected():
|
| 221 |
+
disconnect_event.set()
|
| 222 |
+
break
|
| 223 |
+
yield chunk
|
| 224 |
+
except Exception as e:
|
| 225 |
+
disconnect_event.set()
|
| 226 |
+
raise e
|
| 227 |
+
|
| 228 |
+
return StreamingResponse(
|
| 229 |
+
disconnect_monitor_gen(),
|
| 230 |
+
media_type="application/x-ndjson"
|
| 231 |
+
)
|
backend/app/api/api_v1/endpoints/documents.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Form
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from langchain_core.documents import Document as LCDocument
|
| 7 |
+
from ... import deps
|
| 8 |
+
from ....core.config import settings
|
| 9 |
+
from ....db.init_db import q_client, COLLECTION_NAME, get_vector_store
|
| 10 |
+
from ....models.user import User
|
| 11 |
+
from ....models.document import Document
|
| 12 |
+
from ....services.pdf_processor import process_pdf
|
| 13 |
+
from ....services.rag_service import rag_service
|
| 14 |
+
from qdrant_client.http import models as rest
|
| 15 |
+
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
|
| 18 |
+
@router.post("/upload")
|
| 19 |
+
async def upload_document(
|
| 20 |
+
file: UploadFile = File(...),
|
| 21 |
+
session_id: str = Form(None),
|
| 22 |
+
db: Session = Depends(deps.get_db),
|
| 23 |
+
current_user: User = Depends(deps.get_current_active_user)
|
| 24 |
+
):
|
| 25 |
+
query = db.query(Document).filter(
|
| 26 |
+
Document.user_id == str(current_user.id),
|
| 27 |
+
Document.filename == file.filename
|
| 28 |
+
)
|
| 29 |
+
if session_id:
|
| 30 |
+
query = query.filter(Document.session_id == session_id)
|
| 31 |
+
else:
|
| 32 |
+
query = query.filter(Document.session_id == None)
|
| 33 |
+
|
| 34 |
+
if query.first():
|
| 35 |
+
raise HTTPException(status_code=409, detail=f"Document '{file.filename}' already indexed.")
|
| 36 |
+
|
| 37 |
+
temp_path = f"temp_{uuid.uuid4()}_{file.filename}"
|
| 38 |
+
with open(temp_path, "wb") as buffer:
|
| 39 |
+
buffer.write(await file.read())
|
| 40 |
+
|
| 41 |
+
start_time = time.time()
|
| 42 |
+
try:
|
| 43 |
+
chunks = process_pdf(temp_path, current_user.id, file.filename, session_id)
|
| 44 |
+
if not chunks:
|
| 45 |
+
raise HTTPException(status_code=400, detail="No text extracted")
|
| 46 |
+
|
| 47 |
+
# Legal Audit
|
| 48 |
+
text_sample = "\n".join([c["text"] for c in chunks[:3]])
|
| 49 |
+
if not rag_service.validate_is_legal(text_sample):
|
| 50 |
+
raise HTTPException(status_code=400, detail="Not a legal document")
|
| 51 |
+
|
| 52 |
+
vector_store = get_vector_store(rag_service.embeddings)
|
| 53 |
+
docs = [LCDocument(page_content=c["text"], metadata=c["metadata"]) for c in chunks]
|
| 54 |
+
vector_store.add_documents(docs)
|
| 55 |
+
|
| 56 |
+
embed_time = time.time() - start_time
|
| 57 |
+
new_doc = Document(
|
| 58 |
+
user_id=str(current_user.id),
|
| 59 |
+
filename=file.filename,
|
| 60 |
+
session_id=session_id,
|
| 61 |
+
chunk_count=len(chunks),
|
| 62 |
+
embed_time_seconds=round(embed_time, 2)
|
| 63 |
+
)
|
| 64 |
+
db.add(new_doc)
|
| 65 |
+
db.commit()
|
| 66 |
+
return {
|
| 67 |
+
"message": f"Successfully indexed {file.filename}",
|
| 68 |
+
"chunks": len(chunks),
|
| 69 |
+
"time": round(embed_time, 2)
|
| 70 |
+
}
|
| 71 |
+
finally:
|
| 72 |
+
if os.path.exists(temp_path):
|
| 73 |
+
os.remove(temp_path)
|
| 74 |
+
|
| 75 |
+
@router.get("/")
|
| 76 |
+
def get_user_documents(db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user)):
|
| 77 |
+
from ....models.chat import ChatSession
|
| 78 |
+
|
| 79 |
+
# Efficiently join Document and ChatSession to avoid N+1 queries (lookup speed-up)
|
| 80 |
+
query_results = db.query(Document, ChatSession.title).outerjoin(
|
| 81 |
+
ChatSession, Document.session_id == ChatSession.id
|
| 82 |
+
).filter(Document.user_id == str(current_user.id)).all()
|
| 83 |
+
|
| 84 |
+
results = []
|
| 85 |
+
for doc, chat_title in query_results:
|
| 86 |
+
results.append({
|
| 87 |
+
"id": doc.id,
|
| 88 |
+
"filename": doc.filename,
|
| 89 |
+
"date": doc.upload_date,
|
| 90 |
+
"session_id": doc.session_id,
|
| 91 |
+
"session_title": chat_title or "Direct Upload",
|
| 92 |
+
"chunks": doc.chunk_count or 0,
|
| 93 |
+
"embed_time": doc.embed_time_seconds or 0
|
| 94 |
+
})
|
| 95 |
+
return {"documents": results}
|
| 96 |
+
|
| 97 |
+
@router.get("/session/{session_id}")
|
| 98 |
+
def get_session_documents(
|
| 99 |
+
session_id: str,
|
| 100 |
+
db: Session = Depends(deps.get_db),
|
| 101 |
+
current_user: User = Depends(deps.get_current_active_user)
|
| 102 |
+
):
|
| 103 |
+
docs = db.query(Document).filter(
|
| 104 |
+
Document.user_id == str(current_user.id),
|
| 105 |
+
Document.session_id == session_id
|
| 106 |
+
).all()
|
| 107 |
+
return {
|
| 108 |
+
"documents": [{"filename": d.filename, "chunks": d.chunk_count} for d in docs]
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
@router.delete("/{filename}")
|
| 112 |
+
def delete_document(filename: str, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user)):
|
| 113 |
+
# Find all variants (different sessions) of this filename for this user
|
| 114 |
+
docs = db.query(Document).filter(Document.user_id == str(current_user.id), Document.filename == filename).all()
|
| 115 |
+
if not docs:
|
| 116 |
+
raise HTTPException(status_code=404, detail="Document not found")
|
| 117 |
+
|
| 118 |
+
for doc in docs:
|
| 119 |
+
db.delete(doc)
|
| 120 |
+
db.commit()
|
| 121 |
+
|
| 122 |
+
q_client.delete(
|
| 123 |
+
collection_name=COLLECTION_NAME,
|
| 124 |
+
points_selector=rest.Filter(
|
| 125 |
+
must=[
|
| 126 |
+
rest.FieldCondition(key="metadata.user_id", match=rest.MatchValue(value=str(current_user.id))),
|
| 127 |
+
rest.FieldCondition(key="metadata.filename", match=rest.MatchValue(value=filename))
|
| 128 |
+
]
|
| 129 |
+
)
|
| 130 |
+
)
|
| 131 |
+
return {"message": f"Deleted all instances of {filename}"}
|
backend/app/api/deps.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Generator
|
| 2 |
+
from fastapi import Depends, HTTPException, status
|
| 3 |
+
from fastapi.security import OAuth2PasswordBearer
|
| 4 |
+
from jose import jwt, JWTError
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from ..core import security
|
| 7 |
+
from ..core.config import settings
|
| 8 |
+
from ..db.session import SessionLocal
|
| 9 |
+
from ..models.user import User
|
| 10 |
+
from ..schemas.token import TokenPayload
|
| 11 |
+
# Reference: https://fastapi.tiangolo.com/tutorial/security/
|
| 12 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/token")
|
| 13 |
+
|
| 14 |
+
def get_db() -> Generator:
|
| 15 |
+
try:
|
| 16 |
+
db = SessionLocal()
|
| 17 |
+
yield db
|
| 18 |
+
finally:
|
| 19 |
+
db.close()
|
| 20 |
+
|
| 21 |
+
async def get_current_user(
|
| 22 |
+
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)
|
| 23 |
+
) -> User:
|
| 24 |
+
credentials_exception = HTTPException(
|
| 25 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 26 |
+
detail="Could not validate credentials",
|
| 27 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 28 |
+
)
|
| 29 |
+
try:
|
| 30 |
+
payload = jwt.decode(
|
| 31 |
+
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
| 32 |
+
)
|
| 33 |
+
username: str = payload.get("sub")
|
| 34 |
+
if username is None:
|
| 35 |
+
raise credentials_exception
|
| 36 |
+
token_data = TokenPayload(sub=username)
|
| 37 |
+
except JWTError:
|
| 38 |
+
raise credentials_exception
|
| 39 |
+
user = db.query(User).filter(User.email == token_data.sub).first()
|
| 40 |
+
if not user:
|
| 41 |
+
raise credentials_exception
|
| 42 |
+
return user
|
| 43 |
+
|
| 44 |
+
async def get_current_active_user(
|
| 45 |
+
current_user: User = Depends(get_current_user),
|
| 46 |
+
) -> User:
|
| 47 |
+
if not current_user.is_active:
|
| 48 |
+
raise HTTPException(status_code=400, detail="Inactive user")
|
| 49 |
+
return current_user
|
backend/app/core/__init__.py
ADDED
|
File without changes
|
backend/app/core/config.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pydantic_settings import BaseSettings
|
| 3 |
+
from pydantic import field_validator
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
class Settings(BaseSettings):
|
| 9 |
+
PROJECT_NAME: str = "Legal Case Law RAG"
|
| 10 |
+
|
| 11 |
+
# Auth
|
| 12 |
+
SECRET_KEY: str = os.getenv("SECRET_KEY", "legal-rag-secret-2026")
|
| 13 |
+
ALGORITHM: str = "HS256"
|
| 14 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24 hours
|
| 15 |
+
|
| 16 |
+
# RAG Search
|
| 17 |
+
SEARCH_K: int = int(os.getenv("SEARCH_K", 40))
|
| 18 |
+
FETCH_K: int = int(os.getenv("FETCH_K", 100))
|
| 19 |
+
RERANK_TOP_K: int = int(os.getenv("RERANK_TOP_K", 15))
|
| 20 |
+
|
| 21 |
+
# Models
|
| 22 |
+
EMBEDDING_MODEL_NAME: str = os.getenv("EMBEDDING_MODEL_NAME", "law-ai/InLegalBERT")
|
| 23 |
+
RERANKER_MODEL_NAME: str = os.getenv("RERANKER_MODEL_NAME", "BAAI/bge-reranker-v2-m3")
|
| 24 |
+
GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY", "")
|
| 25 |
+
|
| 26 |
+
# LLM Settings
|
| 27 |
+
ACTIVE_LLM: str = os.getenv("ACTIVE_LLM", "groq").lower()
|
| 28 |
+
LLM_MODEL_NAME: str = os.getenv("LLM_MODEL_NAME", "gemini-2.5-flash")
|
| 29 |
+
LLM_TEMPERATURE: float = float(os.getenv("LLM_TEMPERATURE", 0.0))
|
| 30 |
+
LLM_MAX_RETRIES: int = int(os.getenv("LLM_MAX_RETRIES", 5))
|
| 31 |
+
|
| 32 |
+
# Groq
|
| 33 |
+
GROQ_API_KEY: str = os.getenv("GROQ_API_KEY", "")
|
| 34 |
+
GROQ_MODEL_NAME: str = os.getenv("GROQ_MODEL_NAME", "llama-3.1-8b-instant")
|
| 35 |
+
|
| 36 |
+
# Database
|
| 37 |
+
BASE_DIR: str = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 38 |
+
SQLITE_DB_PATH: str = os.path.join(BASE_DIR, "data", "users.db")
|
| 39 |
+
|
| 40 |
+
USE_POSTGRES: bool = os.getenv("USE_POSTGRES", "false").lower() == "true"
|
| 41 |
+
POSTGRES_USER: str = os.getenv("POSTGRES_USER", "postgres")
|
| 42 |
+
POSTGRES_PASSWORD: str = os.getenv("POSTGRES_PASSWORD", "password")
|
| 43 |
+
POSTGRES_SERVER: str = os.getenv("POSTGRES_SERVER", "localhost")
|
| 44 |
+
POSTGRES_PORT: str = os.getenv("POSTGRES_PORT", "5432")
|
| 45 |
+
POSTGRES_DB: str = os.getenv("POSTGRES_DB", "legal_rag")
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def DATABASE_URL(self) -> str:
|
| 49 |
+
direct_url = os.getenv("DATABASE_URL")
|
| 50 |
+
if direct_url:
|
| 51 |
+
return direct_url
|
| 52 |
+
if self.USE_POSTGRES:
|
| 53 |
+
return f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
| 54 |
+
return f"sqlite:///{self.SQLITE_DB_PATH}"
|
| 55 |
+
|
| 56 |
+
QDRANT_URL: str = os.getenv("QDRANT_URL", "") # e.g. http://qdrant:6333 for Docker
|
| 57 |
+
QDRANT_API_KEY: str = os.getenv("QDRANT_API_KEY", "")
|
| 58 |
+
QDRANT_PATH: str = "qdrant_storage"
|
| 59 |
+
COLLECTION_NAME: str = "legal_rag"
|
| 60 |
+
|
| 61 |
+
# Chunking
|
| 62 |
+
PARENT_CHUNK_SIZE: int = int(os.getenv("PARENT_CHUNK_SIZE", 1500))
|
| 63 |
+
PARENT_CHUNK_OVERLAP: int = int(os.getenv("PARENT_CHUNK_OVERLAP", 200))
|
| 64 |
+
CHILD_CHUNK_SIZE: int = int(os.getenv("CHILD_CHUNK_SIZE", 400))
|
| 65 |
+
CHILD_CHUNK_OVERLAP: int = int(os.getenv("CHILD_CHUNK_OVERLAP", 100))
|
| 66 |
+
|
| 67 |
+
@field_validator("SECRET_KEY")
|
| 68 |
+
@classmethod
|
| 69 |
+
def check_secret_key(cls, v: str) -> str:
|
| 70 |
+
if v == "legal-rag-secret-2026" or len(v) < 32:
|
| 71 |
+
import sys
|
| 72 |
+
print(
|
| 73 |
+
"\n⚠️ [SECURITY WARNING] INSECURE SECRET_KEY DETECTED!\n"
|
| 74 |
+
f"Current SECRET_KEY is either the default template value or too short ({len(v)} chars).\n"
|
| 75 |
+
"For production, please generate a secure 32-character hex key using:\n"
|
| 76 |
+
" python -c \"import secrets; print(secrets.token_hex(32))\"\n"
|
| 77 |
+
"and set it as SECRET_KEY in your .env file.\n",
|
| 78 |
+
file=sys.stderr
|
| 79 |
+
)
|
| 80 |
+
return v
|
| 81 |
+
|
| 82 |
+
class Config:
|
| 83 |
+
case_sensitive = True
|
| 84 |
+
|
| 85 |
+
settings = Settings()
|
backend/app/core/security.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta, timezone
|
| 2 |
+
# Reference: https://fastapi.tiangolo.com/tutorial/security/
|
| 3 |
+
from typing import Any, Union
|
| 4 |
+
from jose import jwt
|
| 5 |
+
import bcrypt
|
| 6 |
+
from passlib.context import CryptContext
|
| 7 |
+
from .config import settings
|
| 8 |
+
|
| 9 |
+
# Graceful legacy verifier for pbkdf2_sha256 hashes in database
|
| 10 |
+
legacy_context = CryptContext(schemes=["pbkdf2_sha256"])
|
| 11 |
+
|
| 12 |
+
def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str:
|
| 13 |
+
# Use modern timezone-aware UTC datetime (Python 3.12+ standard)
|
| 14 |
+
now = datetime.now(timezone.utc)
|
| 15 |
+
if expires_delta:
|
| 16 |
+
expire = now + expires_delta
|
| 17 |
+
else:
|
| 18 |
+
expire = now + timedelta(
|
| 19 |
+
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
| 20 |
+
)
|
| 21 |
+
to_encode = {"exp": expire, "sub": str(subject)}
|
| 22 |
+
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
| 23 |
+
return encoded_jwt
|
| 24 |
+
|
| 25 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 26 |
+
try:
|
| 27 |
+
# If it's a legacy pbkdf2 hash, verify with passlib legacy context
|
| 28 |
+
if hashed_password.startswith("$pbkdf2-sha256"):
|
| 29 |
+
return legacy_context.verify(plain_password, hashed_password)
|
| 30 |
+
# Otherwise, verify using standard native bcrypt
|
| 31 |
+
return bcrypt.checkpw(
|
| 32 |
+
plain_password.encode("utf-8"),
|
| 33 |
+
hashed_password.encode("utf-8")
|
| 34 |
+
)
|
| 35 |
+
except Exception as e:
|
| 36 |
+
print(f"[-] Password verification error: {e}")
|
| 37 |
+
return False
|
| 38 |
+
|
| 39 |
+
def get_password_hash(password: str) -> str:
|
| 40 |
+
# Hash password using native bcrypt (highly secure and fully compatible with Python 3.12+)
|
| 41 |
+
pwd_bytes = password.encode("utf-8")
|
| 42 |
+
salt = bcrypt.gensalt()
|
| 43 |
+
hashed = bcrypt.hashpw(pwd_bytes, salt)
|
| 44 |
+
return hashed.decode("utf-8")
|
backend/app/core/utils.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
def clean_filename(filename: str) -> str:
|
| 4 |
+
"""Strips the temp_UUID_ prefix from legacy filenames."""
|
| 5 |
+
if filename.startswith("temp_") and len(filename) > 42:
|
| 6 |
+
parts = filename.split("_", 2)
|
| 7 |
+
if len(parts) > 2:
|
| 8 |
+
return parts[2]
|
| 9 |
+
return filename
|
| 10 |
+
|
| 11 |
+
def build_chat_title_fallback(query: str) -> str:
|
| 12 |
+
cleaned = re.sub(r"\s+", " ", query or "").strip()
|
| 13 |
+
cleaned = cleaned.rstrip("?.!,:;")
|
| 14 |
+
words = cleaned.split(" ")
|
| 15 |
+
short_words = words[:8]
|
| 16 |
+
title = " ".join(short_words)
|
| 17 |
+
if len(words) > 8 or len(cleaned) > len(title):
|
| 18 |
+
title = f"{title}..."
|
| 19 |
+
return title[:60]
|
backend/app/db/__init__.py
ADDED
|
File without changes
|
backend/app/db/base_class.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
from sqlalchemy.ext.declarative import as_declarative, declared_attr
|
| 3 |
+
|
| 4 |
+
@as_declarative()
|
| 5 |
+
class Base:
|
| 6 |
+
id: Any
|
| 7 |
+
__name__: str
|
| 8 |
+
# Generate __tablename__ automatically
|
| 9 |
+
@declared_attr
|
| 10 |
+
def __tablename__(cls) -> str:
|
| 11 |
+
return cls.__name__.lower()
|
backend/app/db/init_db.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from qdrant_client.http import models as rest
|
| 2 |
+
from langchain_qdrant import QdrantVectorStore
|
| 3 |
+
from qdrant_client import QdrantClient
|
| 4 |
+
from .session import engine
|
| 5 |
+
from .base_class import Base
|
| 6 |
+
from ..core.config import settings
|
| 7 |
+
# Import all models to ensure they are registered with Base
|
| 8 |
+
from ..models.user import User
|
| 9 |
+
from ..models.document import Document
|
| 10 |
+
from ..models.chat import ChatSession, ChatMessage
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
import datetime
|
| 13 |
+
|
| 14 |
+
def safe_migrate():
|
| 15 |
+
"""Add new columns to existing tables without destroying data."""
|
| 16 |
+
with engine.connect() as conn:
|
| 17 |
+
inspector = sa.inspect(engine)
|
| 18 |
+
|
| 19 |
+
# Documents migration
|
| 20 |
+
existing_doc_cols = [c['name'] for c in inspector.get_columns('documents')]
|
| 21 |
+
if 'chunk_count' not in existing_doc_cols:
|
| 22 |
+
conn.execute(sa.text("ALTER TABLE documents ADD COLUMN chunk_count INTEGER"))
|
| 23 |
+
if 'embed_time_seconds' not in existing_doc_cols:
|
| 24 |
+
conn.execute(sa.text("ALTER TABLE documents ADD COLUMN embed_time_seconds REAL"))
|
| 25 |
+
|
| 26 |
+
# Users migration
|
| 27 |
+
existing_user_cols = [c['name'] for c in inspector.get_columns('users')]
|
| 28 |
+
if 'is_active' not in existing_user_cols:
|
| 29 |
+
# Use 1 for SQLite, TRUE for Postgres
|
| 30 |
+
default_val = "TRUE" if settings.USE_POSTGRES else "1"
|
| 31 |
+
conn.execute(sa.text(f"ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT {default_val}"))
|
| 32 |
+
if 'email' not in existing_user_cols:
|
| 33 |
+
conn.execute(sa.text("ALTER TABLE users ADD COLUMN email TEXT UNIQUE"))
|
| 34 |
+
|
| 35 |
+
conn.commit()
|
| 36 |
+
|
| 37 |
+
def init_db():
|
| 38 |
+
Base.metadata.create_all(bind=engine)
|
| 39 |
+
try:
|
| 40 |
+
safe_migrate()
|
| 41 |
+
except Exception as e:
|
| 42 |
+
print(f"Migration note: {e}")
|
| 43 |
+
|
| 44 |
+
def get_qdrant_client():
|
| 45 |
+
if settings.QDRANT_URL:
|
| 46 |
+
return QdrantClient(url=settings.QDRANT_URL, api_key=settings.QDRANT_API_KEY or None)
|
| 47 |
+
if settings.QDRANT_PATH == ":memory:":
|
| 48 |
+
return QdrantClient(":memory:")
|
| 49 |
+
return QdrantClient(path=settings.QDRANT_PATH)
|
| 50 |
+
|
| 51 |
+
q_client = get_qdrant_client()
|
| 52 |
+
|
| 53 |
+
COLLECTION_NAME = settings.COLLECTION_NAME
|
| 54 |
+
|
| 55 |
+
def init_qdrant():
|
| 56 |
+
global q_client
|
| 57 |
+
if not q_client.collection_exists(COLLECTION_NAME):
|
| 58 |
+
q_client.create_collection(
|
| 59 |
+
collection_name=COLLECTION_NAME,
|
| 60 |
+
vectors_config=rest.VectorParams(size=768, distance=rest.Distance.COSINE),
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# Run a lightweight backfill check for older documents
|
| 64 |
+
try:
|
| 65 |
+
backfill_missing_metadata()
|
| 66 |
+
except Exception as e:
|
| 67 |
+
print(f"Backfill error: {e}")
|
| 68 |
+
|
| 69 |
+
def backfill_missing_metadata():
|
| 70 |
+
"""Detects documents with missing total_pages/timestamp and repairs them."""
|
| 71 |
+
global q_client
|
| 72 |
+
offset = None
|
| 73 |
+
doc_stats = {} # filename -> max_page
|
| 74 |
+
points_to_fix = []
|
| 75 |
+
|
| 76 |
+
# 1. Scan for missing data
|
| 77 |
+
while True:
|
| 78 |
+
res, offset = q_client.scroll(
|
| 79 |
+
collection_name=COLLECTION_NAME,
|
| 80 |
+
limit=100,
|
| 81 |
+
offset=offset,
|
| 82 |
+
with_payload=True,
|
| 83 |
+
with_vectors=False
|
| 84 |
+
)
|
| 85 |
+
for p in res:
|
| 86 |
+
meta = p.payload.get("metadata", {})
|
| 87 |
+
fname = meta.get("filename")
|
| 88 |
+
page = meta.get("page")
|
| 89 |
+
if fname:
|
| 90 |
+
if fname not in doc_stats: doc_stats[fname] = 0
|
| 91 |
+
if page and isinstance(page, int) and page > doc_stats[fname]:
|
| 92 |
+
doc_stats[fname] = page
|
| 93 |
+
|
| 94 |
+
if "total_pages" not in meta or "ingestion_timestamp" not in meta:
|
| 95 |
+
points_to_fix.append(p)
|
| 96 |
+
if offset is None: break
|
| 97 |
+
|
| 98 |
+
if not points_to_fix:
|
| 99 |
+
return
|
| 100 |
+
|
| 101 |
+
print(f"Repairing metadata for {len(points_to_fix)} chunks...")
|
| 102 |
+
now = datetime.datetime.now().isoformat()
|
| 103 |
+
for p in points_to_fix:
|
| 104 |
+
meta = p.payload.get("metadata", {}).copy()
|
| 105 |
+
fname = meta.get("filename")
|
| 106 |
+
if "total_pages" not in meta:
|
| 107 |
+
meta["total_pages"] = doc_stats.get(fname, meta.get("page", 1))
|
| 108 |
+
if "ingestion_timestamp" not in meta:
|
| 109 |
+
meta["ingestion_timestamp"] = now
|
| 110 |
+
|
| 111 |
+
q_client.set_payload(
|
| 112 |
+
collection_name=COLLECTION_NAME,
|
| 113 |
+
payload={"metadata": meta},
|
| 114 |
+
points=[p.id]
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
def get_vector_store(embeddings):
|
| 118 |
+
return QdrantVectorStore(
|
| 119 |
+
client=q_client,
|
| 120 |
+
collection_name=COLLECTION_NAME,
|
| 121 |
+
embedding=embeddings,
|
| 122 |
+
)
|
backend/app/db/session.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from sqlalchemy import create_engine
|
| 3 |
+
from sqlalchemy.orm import sessionmaker
|
| 4 |
+
from ..core.config import settings
|
| 5 |
+
|
| 6 |
+
SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL
|
| 7 |
+
|
| 8 |
+
# Ensure data directory exists for SQLite
|
| 9 |
+
if not settings.USE_POSTGRES:
|
| 10 |
+
os_path = os.path.dirname(settings.SQLITE_DB_PATH)
|
| 11 |
+
if os_path and not os.path.exists(os_path):
|
| 12 |
+
os.makedirs(os_path, exist_ok=True)
|
| 13 |
+
|
| 14 |
+
connect_args = {"check_same_thread": False} if not settings.USE_POSTGRES else {}
|
| 15 |
+
|
| 16 |
+
engine = create_engine(
|
| 17 |
+
SQLALCHEMY_DATABASE_URL, connect_args=connect_args
|
| 18 |
+
)
|
| 19 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 20 |
+
|
| 21 |
+
def get_db():
|
| 22 |
+
db = SessionLocal()
|
| 23 |
+
try:
|
| 24 |
+
yield db
|
| 25 |
+
finally:
|
| 26 |
+
db.close()
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from .api.api_v1.api import api_router
|
| 4 |
+
from .core.config import settings
|
| 5 |
+
from .db.init_db import init_db, init_qdrant
|
| 6 |
+
import uvicorn
|
| 7 |
+
import threading
|
| 8 |
+
from .services.voice_service import voice_service
|
| 9 |
+
|
| 10 |
+
app = FastAPI(title=settings.PROJECT_NAME)
|
| 11 |
+
|
| 12 |
+
app.add_middleware(
|
| 13 |
+
CORSMiddleware,
|
| 14 |
+
allow_origins=["*"],
|
| 15 |
+
allow_credentials=True,
|
| 16 |
+
allow_methods=["*"],
|
| 17 |
+
allow_headers=["*"],
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
@app.on_event("startup")
|
| 21 |
+
async def startup_event():
|
| 22 |
+
init_db()
|
| 23 |
+
init_qdrant()
|
| 24 |
+
# Preload ASR/TTS models in a background thread to avoid blocking startup
|
| 25 |
+
threading.Thread(target=voice_service.preload_models, daemon=True).start()
|
| 26 |
+
|
| 27 |
+
app.include_router(api_router)
|
| 28 |
+
|
| 29 |
+
if __name__ == "__main__":
|
| 30 |
+
uvicorn.run("app.main:app", host="0.0.0.0", port=8001, reload=True)
|
backend/app/models/__init__.py
ADDED
|
File without changes
|
backend/app/models/chat.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Text, DateTime
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from ..db.base_class import Base
|
| 4 |
+
|
| 5 |
+
class ChatSession(Base):
|
| 6 |
+
__tablename__ = "chat_sessions"
|
| 7 |
+
id = Column(String, primary_key=True, index=True) # UUID
|
| 8 |
+
user_id = Column(String, index=True)
|
| 9 |
+
title = Column(String)
|
| 10 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 11 |
+
|
| 12 |
+
class ChatMessage(Base):
|
| 13 |
+
__tablename__ = "chat_messages"
|
| 14 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 15 |
+
user_id = Column(String, index=True)
|
| 16 |
+
session_id = Column(String, index=True)
|
| 17 |
+
role = Column(String) # 'user' or 'assistant'
|
| 18 |
+
content = Column(Text)
|
| 19 |
+
sources = Column(Text, nullable=True) # JSON string of sources
|
| 20 |
+
timestamp = Column(DateTime, default=datetime.utcnow)
|
backend/app/models/document.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, DateTime, Float
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from ..db.base_class import Base
|
| 4 |
+
|
| 5 |
+
class Document(Base):
|
| 6 |
+
__tablename__ = "documents"
|
| 7 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 8 |
+
user_id = Column(String, index=True)
|
| 9 |
+
session_id = Column(String, index=True, nullable=True)
|
| 10 |
+
filename = Column(String)
|
| 11 |
+
upload_date = Column(DateTime, default=datetime.utcnow)
|
| 12 |
+
chunk_count = Column(Integer, nullable=True)
|
| 13 |
+
embed_time_seconds = Column(Float, nullable=True)
|
backend/app/models/user.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Boolean
|
| 2 |
+
from ..db.base_class import Base
|
| 3 |
+
|
| 4 |
+
class User(Base):
|
| 5 |
+
__tablename__ = "users"
|
| 6 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 7 |
+
username = Column(String, unique=True, index=True)
|
| 8 |
+
email = Column(String, unique=True, index=True)
|
| 9 |
+
hashed_password = Column(String)
|
| 10 |
+
is_active = Column(Boolean, default=True)
|
backend/app/schemas/__init__.py
ADDED
|
File without changes
|
backend/app/schemas/chat.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
class ChatQuery(BaseModel):
|
| 6 |
+
query: str
|
| 7 |
+
session_id: str
|
| 8 |
+
filename: Optional[str] = None
|
| 9 |
+
|
| 10 |
+
class ChatMessageBase(BaseModel):
|
| 11 |
+
role: str
|
| 12 |
+
content: str
|
| 13 |
+
sources: Optional[str] = None
|
| 14 |
+
|
| 15 |
+
class ChatMessage(ChatMessageBase):
|
| 16 |
+
id: int
|
| 17 |
+
user_id: str
|
| 18 |
+
session_id: str
|
| 19 |
+
timestamp: datetime
|
| 20 |
+
|
| 21 |
+
class Config:
|
| 22 |
+
from_attributes = True
|
| 23 |
+
|
| 24 |
+
class ChatSessionBase(BaseModel):
|
| 25 |
+
id: str
|
| 26 |
+
title: str
|
| 27 |
+
|
| 28 |
+
class ChatSession(ChatSessionBase):
|
| 29 |
+
user_id: str
|
| 30 |
+
created_at: datetime
|
| 31 |
+
|
| 32 |
+
class Config:
|
| 33 |
+
from_attributes = True
|
backend/app/schemas/document.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
class DocumentBase(BaseModel):
|
| 6 |
+
filename: str
|
| 7 |
+
session_id: Optional[str] = None
|
| 8 |
+
|
| 9 |
+
class DocumentCreate(DocumentBase):
|
| 10 |
+
user_id: str
|
| 11 |
+
|
| 12 |
+
class Document(DocumentBase):
|
| 13 |
+
upload_date: datetime
|
| 14 |
+
|
| 15 |
+
class Config:
|
| 16 |
+
from_attributes = True
|
backend/app/schemas/token.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
|
| 4 |
+
class Token(BaseModel):
|
| 5 |
+
access_token: str
|
| 6 |
+
token_type: str
|
| 7 |
+
|
| 8 |
+
class TokenPayload(BaseModel):
|
| 9 |
+
sub: Optional[str] = None
|
backend/app/schemas/user.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from pydantic import BaseModel, ConfigDict, field_validator
|
| 4 |
+
|
| 5 |
+
class UserBase(BaseModel):
|
| 6 |
+
username: Optional[str] = None
|
| 7 |
+
email: Optional[str] = None
|
| 8 |
+
|
| 9 |
+
class UserCreate(UserBase):
|
| 10 |
+
username: str
|
| 11 |
+
email: str
|
| 12 |
+
password: str
|
| 13 |
+
|
| 14 |
+
@field_validator("password")
|
| 15 |
+
@classmethod
|
| 16 |
+
def password_complexity(cls, v: str) -> str:
|
| 17 |
+
if len(v) < 8:
|
| 18 |
+
raise ValueError("Password must be at least 8 characters long")
|
| 19 |
+
if not re.search(r"[A-Z]", v):
|
| 20 |
+
raise ValueError("Password must contain at least one uppercase letter")
|
| 21 |
+
if not re.search(r"\d", v):
|
| 22 |
+
raise ValueError("Password must contain at least one number")
|
| 23 |
+
if not re.search(r"[@$!%*?&]", v):
|
| 24 |
+
raise ValueError("Password must contain at least one special character (@$!%*?&)")
|
| 25 |
+
return v
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class UserUpdate(UserBase):
|
| 29 |
+
password: Optional[str] = None
|
| 30 |
+
|
| 31 |
+
class UserInDBBase(UserBase):
|
| 32 |
+
id: Optional[int] = None
|
| 33 |
+
|
| 34 |
+
model_config = ConfigDict(from_attributes=True)
|
| 35 |
+
|
| 36 |
+
class User(UserInDBBase):
|
| 37 |
+
pass
|
| 38 |
+
|
| 39 |
+
class UserInDB(UserInDBBase):
|
| 40 |
+
hashed_password: str
|
backend/app/services/__init__.py
ADDED
|
File without changes
|
backend/app/services/pdf_processor.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import fitz # PyMuPDF
|
| 3 |
+
import datetime
|
| 4 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 5 |
+
from ..core.config import settings
|
| 6 |
+
|
| 7 |
+
def process_pdf(pdf_path: str, user_id: str, original_filename: str, session_id: str = None):
|
| 8 |
+
"""
|
| 9 |
+
Extracts text from a PDF file, ignoring margins, and returns chunks with metadata.
|
| 10 |
+
"""
|
| 11 |
+
parent_splitter = RecursiveCharacterTextSplitter(
|
| 12 |
+
chunk_size=settings.PARENT_CHUNK_SIZE,
|
| 13 |
+
chunk_overlap=settings.PARENT_CHUNK_OVERLAP,
|
| 14 |
+
separators=["\n\n", "\n"]
|
| 15 |
+
)
|
| 16 |
+
child_splitter = RecursiveCharacterTextSplitter(
|
| 17 |
+
chunk_size=settings.CHILD_CHUNK_SIZE,
|
| 18 |
+
chunk_overlap=settings.CHILD_CHUNK_OVERLAP,
|
| 19 |
+
separators=["\n", ". ", " ", ""]
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
chunks_with_metadata = []
|
| 23 |
+
chunk_idx = 0
|
| 24 |
+
ingestion_time = datetime.datetime.now().isoformat()
|
| 25 |
+
try:
|
| 26 |
+
with fitz.open(pdf_path) as pdf:
|
| 27 |
+
total_pages = len(pdf)
|
| 28 |
+
for page_num, page in enumerate(pdf, start=1):
|
| 29 |
+
page_rect = page.rect
|
| 30 |
+
footer_threshold = page_rect.height * 0.97
|
| 31 |
+
header_threshold = page_rect.height * 0.03
|
| 32 |
+
|
| 33 |
+
blocks = page.get_text("blocks")
|
| 34 |
+
blocks.sort(key=lambda b: (b[1], b[0]))
|
| 35 |
+
|
| 36 |
+
page_text = ""
|
| 37 |
+
for block in blocks:
|
| 38 |
+
if block[1] > header_threshold and block[3] < footer_threshold:
|
| 39 |
+
if block[6] == 0: # text block
|
| 40 |
+
page_text += block[4] + "\n"
|
| 41 |
+
|
| 42 |
+
if not page_text.strip():
|
| 43 |
+
page_text = page.get_text("text")
|
| 44 |
+
|
| 45 |
+
if not page_text.strip():
|
| 46 |
+
continue
|
| 47 |
+
|
| 48 |
+
parent_chunks = parent_splitter.split_text(page_text)
|
| 49 |
+
for p_chunk in parent_chunks:
|
| 50 |
+
child_chunks = child_splitter.split_text(p_chunk)
|
| 51 |
+
for c_chunk in child_chunks:
|
| 52 |
+
chunk_idx += 1
|
| 53 |
+
metadata = {
|
| 54 |
+
"page": page_num,
|
| 55 |
+
"total_pages": total_pages,
|
| 56 |
+
"chunk_number": chunk_idx,
|
| 57 |
+
"user_id": str(user_id),
|
| 58 |
+
"filename": original_filename,
|
| 59 |
+
"parent_text": p_chunk,
|
| 60 |
+
"ingestion_timestamp": ingestion_time,
|
| 61 |
+
"embedding_model": settings.EMBEDDING_MODEL_NAME,
|
| 62 |
+
"chunk_size": settings.CHILD_CHUNK_SIZE,
|
| 63 |
+
"chunk_overlap": settings.CHILD_CHUNK_OVERLAP,
|
| 64 |
+
"file_type": "pdf"
|
| 65 |
+
}
|
| 66 |
+
if session_id:
|
| 67 |
+
metadata["session_id"] = session_id
|
| 68 |
+
|
| 69 |
+
chunks_with_metadata.append({
|
| 70 |
+
"text": c_chunk,
|
| 71 |
+
"metadata": metadata
|
| 72 |
+
})
|
| 73 |
+
return chunks_with_metadata
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"Error processing PDF {pdf_path}: {e}")
|
| 76 |
+
return []
|
backend/app/services/rag_service.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
import torch
|
| 4 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
| 5 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 6 |
+
from langchain_groq import ChatGroq
|
| 7 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
| 8 |
+
from sentence_transformers import CrossEncoder
|
| 9 |
+
from ..core.config import settings
|
| 10 |
+
import langsmith as ls
|
| 11 |
+
from langsmith import traceable
|
| 12 |
+
|
| 13 |
+
# Suppress noisy logs
|
| 14 |
+
logging.getLogger("sentence_transformers").setLevel(logging.ERROR)
|
| 15 |
+
logging.getLogger("transformers").setLevel(logging.ERROR)
|
| 16 |
+
|
| 17 |
+
class ResilientLLMProxy:
|
| 18 |
+
"""
|
| 19 |
+
A transparent proxy wrapper for LangChain chat models that provides
|
| 20 |
+
resilient runtime failover. If the primary LLM fails (e.g. rate limit,
|
| 21 |
+
service down), it automatically switches to the backup LLM.
|
| 22 |
+
"""
|
| 23 |
+
def __init__(self, primary, backup=None):
|
| 24 |
+
self.primary = primary
|
| 25 |
+
self.backup = backup
|
| 26 |
+
|
| 27 |
+
def invoke(self, *args, **kwargs):
|
| 28 |
+
try:
|
| 29 |
+
return self.primary.invoke(*args, **kwargs)
|
| 30 |
+
except Exception as e:
|
| 31 |
+
if self.backup:
|
| 32 |
+
print(f"\n⚠️ [RUNTIME FAILOVER] Primary LLM ({type(self.primary).__name__}) failed: {e}.\n"
|
| 33 |
+
f"🔄 Falling back to Backup LLM ({type(self.backup).__name__})...")
|
| 34 |
+
return self.backup.invoke(*args, **kwargs)
|
| 35 |
+
raise e
|
| 36 |
+
|
| 37 |
+
async def ainvoke(self, *args, **kwargs):
|
| 38 |
+
try:
|
| 39 |
+
return await self.primary.ainvoke(*args, **kwargs)
|
| 40 |
+
except Exception as e:
|
| 41 |
+
if self.backup:
|
| 42 |
+
print(f"\n⚠️ [RUNTIME FAILOVER] Primary LLM ({type(self.primary).__name__}) failed: {e}.\n"
|
| 43 |
+
f"🔄 Falling back to Backup LLM ({type(self.backup).__name__})...")
|
| 44 |
+
return await self.backup.ainvoke(*args, **kwargs)
|
| 45 |
+
raise e
|
| 46 |
+
|
| 47 |
+
async def astream(self, *args, **kwargs):
|
| 48 |
+
try:
|
| 49 |
+
async for chunk in self.primary.astream(*args, **kwargs):
|
| 50 |
+
yield chunk
|
| 51 |
+
except Exception as e:
|
| 52 |
+
if self.backup:
|
| 53 |
+
print(f"\n⚠️ [RUNTIME FAILOVER] Primary LLM ({type(self.primary).__name__}) streaming failed: {e}.\n"
|
| 54 |
+
f"🔄 Falling back to Backup LLM ({type(self.backup).__name__})...")
|
| 55 |
+
async for chunk in self.backup.astream(*args, **kwargs):
|
| 56 |
+
yield chunk
|
| 57 |
+
else:
|
| 58 |
+
raise e
|
| 59 |
+
|
| 60 |
+
def stream(self, *args, **kwargs):
|
| 61 |
+
try:
|
| 62 |
+
for chunk in self.primary.stream(*args, **kwargs):
|
| 63 |
+
yield chunk
|
| 64 |
+
except Exception as e:
|
| 65 |
+
if self.backup:
|
| 66 |
+
print(f"\n⚠️ [RUNTIME FAILOVER] Primary LLM ({type(self.primary).__name__}) streaming failed: {e}.\n"
|
| 67 |
+
f"🔄 Falling back to Backup LLM ({type(self.backup).__name__})...")
|
| 68 |
+
for chunk in self.backup.stream(*args, **kwargs):
|
| 69 |
+
yield chunk
|
| 70 |
+
else:
|
| 71 |
+
raise e
|
| 72 |
+
|
| 73 |
+
class RAGService:
|
| 74 |
+
def __init__(self):
|
| 75 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 76 |
+
print(f"Loading {settings.EMBEDDING_MODEL_NAME} on {device}...")
|
| 77 |
+
self.embeddings = HuggingFaceEmbeddings(
|
| 78 |
+
model_name=settings.EMBEDDING_MODEL_NAME,
|
| 79 |
+
model_kwargs={"device": device}
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
print(f"Loading {settings.RERANKER_MODEL_NAME} on {device}...")
|
| 83 |
+
self.reranker = CrossEncoder(
|
| 84 |
+
settings.RERANKER_MODEL_NAME,
|
| 85 |
+
trust_remote_code=True,
|
| 86 |
+
device=device,
|
| 87 |
+
automodel_args={"torch_dtype": torch.float16}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Initialize Resilient LLMs
|
| 91 |
+
self.llm = self._setup_llms()
|
| 92 |
+
|
| 93 |
+
def _setup_llms(self):
|
| 94 |
+
# Diagnostics
|
| 95 |
+
groq_key_len = len(settings.GROQ_API_KEY) if settings.GROQ_API_KEY else 0
|
| 96 |
+
gemini_key_len = len(settings.GEMINI_API_KEY) if settings.GEMINI_API_KEY else 0
|
| 97 |
+
print(f"[DIAGNOSTICS] ACTIVE_LLM: {settings.ACTIVE_LLM}")
|
| 98 |
+
print(f"[DIAGNOSTICS] GROQ_API_KEY length: {groq_key_len}, model: {settings.GROQ_MODEL_NAME}")
|
| 99 |
+
print(f"[DIAGNOSTICS] GEMINI_API_KEY length: {gemini_key_len}, model: {settings.LLM_MODEL_NAME}")
|
| 100 |
+
|
| 101 |
+
# 1. Initialize Groq model if key is present
|
| 102 |
+
groq_model = None
|
| 103 |
+
if settings.GROQ_API_KEY:
|
| 104 |
+
try:
|
| 105 |
+
groq_model = ChatGroq(
|
| 106 |
+
model_name=settings.GROQ_MODEL_NAME,
|
| 107 |
+
temperature=settings.LLM_TEMPERATURE,
|
| 108 |
+
groq_api_key=settings.GROQ_API_KEY,
|
| 109 |
+
max_retries=settings.LLM_MAX_RETRIES
|
| 110 |
+
)
|
| 111 |
+
print("[+] Groq model initialized successfully.")
|
| 112 |
+
except Exception as e:
|
| 113 |
+
import traceback
|
| 114 |
+
print(f"[-] Failed to initialize Groq model: {e}")
|
| 115 |
+
traceback.print_exc()
|
| 116 |
+
|
| 117 |
+
# 2. Initialize Gemini model if key is present
|
| 118 |
+
gemini_model = None
|
| 119 |
+
if settings.GEMINI_API_KEY:
|
| 120 |
+
try:
|
| 121 |
+
gemini_model = ChatGoogleGenerativeAI(
|
| 122 |
+
model=settings.LLM_MODEL_NAME,
|
| 123 |
+
google_api_key=settings.GEMINI_API_KEY,
|
| 124 |
+
temperature=settings.LLM_TEMPERATURE,
|
| 125 |
+
max_retries=settings.LLM_MAX_RETRIES
|
| 126 |
+
)
|
| 127 |
+
print("[+] Gemini model initialized successfully.")
|
| 128 |
+
except Exception as e:
|
| 129 |
+
import traceback
|
| 130 |
+
print(f"[-] Failed to initialize Gemini model: {e}")
|
| 131 |
+
traceback.print_exc()
|
| 132 |
+
|
| 133 |
+
# 3. Configure primary and backup models based on active preference
|
| 134 |
+
primary = None
|
| 135 |
+
backup = None
|
| 136 |
+
|
| 137 |
+
if settings.ACTIVE_LLM == "groq" and groq_model:
|
| 138 |
+
primary = groq_model
|
| 139 |
+
backup = gemini_model
|
| 140 |
+
elif gemini_model:
|
| 141 |
+
primary = gemini_model
|
| 142 |
+
backup = groq_model
|
| 143 |
+
else:
|
| 144 |
+
primary = groq_model or gemini_model
|
| 145 |
+
backup = None
|
| 146 |
+
|
| 147 |
+
if not primary:
|
| 148 |
+
raise ValueError(
|
| 149 |
+
f"CRITICAL: No LLM models could be initialized! Please configure at least "
|
| 150 |
+
f"one valid API key (GEMINI_API_KEY or GROQ_API_KEY) in your environment. "
|
| 151 |
+
f"Diagnostics: Groq Key Length={groq_key_len}, Gemini Key Length={gemini_key_len}"
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
print(f"[+] Resilient LLM System loaded successfully.")
|
| 155 |
+
print(f" - Primary Provider: {type(primary).__name__} (Model: {getattr(primary, 'model_name', getattr(primary, 'model', 'default'))})")
|
| 156 |
+
print(f" - Backup Provider: {type(backup).__name__ if backup else 'None'}")
|
| 157 |
+
|
| 158 |
+
return ResilientLLMProxy(primary=primary, backup=backup)
|
| 159 |
+
|
| 160 |
+
@traceable
|
| 161 |
+
def rerank_results(self, query, candidates):
|
| 162 |
+
if not candidates:
|
| 163 |
+
return []
|
| 164 |
+
try:
|
| 165 |
+
cross_inp = [[query, c] for c in candidates]
|
| 166 |
+
scores = self.reranker.predict(cross_inp, batch_size=16, show_progress_bar=False, convert_to_tensor=True)
|
| 167 |
+
return scores.tolist()
|
| 168 |
+
except Exception as e:
|
| 169 |
+
print(f"Reranker Error: {e}. Falling back to default order.")
|
| 170 |
+
return [1.0 - (i * 0.01) for i in range(len(candidates))]
|
| 171 |
+
|
| 172 |
+
def detect_intent(self, query: str) -> str:
|
| 173 |
+
query = query.lower()
|
| 174 |
+
if any(word in query for word in ["summary", "summarize", "brief", "overview"]):
|
| 175 |
+
return "SUMMARY"
|
| 176 |
+
elif any(word in query for word in ["judgment", "outcome", "verdict", "decision"]):
|
| 177 |
+
return "JUDGMENT"
|
| 178 |
+
elif any(word in query for word in ["fact", "evidence", "background"]):
|
| 179 |
+
return "FACTS"
|
| 180 |
+
elif any(word in query for word in ["why", "reasoning", "analysis"]):
|
| 181 |
+
return "REASONING"
|
| 182 |
+
else:
|
| 183 |
+
return "GENERAL"
|
| 184 |
+
|
| 185 |
+
def get_dynamic_prompt(self, intent: str):
|
| 186 |
+
base_rules = """
|
| 187 |
+
CRITICAL RULES:
|
| 188 |
+
1. Base your answer strictly on context.
|
| 189 |
+
2. MISSING INFO: Answer naturally based ONLY on context, don't use repetitive disclaimers.
|
| 190 |
+
3. LEGAL ONLY: If context isn't legal, refuse.
|
| 191 |
+
4. STYLE: Plain text only. No markdown, no bold, no lists. Follow the requested intent in your tone.
|
| 192 |
+
"""
|
| 193 |
+
prompts = {
|
| 194 |
+
"SUMMARY": f"You are a Legal Clerk. Summarize the case background, issue, and outcome. {base_rules}",
|
| 195 |
+
"JUDGMENT": f"You are a Judge. State the final legal outcome clearly. {base_rules}",
|
| 196 |
+
"FACTS": f"You are a Legal Researcher. Extract facts and evidence. {base_rules}",
|
| 197 |
+
"REASONING": f"You are a Constitutional Expert. Explain the reasoning behind the decision. {base_rules}",
|
| 198 |
+
"GENERAL": f"You are a Legal Assistant. Answer the question based on context. {base_rules}"
|
| 199 |
+
}
|
| 200 |
+
return prompts.get(intent, prompts["GENERAL"])
|
| 201 |
+
|
| 202 |
+
@traceable
|
| 203 |
+
async def generate_answer_stream(self, query, context_list, brief=False, trace_metadata=None):
|
| 204 |
+
intent = self.detect_intent(query)
|
| 205 |
+
context_str = "\n\n".join(context_list)
|
| 206 |
+
system_prompt = self.get_dynamic_prompt(intent)
|
| 207 |
+
|
| 208 |
+
if brief:
|
| 209 |
+
system_prompt += "\nINSTRUCTION: Be extremely concise. Focus strictly on the provided context."
|
| 210 |
+
|
| 211 |
+
user_prompt = f"CONTEXT:\n{context_str}\n\nUSER QUESTION: {query}\n\nANSWER BASED ON INTENT ({intent}):"
|
| 212 |
+
|
| 213 |
+
messages = [
|
| 214 |
+
SystemMessage(content=system_prompt),
|
| 215 |
+
HumanMessage(content=user_prompt)
|
| 216 |
+
]
|
| 217 |
+
|
| 218 |
+
async for chunk in self.llm.astream(messages, config={"metadata": trace_metadata}):
|
| 219 |
+
yield chunk.content
|
| 220 |
+
|
| 221 |
+
def validate_is_legal(self, text_sample: str) -> bool:
|
| 222 |
+
if not text_sample or len(text_sample.strip()) < 50:
|
| 223 |
+
return True # Conservative default
|
| 224 |
+
|
| 225 |
+
prompt = (
|
| 226 |
+
"You are a legal document auditor. Is the following text sample from a legal or quasi-legal document? "
|
| 227 |
+
"Count official court judgments, statutes, case summaries, legal commentaries, contracts, and academic legal articles as YES. "
|
| 228 |
+
"Reject unrelated technical manuals, personal letters, or general news that doesn't reference specific law. "
|
| 229 |
+
"Respond with 'YES' or 'NO' only.\n\n"
|
| 230 |
+
f"TEXT SAMPLE:\n{text_sample[:1200]}"
|
| 231 |
+
)
|
| 232 |
+
try:
|
| 233 |
+
response = self.llm.invoke(prompt)
|
| 234 |
+
return "YES" in response.content.upper()
|
| 235 |
+
except:
|
| 236 |
+
return True
|
| 237 |
+
|
| 238 |
+
rag_service = RAGService()
|
backend/app/services/tts.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import warnings
|
| 2 |
+
import threading
|
| 3 |
+
import queue
|
| 4 |
+
import time
|
| 5 |
+
import io
|
| 6 |
+
import numpy as np
|
| 7 |
+
import sounddevice as sd
|
| 8 |
+
import re
|
| 9 |
+
import datetime
|
| 10 |
+
|
| 11 |
+
warnings.filterwarnings("ignore")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
_kokoro_pipeline = None
|
| 15 |
+
_qwen_tts_model = None
|
| 16 |
+
SPEECH_SPEED = 1 # Slower than default 1.0
|
| 17 |
+
|
| 18 |
+
def clean_text_for_speech(text: str) -> str:
|
| 19 |
+
"""Removes citations and other non-spoken markers from text, and formats dates."""
|
| 20 |
+
if not text:
|
| 21 |
+
return ""
|
| 22 |
+
|
| 23 |
+
# 1. Format Dates (DD.MM.YYYY -> Month Day, Year)
|
| 24 |
+
date_pattern = r'\b(\d{1,2})[./-](\d{1,2})[./-](\d{4})\b'
|
| 25 |
+
|
| 26 |
+
def date_replacer(match):
|
| 27 |
+
d_str, m_str, y_str = match.groups()
|
| 28 |
+
try:
|
| 29 |
+
d, m, y = int(d_str), int(m_str), int(y_str)
|
| 30 |
+
dt = datetime.date(y, m, d)
|
| 31 |
+
return dt.strftime("%B %d, %Y")
|
| 32 |
+
except:
|
| 33 |
+
return match.group(0)
|
| 34 |
+
|
| 35 |
+
text = re.sub(date_pattern, date_replacer, text)
|
| 36 |
+
|
| 37 |
+
# 2. Aggressive Source Removal
|
| 38 |
+
# Matches anything in brackets/parens that looks like a PDF reference or Page citation
|
| 39 |
+
source_patterns = [
|
| 40 |
+
r'\[[^\]]*?(?:\.pdf|Pages?:|Source:)[^\]]*?\]',
|
| 41 |
+
r'\([^)]*?(?:\.pdf|Pages?:|Source:)[^)]*?\)'
|
| 42 |
+
]
|
| 43 |
+
for pattern in source_patterns:
|
| 44 |
+
text = re.sub(pattern, '', text, flags=re.IGNORECASE | re.DOTALL)
|
| 45 |
+
|
| 46 |
+
# 3. Fix All-Caps Pronunciation (e.g., SUNITA -> Sunita)
|
| 47 |
+
# TTS engines often spell out ALL CAPS words letter-by-letter.
|
| 48 |
+
# Converting words > 2 chars to Title Case fixes this.
|
| 49 |
+
def to_title_case(match):
|
| 50 |
+
word = match.group(0)
|
| 51 |
+
if len(word) > 2:
|
| 52 |
+
return word.capitalize()
|
| 53 |
+
return word
|
| 54 |
+
|
| 55 |
+
text = re.sub(r'\b[A-Z]{3,}\b', to_title_case, text)
|
| 56 |
+
|
| 57 |
+
# 4. Remove standard citation markers [1], (1)
|
| 58 |
+
text = re.sub(r'\[\d+\]', '', text)
|
| 59 |
+
text = re.sub(r'\(\d+\)', '', text)
|
| 60 |
+
|
| 61 |
+
# Cleanup extra whitespace
|
| 62 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 63 |
+
return text
|
| 64 |
+
|
| 65 |
+
class AudioPlayer:
|
| 66 |
+
def __init__(self):
|
| 67 |
+
self.queue = queue.Queue()
|
| 68 |
+
self.stop_event = threading.Event()
|
| 69 |
+
self.is_playing = False
|
| 70 |
+
self._thread = None
|
| 71 |
+
|
| 72 |
+
def _play_loop(self):
|
| 73 |
+
while not self.stop_event.is_set():
|
| 74 |
+
try:
|
| 75 |
+
# Use a timeout to occasionally check the stop_event
|
| 76 |
+
audio, sr = self.queue.get(timeout=0.5)
|
| 77 |
+
if audio is not None:
|
| 78 |
+
try:
|
| 79 |
+
|
| 80 |
+
self.is_playing = True
|
| 81 |
+
sd.play(audio, sr)
|
| 82 |
+
# We need a way to wait for playback OR stop
|
| 83 |
+
# sd.wait() blocks everything, so we'll poll sd.get_stream().active
|
| 84 |
+
while sd.get_stream().active and not self.stop_event.is_set():
|
| 85 |
+
time.sleep(0.1)
|
| 86 |
+
|
| 87 |
+
if self.stop_event.is_set():
|
| 88 |
+
sd.stop()
|
| 89 |
+
except ImportError:
|
| 90 |
+
print("sounddevice not installed, local playback skipped")
|
| 91 |
+
|
| 92 |
+
self.queue.task_done()
|
| 93 |
+
self.is_playing = False
|
| 94 |
+
except queue.Empty:
|
| 95 |
+
if self.stop_event.is_set():
|
| 96 |
+
break
|
| 97 |
+
continue
|
| 98 |
+
|
| 99 |
+
self.is_playing = False
|
| 100 |
+
# Clear the queue
|
| 101 |
+
while not self.queue.empty():
|
| 102 |
+
try:
|
| 103 |
+
self.queue.get_nowait()
|
| 104 |
+
self.queue.task_done()
|
| 105 |
+
except queue.Empty:
|
| 106 |
+
break
|
| 107 |
+
|
| 108 |
+
def start(self):
|
| 109 |
+
self.stop_event.clear()
|
| 110 |
+
if self._thread is None or not self._thread.is_alive():
|
| 111 |
+
self._thread = threading.Thread(target=self._play_loop, daemon=True)
|
| 112 |
+
self._thread.start()
|
| 113 |
+
|
| 114 |
+
def stop(self):
|
| 115 |
+
self.stop_event.set()
|
| 116 |
+
try:
|
| 117 |
+
|
| 118 |
+
sd.stop()
|
| 119 |
+
except ImportError:
|
| 120 |
+
pass
|
| 121 |
+
if self._thread:
|
| 122 |
+
self._thread.join(timeout=1)
|
| 123 |
+
self.is_playing = False
|
| 124 |
+
|
| 125 |
+
def add_to_queue(self, audio, sr):
|
| 126 |
+
self.queue.put((audio, sr))
|
| 127 |
+
|
| 128 |
+
_player = AudioPlayer()
|
| 129 |
+
|
| 130 |
+
def get_kokoro_pipeline():
|
| 131 |
+
global _kokoro_pipeline
|
| 132 |
+
if _kokoro_pipeline is None:
|
| 133 |
+
from kokoro import KPipeline
|
| 134 |
+
try:
|
| 135 |
+
# In newer kokoro versions, repo_id is not a parameter and it defaults to hexgrad/Kokoro-82M
|
| 136 |
+
_kokoro_pipeline = KPipeline(lang_code='a')
|
| 137 |
+
except TypeError:
|
| 138 |
+
_kokoro_pipeline = KPipeline(repo_id="hexgrad/Kokoro-82M", lang_code='a')
|
| 139 |
+
return _kokoro_pipeline
|
| 140 |
+
|
| 141 |
+
def get_qwen_tts_model():
|
| 142 |
+
global _qwen_tts_model
|
| 143 |
+
if _qwen_tts_model is None:
|
| 144 |
+
import torch
|
| 145 |
+
from qwen_tts import Qwen3TTSModel
|
| 146 |
+
|
| 147 |
+
# Use float16 for better memory efficiency and speed if CUDA is available
|
| 148 |
+
dtype = torch.float16 if torch.cuda.is_available() else torch.float32
|
| 149 |
+
|
| 150 |
+
print(f"Initializing Qwen3-TTS (dtype={dtype})...")
|
| 151 |
+
_qwen_tts_model = Qwen3TTSModel.from_pretrained(
|
| 152 |
+
"Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice",
|
| 153 |
+
device_map="auto", # Let accelerate handle optimal placement
|
| 154 |
+
dtype=dtype,
|
| 155 |
+
attn_implementation="eager",
|
| 156 |
+
)
|
| 157 |
+
return _qwen_tts_model
|
| 158 |
+
|
| 159 |
+
def generate_audio(text):
|
| 160 |
+
text = clean_text_for_speech(text)
|
| 161 |
+
model = get_qwen_tts_model()
|
| 162 |
+
wavs, sr = model.generate_custom_voice(
|
| 163 |
+
text=[text],
|
| 164 |
+
language=["english"],
|
| 165 |
+
speaker=["sohee"],
|
| 166 |
+
instruct=None
|
| 167 |
+
)
|
| 168 |
+
_player.start()
|
| 169 |
+
_player.add_to_queue(wavs[0], sr)
|
| 170 |
+
|
| 171 |
+
def audio_generate(text):
|
| 172 |
+
text = clean_text_for_speech(text)
|
| 173 |
+
pipeline = get_kokoro_pipeline()
|
| 174 |
+
voice = "af_sarah"
|
| 175 |
+
|
| 176 |
+
# Start the player thread
|
| 177 |
+
_player.start()
|
| 178 |
+
|
| 179 |
+
# 🔊 Generate chunks asynchronously
|
| 180 |
+
generator = pipeline(text, voice=voice, speed=SPEECH_SPEED)
|
| 181 |
+
|
| 182 |
+
for i, (gs, ps, audio) in enumerate(generator):
|
| 183 |
+
if _player.stop_event.is_set():
|
| 184 |
+
break
|
| 185 |
+
print(f"Chunk {i} ready and queued")
|
| 186 |
+
_player.add_to_queue(audio, 24000)
|
| 187 |
+
|
| 188 |
+
def get_tts_wav(text):
|
| 189 |
+
text = clean_text_for_speech(text)
|
| 190 |
+
pipeline = get_kokoro_pipeline()
|
| 191 |
+
voice = "af_sarah"
|
| 192 |
+
|
| 193 |
+
# Generate all chunks
|
| 194 |
+
generator = pipeline(text, voice=voice, speed=SPEECH_SPEED)
|
| 195 |
+
all_chunks = []
|
| 196 |
+
for gs, ps, audio in generator:
|
| 197 |
+
all_chunks.append(audio)
|
| 198 |
+
|
| 199 |
+
if not all_chunks:
|
| 200 |
+
return None
|
| 201 |
+
|
| 202 |
+
# Concatenate all chunks
|
| 203 |
+
full_audio = np.concatenate(all_chunks)
|
| 204 |
+
|
| 205 |
+
# Write to BytesIO buffer as WAV
|
| 206 |
+
buffer = io.BytesIO()
|
| 207 |
+
import soundfile as sf
|
| 208 |
+
sf.write(buffer, full_audio, 24000, format='WAV')
|
| 209 |
+
buffer.seek(0)
|
| 210 |
+
return buffer.read()
|
| 211 |
+
|
| 212 |
+
def stream_tts_wav_chunks(text, cancel_event=None):
|
| 213 |
+
# Important: Do NOT clean the entire block at once.
|
| 214 |
+
# We need to keep the raw sentence for the frontend highlighter.
|
| 215 |
+
pipeline = get_kokoro_pipeline()
|
| 216 |
+
voice = "af_sarah"
|
| 217 |
+
# 1. Split RAW text into sentences while protecting legal abbreviations
|
| 218 |
+
# Standard Python 're' doesn't support variable-width lookbehind,
|
| 219 |
+
# so we use a 'Protection' strategy.
|
| 220 |
+
protected_text = text
|
| 221 |
+
abbreviations = ['No', 'v', 'vs', 'Art', 'Sec', 'para', 'exh', 'cl', 'st', 'adv', 'cr', 'rev', 'app', 'spl', 'petn', 'writ', 'pil', 'scc', 'air', 'scr', 'ilr', 'guj', 'bom', 'del', 'Mr', 'Mrs', 'Ms', 'Dr', 'Justice', 'Hon', 'Honble', 'Honourable', 'Chief']
|
| 222 |
+
|
| 223 |
+
# Temporarily hide the periods in abbreviations
|
| 224 |
+
for abbr in abbreviations:
|
| 225 |
+
# Match 'No.' but not 'No' inside a word
|
| 226 |
+
protected_text = re.sub(rf'\b{abbr}\.', f'{abbr}@@@', protected_text, flags=re.IGNORECASE)
|
| 227 |
+
|
| 228 |
+
# Protect Initials (e.g., D.N. Ray)
|
| 229 |
+
# This matches a single capital letter followed by a period
|
| 230 |
+
protected_text = re.sub(r'\b([A-Z])\.', r'\1@@@', protected_text)
|
| 231 |
+
|
| 232 |
+
# Now split safely
|
| 233 |
+
split_chunks = re.split(r'(?<=[.!?])\s+', protected_text)
|
| 234 |
+
|
| 235 |
+
# Restore the periods and clean up
|
| 236 |
+
raw_sentences = []
|
| 237 |
+
for chunk in split_chunks:
|
| 238 |
+
s = chunk.replace('@@@', '.').strip()
|
| 239 |
+
if s:
|
| 240 |
+
raw_sentences.append(s)
|
| 241 |
+
|
| 242 |
+
if not raw_sentences:
|
| 243 |
+
return
|
| 244 |
+
|
| 245 |
+
import soundfile as sf
|
| 246 |
+
import io
|
| 247 |
+
import base64
|
| 248 |
+
import json
|
| 249 |
+
import threading
|
| 250 |
+
import queue
|
| 251 |
+
|
| 252 |
+
import numpy as np
|
| 253 |
+
q = queue.Queue(maxsize=10)
|
| 254 |
+
stop_signal = threading.Event()
|
| 255 |
+
|
| 256 |
+
def producer():
|
| 257 |
+
try:
|
| 258 |
+
for s_raw in raw_sentences:
|
| 259 |
+
# Check ALL stop conditions: internal stop, global stop, or client disconnect
|
| 260 |
+
if stop_signal.is_set() or _player.stop_event.is_set() or (cancel_event and cancel_event.is_set()):
|
| 261 |
+
break
|
| 262 |
+
|
| 263 |
+
# 2. Clean ONLY for speech engine
|
| 264 |
+
s_clean = clean_text_for_speech(s_raw)
|
| 265 |
+
if not s_clean:
|
| 266 |
+
continue
|
| 267 |
+
|
| 268 |
+
generator = pipeline(s_clean, voice=voice, speed=SPEECH_SPEED)
|
| 269 |
+
sentence_audio_chunks = []
|
| 270 |
+
for gs, ps, audio in generator:
|
| 271 |
+
if (cancel_event and cancel_event.is_set()) or _player.stop_event.is_set():
|
| 272 |
+
break
|
| 273 |
+
sentence_audio_chunks.append(audio)
|
| 274 |
+
|
| 275 |
+
if sentence_audio_chunks:
|
| 276 |
+
# Aggregate all grains of the sentence into ONE continuous array
|
| 277 |
+
# This eliminates breaks/pauses inside the sentence.
|
| 278 |
+
full_audio = np.concatenate(sentence_audio_chunks)
|
| 279 |
+
|
| 280 |
+
buffer = io.BytesIO()
|
| 281 |
+
sf.write(buffer, full_audio, 24000, format='WAV')
|
| 282 |
+
buffer.seek(0)
|
| 283 |
+
audio_bytes = buffer.read()
|
| 284 |
+
q.put((s_raw, audio_bytes))
|
| 285 |
+
q.put(None)
|
| 286 |
+
except Exception as e:
|
| 287 |
+
print(f"TTS Producer error: {e}")
|
| 288 |
+
q.put(None)
|
| 289 |
+
|
| 290 |
+
worker = threading.Thread(target=producer, daemon=True)
|
| 291 |
+
worker.start()
|
| 292 |
+
|
| 293 |
+
try:
|
| 294 |
+
while True:
|
| 295 |
+
item = q.get()
|
| 296 |
+
if item is None:
|
| 297 |
+
break
|
| 298 |
+
|
| 299 |
+
s, audio_bytes = item
|
| 300 |
+
audio_b64 = base64.b64encode(audio_bytes).decode('utf-8')
|
| 301 |
+
|
| 302 |
+
# Yield as NDJSON event with text metadata for highlighting
|
| 303 |
+
yield json.dumps({"audio": audio_b64, "text": s}) + "\n"
|
| 304 |
+
finally:
|
| 305 |
+
stop_signal.set()
|
| 306 |
+
# Drain the queue if needed
|
| 307 |
+
while not q.empty():
|
| 308 |
+
q.get_nowait()
|
| 309 |
+
|
| 310 |
+
def warm_up_tts():
|
| 311 |
+
"""Initializes the TTS pipeline with a dummy generation to remove cold-start latency."""
|
| 312 |
+
print("Pre-warming TTS Engine...")
|
| 313 |
+
try:
|
| 314 |
+
pipeline = get_kokoro_pipeline()
|
| 315 |
+
# Single very short generation to trigger model weights loading
|
| 316 |
+
list(pipeline("Warmup.", voice="af_sarah", speed=1.0))
|
| 317 |
+
print("TTS Engine warmed up and ready.")
|
| 318 |
+
except Exception as e:
|
| 319 |
+
print(f"TTS Warmup failed: {e}")
|
| 320 |
+
|
| 321 |
+
def stop_audio():
|
| 322 |
+
_player.stop()
|
| 323 |
+
|
| 324 |
+
def is_audio_playing():
|
| 325 |
+
return _player.is_playing or not _player.queue.empty()
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
if __name__ == "__main__":
|
| 329 |
+
audio_generate("She said she would be here by noon.")
|
| 330 |
+
# wavs, sr = generate_audio("She said she would be here by noon.")
|
| 331 |
+
|
backend/app/services/voice_service.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import threading
|
| 3 |
+
import tempfile
|
| 4 |
+
import wave
|
| 5 |
+
import pyaudio
|
| 6 |
+
import torch
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from ..core.config import settings
|
| 9 |
+
|
| 10 |
+
SAMPLE_RATE = 16000
|
| 11 |
+
CHUNK = 1024
|
| 12 |
+
CHANNELS = 1
|
| 13 |
+
FORMAT = pyaudio.paInt16
|
| 14 |
+
|
| 15 |
+
class VoiceService:
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.frames = []
|
| 18 |
+
self.is_recording = False
|
| 19 |
+
self.thread = None
|
| 20 |
+
self.stream = None
|
| 21 |
+
self.pa = None
|
| 22 |
+
self.lock = threading.Lock()
|
| 23 |
+
self.thread_started = threading.Event()
|
| 24 |
+
self._asr_model = None
|
| 25 |
+
|
| 26 |
+
def start_recording(self):
|
| 27 |
+
with self.lock:
|
| 28 |
+
if self.is_recording:
|
| 29 |
+
return {"status": "already_recording"}
|
| 30 |
+
self.frames = []
|
| 31 |
+
self.is_recording = True
|
| 32 |
+
self.thread_started.clear()
|
| 33 |
+
self.pa = pyaudio.PyAudio()
|
| 34 |
+
self.stream = self.pa.open(
|
| 35 |
+
format=FORMAT,
|
| 36 |
+
channels=CHANNELS,
|
| 37 |
+
rate=SAMPLE_RATE,
|
| 38 |
+
input=True,
|
| 39 |
+
frames_per_buffer=CHUNK,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
def _capture():
|
| 43 |
+
self.thread_started.set()
|
| 44 |
+
while self.is_recording:
|
| 45 |
+
try:
|
| 46 |
+
data = self.stream.read(CHUNK, exception_on_overflow=False)
|
| 47 |
+
with self.lock:
|
| 48 |
+
if self.is_recording:
|
| 49 |
+
self.frames.append(data)
|
| 50 |
+
except:
|
| 51 |
+
break
|
| 52 |
+
self.thread_started.set()
|
| 53 |
+
|
| 54 |
+
self.thread = threading.Thread(target=_capture, daemon=False)
|
| 55 |
+
self.thread.start()
|
| 56 |
+
self.thread_started.wait(timeout=1)
|
| 57 |
+
return {"status": "recording_started"}
|
| 58 |
+
|
| 59 |
+
def stop_recording(self) -> str:
|
| 60 |
+
with self.lock:
|
| 61 |
+
if not self.is_recording:
|
| 62 |
+
raise RuntimeError("No active recording.")
|
| 63 |
+
self.is_recording = False
|
| 64 |
+
|
| 65 |
+
if self.thread and self.thread.is_alive():
|
| 66 |
+
self.thread.join(timeout=2)
|
| 67 |
+
|
| 68 |
+
with self.lock:
|
| 69 |
+
if self.stream:
|
| 70 |
+
self.stream.stop_stream()
|
| 71 |
+
self.stream.close()
|
| 72 |
+
if self.pa:
|
| 73 |
+
self.pa.terminate()
|
| 74 |
+
frames = list(self.frames)
|
| 75 |
+
self.thread = None
|
| 76 |
+
self.stream = None
|
| 77 |
+
self.pa = None
|
| 78 |
+
|
| 79 |
+
if not frames:
|
| 80 |
+
raise RuntimeError("No audio captured.")
|
| 81 |
+
|
| 82 |
+
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
| 83 |
+
with wave.open(tmp.name, "wb") as wf:
|
| 84 |
+
wf.setnchannels(CHANNELS)
|
| 85 |
+
wf.setsampwidth(pyaudio.PyAudio().get_sample_size(FORMAT))
|
| 86 |
+
wf.setframerate(SAMPLE_RATE)
|
| 87 |
+
wf.writeframes(b"".join(frames))
|
| 88 |
+
return tmp.name
|
| 89 |
+
|
| 90 |
+
def preload_models(self):
|
| 91 |
+
"""Preloads ASR and TTS models into memory/GPU."""
|
| 92 |
+
print(f"Loading Qwen3-ASR...")
|
| 93 |
+
self._load_asr_model()
|
| 94 |
+
|
| 95 |
+
print(f"Loading Kokoro TTS...")
|
| 96 |
+
from .tts import warm_up_tts
|
| 97 |
+
warm_up_tts()
|
| 98 |
+
|
| 99 |
+
def _load_asr_model(self):
|
| 100 |
+
if self._asr_model:
|
| 101 |
+
return self._asr_model
|
| 102 |
+
from qwen_asr import Qwen3ASRModel
|
| 103 |
+
|
| 104 |
+
# Use float16 for better memory efficiency and speed if CUDA is available
|
| 105 |
+
dtype = torch.float16 if torch.cuda.is_available() else torch.float32
|
| 106 |
+
|
| 107 |
+
print(f"Initializing Qwen3-ASR (dtype={dtype})...")
|
| 108 |
+
self._asr_model = Qwen3ASRModel.from_pretrained(
|
| 109 |
+
"Qwen/Qwen3-ASR-0.6B",
|
| 110 |
+
torch_dtype=dtype,
|
| 111 |
+
device_map="auto" # Let accelerate handle optimal placement
|
| 112 |
+
)
|
| 113 |
+
return self._asr_model
|
| 114 |
+
|
| 115 |
+
def transcribe(self, audio_path: str) -> str:
|
| 116 |
+
model = self._load_asr_model()
|
| 117 |
+
result = model.transcribe(audio_path, language="English")
|
| 118 |
+
if not result: return ""
|
| 119 |
+
item = result[0] if isinstance(result, list) else result
|
| 120 |
+
return getattr(item, 'text', str(item))
|
| 121 |
+
|
| 122 |
+
def create_voice_router(self):
|
| 123 |
+
from fastapi import APIRouter, HTTPException, UploadFile, File
|
| 124 |
+
from pydantic import BaseModel
|
| 125 |
+
|
| 126 |
+
router = APIRouter(prefix="/voice", tags=["voice"])
|
| 127 |
+
|
| 128 |
+
class TranscriptionResponse(BaseModel):
|
| 129 |
+
query: str
|
| 130 |
+
status: str
|
| 131 |
+
|
| 132 |
+
@router.post("/start")
|
| 133 |
+
def voice_start():
|
| 134 |
+
return self.start_recording()
|
| 135 |
+
|
| 136 |
+
@router.post("/stop", response_model=TranscriptionResponse)
|
| 137 |
+
def voice_stop():
|
| 138 |
+
path = self.stop_recording()
|
| 139 |
+
try:
|
| 140 |
+
text = self.transcribe(path)
|
| 141 |
+
return TranscriptionResponse(query=text, status="ok" if text.strip() else "no_speech")
|
| 142 |
+
finally:
|
| 143 |
+
os.unlink(path)
|
| 144 |
+
|
| 145 |
+
@router.post("/transcribe", response_model=TranscriptionResponse)
|
| 146 |
+
async def voice_transcribe(file: UploadFile = File(...)):
|
| 147 |
+
suffix = os.path.splitext(file.filename or "")[1] or ".webm"
|
| 148 |
+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
| 149 |
+
tmp.write(await file.read())
|
| 150 |
+
tmp_path = tmp.name
|
| 151 |
+
try:
|
| 152 |
+
text = self.transcribe(tmp_path)
|
| 153 |
+
return TranscriptionResponse(query=text, status="ok" if text.strip() else "no_speech")
|
| 154 |
+
finally:
|
| 155 |
+
os.unlink(tmp_path)
|
| 156 |
+
|
| 157 |
+
return router
|
| 158 |
+
|
| 159 |
+
voice_service = VoiceService()
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
# ── Qdrant Vector Database ──────────────────────────────────────────────────
|
| 3 |
+
qdrant:
|
| 4 |
+
image: qdrant/qdrant:latest
|
| 5 |
+
container_name: rag_qdrant
|
| 6 |
+
restart: unless-stopped
|
| 7 |
+
ports:
|
| 8 |
+
- "6333:6333" # HTTP API
|
| 9 |
+
- "6334:6334" # gRPC API
|
| 10 |
+
volumes:
|
| 11 |
+
- qdrant_data:/qdrant/storage
|
| 12 |
+
healthcheck:
|
| 13 |
+
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/6333' 2>/dev/null && exit 0 || exit 1"]
|
| 14 |
+
interval: 10s
|
| 15 |
+
timeout: 5s
|
| 16 |
+
retries: 10
|
| 17 |
+
start_period: 20s
|
| 18 |
+
|
| 19 |
+
# ── PostgreSQL (optional – only used when USE_POSTGRES=true) ────────────────
|
| 20 |
+
postgres:
|
| 21 |
+
image: postgres:16-alpine
|
| 22 |
+
container_name: rag_postgres
|
| 23 |
+
restart: unless-stopped
|
| 24 |
+
environment:
|
| 25 |
+
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
| 26 |
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
|
| 27 |
+
POSTGRES_DB: ${POSTGRES_DB:-legal_rag}
|
| 28 |
+
ports:
|
| 29 |
+
- "5432:5432"
|
| 30 |
+
volumes:
|
| 31 |
+
- postgres_data:/var/lib/postgresql/data
|
| 32 |
+
healthcheck:
|
| 33 |
+
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
| 34 |
+
interval: 10s
|
| 35 |
+
timeout: 5s
|
| 36 |
+
retries: 5
|
| 37 |
+
|
| 38 |
+
# ── FastAPI Backend ─────────────────────────────────────────────────────────
|
| 39 |
+
backend:
|
| 40 |
+
build:
|
| 41 |
+
context: . # project root — gives access to requirements.txt
|
| 42 |
+
dockerfile: backend/Dockerfile
|
| 43 |
+
container_name: rag_backend
|
| 44 |
+
restart: unless-stopped
|
| 45 |
+
env_file:
|
| 46 |
+
- .env
|
| 47 |
+
environment:
|
| 48 |
+
# Override DB host to point to the postgres container
|
| 49 |
+
POSTGRES_SERVER: postgres
|
| 50 |
+
# Point Qdrant client to the qdrant container over HTTP
|
| 51 |
+
QDRANT_URL: http://qdrant:6333
|
| 52 |
+
ports:
|
| 53 |
+
- "8001:8001"
|
| 54 |
+
volumes:
|
| 55 |
+
# Persist SQLite DB and uploaded documents
|
| 56 |
+
- backend_data:/app/app/data
|
| 57 |
+
depends_on:
|
| 58 |
+
qdrant:
|
| 59 |
+
condition: service_started
|
| 60 |
+
postgres:
|
| 61 |
+
condition: service_healthy
|
| 62 |
+
healthcheck:
|
| 63 |
+
test: ["CMD", "curl", "-f", "http://localhost:8001/docs"]
|
| 64 |
+
interval: 20s
|
| 65 |
+
timeout: 15s
|
| 66 |
+
retries: 15
|
| 67 |
+
start_period: 300s # InLegalBERT + BGE reranker can take 3-5 min to load
|
| 68 |
+
|
| 69 |
+
# ── React Frontend (Nginx) ──────────────────────────────────────────────────
|
| 70 |
+
frontend:
|
| 71 |
+
build:
|
| 72 |
+
context: ./frontend-react
|
| 73 |
+
dockerfile: Dockerfile
|
| 74 |
+
container_name: rag_frontend
|
| 75 |
+
restart: unless-stopped
|
| 76 |
+
ports:
|
| 77 |
+
- "8080:80"
|
| 78 |
+
depends_on:
|
| 79 |
+
- backend
|
| 80 |
+
|
| 81 |
+
volumes:
|
| 82 |
+
qdrant_data:
|
| 83 |
+
postgres_data:
|
| 84 |
+
backend_data:
|
frontend-react/.dockerignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
dist/
|
| 3 |
+
.env
|
| 4 |
+
*.log
|
| 5 |
+
.git/
|
| 6 |
+
.gitignore
|
| 7 |
+
README.md
|
frontend-react/Dockerfile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Stage 1: Build React app ──────────────────────────────────────────────────
|
| 2 |
+
FROM node:20-alpine AS builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
COPY package.json package-lock.json ./
|
| 7 |
+
RUN npm ci --frozen-lockfile
|
| 8 |
+
|
| 9 |
+
COPY . .
|
| 10 |
+
RUN npm run build
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# ── Stage 2: Serve with Nginx ─────────────────────────────────────────────────
|
| 14 |
+
FROM nginx:alpine AS runtime
|
| 15 |
+
|
| 16 |
+
# Remove default nginx config
|
| 17 |
+
RUN rm /etc/nginx/conf.d/default.conf
|
| 18 |
+
|
| 19 |
+
# Copy custom nginx config
|
| 20 |
+
COPY nginx.conf /etc/nginx/conf.d/app.conf
|
| 21 |
+
|
| 22 |
+
# Copy built React app
|
| 23 |
+
COPY --from=builder /app/dist /usr/share/nginx/html
|
| 24 |
+
|
| 25 |
+
EXPOSE 80
|
| 26 |
+
|
| 27 |
+
CMD ["nginx", "-g", "daemon off;"]
|
frontend-react/index.html
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8" />
|
| 6 |
+
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
| 7 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 8 |
+
<title>Legal Case Law RAG</title>
|
| 9 |
+
</head>
|
| 10 |
+
|
| 11 |
+
<body>
|
| 12 |
+
<div id="root"></div>
|
| 13 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 14 |
+
</body>
|
| 15 |
+
|
| 16 |
+
</html>
|
frontend-react/nginx.conf
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
server {
|
| 2 |
+
listen 80;
|
| 3 |
+
server_name _;
|
| 4 |
+
|
| 5 |
+
root /usr/share/nginx/html;
|
| 6 |
+
index index.html;
|
| 7 |
+
|
| 8 |
+
# Proxy API calls to the FastAPI backend
|
| 9 |
+
location /api/ {
|
| 10 |
+
proxy_pass http://backend:8001/;
|
| 11 |
+
proxy_http_version 1.1;
|
| 12 |
+
proxy_set_header Host $host;
|
| 13 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 14 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 15 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 16 |
+
proxy_read_timeout 120s;
|
| 17 |
+
proxy_send_timeout 120s;
|
| 18 |
+
|
| 19 |
+
# WebSocket / SSE support
|
| 20 |
+
proxy_set_header Upgrade $http_upgrade;
|
| 21 |
+
proxy_set_header Connection "upgrade";
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
# Serve React SPA — fallback to index.html for client-side routing
|
| 25 |
+
location / {
|
| 26 |
+
try_files $uri $uri/ /index.html;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
# Gzip compression
|
| 30 |
+
gzip on;
|
| 31 |
+
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
| 32 |
+
}
|
frontend-react/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend-react/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend-react",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"lucide-react": "^1.8.0",
|
| 14 |
+
"react": "^19.2.4",
|
| 15 |
+
"react-dom": "^19.2.4",
|
| 16 |
+
"react-markdown": "^10.1.0",
|
| 17 |
+
"rehype-raw": "^7.0.0",
|
| 18 |
+
"remark-gfm": "^4.0.1"
|
| 19 |
+
},
|
| 20 |
+
"devDependencies": {
|
| 21 |
+
"@eslint/js": "^9.39.4",
|
| 22 |
+
"@types/react": "^19.2.14",
|
| 23 |
+
"@types/react-dom": "^19.2.3",
|
| 24 |
+
"@vitejs/plugin-react": "^6.0.0",
|
| 25 |
+
"eslint": "^9.39.4",
|
| 26 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 27 |
+
"eslint-plugin-react-refresh": "^0.5.2",
|
| 28 |
+
"globals": "^17.4.0",
|
| 29 |
+
"vite": "^8.0.0"
|
| 30 |
+
}
|
| 31 |
+
}
|
frontend-react/src/App.css
ADDED
|
@@ -0,0 +1,1459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap');
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
/* Premium Rich Dark Palette - Midnight Amethyst Theme */
|
| 5 |
+
--bg-deep: #05020c;
|
| 6 |
+
--bg-surface: #0c081f;
|
| 7 |
+
--bg-surface-elevated: #17113b;
|
| 8 |
+
--sidebar-bg: #080515;
|
| 9 |
+
|
| 10 |
+
/* Jewel Primary & Accents - Royalty Amethyst and Empire Amber/Gold */
|
| 11 |
+
--primary: #a855f7;
|
| 12 |
+
--primary-glow: rgba(168, 85, 247, 0.45);
|
| 13 |
+
--accent: #f59e0b;
|
| 14 |
+
--accent-glow: rgba(245, 158, 11, 0.35);
|
| 15 |
+
|
| 16 |
+
/* Text */
|
| 17 |
+
--text-primary: #f8fafc;
|
| 18 |
+
--text-secondary: #cbd5e1;
|
| 19 |
+
--text-muted: #71717a;
|
| 20 |
+
|
| 21 |
+
/* Premium Glassmorphism */
|
| 22 |
+
--glass-bg: rgba(12, 8, 31, 0.75);
|
| 23 |
+
--glass-border: rgba(168, 85, 247, 0.16);
|
| 24 |
+
--glass-blur: blur(16px);
|
| 25 |
+
|
| 26 |
+
/* Utility */
|
| 27 |
+
--border-radius: 14px;
|
| 28 |
+
--transition-fast: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
| 29 |
+
--transition-slow: 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
| 30 |
+
--shadow-sm: 0 4px 8px -1px rgba(0, 0, 0, 0.2);
|
| 31 |
+
--shadow-lg: 0 12px 24px -3px rgba(0, 0, 0, 0.4);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
* {
|
| 35 |
+
box-sizing: border-box;
|
| 36 |
+
-webkit-font-smoothing: antialiased;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
body {
|
| 40 |
+
margin: 0;
|
| 41 |
+
padding: 0;
|
| 42 |
+
background-color: var(--bg-deep);
|
| 43 |
+
color: var(--text-primary);
|
| 44 |
+
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
| 45 |
+
overflow: hidden;
|
| 46 |
+
height: 100vh;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
h1,
|
| 50 |
+
h2,
|
| 51 |
+
h3,
|
| 52 |
+
.brand-font {
|
| 53 |
+
font-family: 'Lora', serif;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/* Layout */
|
| 57 |
+
.App {
|
| 58 |
+
display: flex;
|
| 59 |
+
height: 100vh;
|
| 60 |
+
width: 100vw;
|
| 61 |
+
background: radial-gradient(circle at 50% -20%, rgba(168, 85, 247, 0.15) 0%, rgba(245, 158, 11, 0.04) 40%, var(--bg-deep) 80%);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.App.blurred {
|
| 65 |
+
filter: blur(8px);
|
| 66 |
+
pointer-events: none;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/* Sidebar Styling */
|
| 70 |
+
.sidebar {
|
| 71 |
+
width: 280px;
|
| 72 |
+
background: var(--sidebar-bg);
|
| 73 |
+
border-right: 1px solid var(--glass-border);
|
| 74 |
+
display: flex;
|
| 75 |
+
flex-direction: column;
|
| 76 |
+
transition: transform var(--transition-slow), width var(--transition-slow);
|
| 77 |
+
z-index: 100;
|
| 78 |
+
position: relative;
|
| 79 |
+
overflow: hidden;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.sidebar.collapsed {
|
| 83 |
+
width: 0;
|
| 84 |
+
border-right: none;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.sidebar-content {
|
| 88 |
+
display: flex;
|
| 89 |
+
flex-direction: column;
|
| 90 |
+
height: 100%;
|
| 91 |
+
width: 280px;
|
| 92 |
+
/* Fixed width for content to prevent squishing */
|
| 93 |
+
padding: 1.5rem 1rem;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.sidebar-header {
|
| 97 |
+
display: flex;
|
| 98 |
+
align-items: center;
|
| 99 |
+
gap: 0.75rem;
|
| 100 |
+
margin-bottom: 2rem;
|
| 101 |
+
padding: 0 0.5rem;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.sidebar-header h2 {
|
| 105 |
+
font-size: 1.25rem;
|
| 106 |
+
font-weight: 600;
|
| 107 |
+
margin: 0;
|
| 108 |
+
background: linear-gradient(135deg, var(--accent) 0%, #d946ef 50%, var(--primary) 100%);
|
| 109 |
+
-webkit-background-clip: text;
|
| 110 |
+
background-clip: text;
|
| 111 |
+
-webkit-text-fill-color: transparent;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.new-chat-btn {
|
| 115 |
+
width: 100%;
|
| 116 |
+
padding: 0.875rem;
|
| 117 |
+
background: var(--primary);
|
| 118 |
+
color: white;
|
| 119 |
+
border: none;
|
| 120 |
+
border-radius: var(--border-radius);
|
| 121 |
+
font-weight: 600;
|
| 122 |
+
cursor: pointer;
|
| 123 |
+
display: flex;
|
| 124 |
+
align-items: center;
|
| 125 |
+
justify-content: center;
|
| 126 |
+
gap: 0.5rem;
|
| 127 |
+
transition: all var(--transition-fast);
|
| 128 |
+
box-shadow: 0 4px 12px var(--primary-glow);
|
| 129 |
+
margin-bottom: 1.5rem;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.new-chat-btn:hover {
|
| 133 |
+
transform: translateY(-1px);
|
| 134 |
+
box-shadow: 0 6px 16px var(--primary-glow);
|
| 135 |
+
background: #4f46e5;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.document-list-container {
|
| 139 |
+
flex: 1;
|
| 140 |
+
overflow-y: auto;
|
| 141 |
+
scrollbar-width: thin;
|
| 142 |
+
scrollbar-color: var(--glass-border) transparent;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.document-list-container::-webkit-scrollbar {
|
| 146 |
+
width: 6px;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.document-list-container::-webkit-scrollbar-thumb {
|
| 150 |
+
background-color: var(--glass-border);
|
| 151 |
+
border-radius: 10px;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.list-section {
|
| 155 |
+
margin-bottom: 2rem;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.list-section h3 {
|
| 159 |
+
font-size: 0.75rem;
|
| 160 |
+
text-transform: uppercase;
|
| 161 |
+
letter-spacing: 0.05em;
|
| 162 |
+
color: var(--text-muted);
|
| 163 |
+
margin-bottom: 0.75rem;
|
| 164 |
+
padding-left: 0.5rem;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.session-item,
|
| 168 |
+
.doc-item {
|
| 169 |
+
display: flex;
|
| 170 |
+
align-items: center;
|
| 171 |
+
gap: 0.75rem;
|
| 172 |
+
padding: 0.75rem;
|
| 173 |
+
border-radius: 10px;
|
| 174 |
+
cursor: pointer;
|
| 175 |
+
transition: all var(--transition-fast);
|
| 176 |
+
color: var(--text-secondary);
|
| 177 |
+
margin-bottom: 0.25rem;
|
| 178 |
+
position: relative;
|
| 179 |
+
border: 1px solid transparent;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.session-item:hover,
|
| 183 |
+
.doc-item:hover {
|
| 184 |
+
background: rgba(255, 255, 255, 0.03);
|
| 185 |
+
color: var(--text-primary);
|
| 186 |
+
border-color: var(--glass-border);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.session-item.active {
|
| 190 |
+
background: rgba(99, 102, 241, 0.1);
|
| 191 |
+
color: var(--primary);
|
| 192 |
+
border-color: rgba(99, 102, 241, 0.2);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.session-title,
|
| 196 |
+
.doc-name {
|
| 197 |
+
flex: 1;
|
| 198 |
+
font-size: 0.875rem;
|
| 199 |
+
white-space: nowrap;
|
| 200 |
+
overflow: hidden;
|
| 201 |
+
text-overflow: ellipsis;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.doc-info {
|
| 205 |
+
display: flex;
|
| 206 |
+
flex-direction: column;
|
| 207 |
+
flex: 1;
|
| 208 |
+
min-width: 0;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.doc-date {
|
| 212 |
+
font-size: 0.7rem;
|
| 213 |
+
color: var(--text-muted);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.delete-btn {
|
| 217 |
+
opacity: 0;
|
| 218 |
+
transition: opacity var(--transition-fast);
|
| 219 |
+
padding: 4px;
|
| 220 |
+
color: var(--text-muted);
|
| 221 |
+
background: none;
|
| 222 |
+
border: none;
|
| 223 |
+
cursor: pointer;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.session-item:hover .delete-btn,
|
| 227 |
+
.doc-item:hover .delete-btn {
|
| 228 |
+
opacity: 1;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.delete-btn:hover {
|
| 232 |
+
color: #ef4444;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.sidebar-footer {
|
| 236 |
+
margin-top: auto;
|
| 237 |
+
padding-top: 0.75rem;
|
| 238 |
+
border-top: 1px solid var(--glass-border);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.user-profile {
|
| 242 |
+
display: flex;
|
| 243 |
+
flex-direction: column;
|
| 244 |
+
padding: 0.75rem;
|
| 245 |
+
border-radius: var(--border-radius);
|
| 246 |
+
background: transparent;
|
| 247 |
+
transition: all var(--transition-fast);
|
| 248 |
+
border: 1px solid transparent;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.user-profile:hover {
|
| 252 |
+
background: rgba(255, 255, 255, 0.03);
|
| 253 |
+
border-color: var(--glass-border);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.user-profile.expanded {
|
| 257 |
+
background: var(--bg-surface-elevated);
|
| 258 |
+
border-color: var(--primary-glow);
|
| 259 |
+
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.25);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.user-profile-header {
|
| 263 |
+
display: flex;
|
| 264 |
+
align-items: center;
|
| 265 |
+
width: 100%;
|
| 266 |
+
gap: 0.75rem;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.user-avatar {
|
| 270 |
+
width: 36px;
|
| 271 |
+
height: 36px;
|
| 272 |
+
background: linear-gradient(135deg, var(--primary), var(--accent));
|
| 273 |
+
border-radius: 50%;
|
| 274 |
+
display: flex;
|
| 275 |
+
align-items: center;
|
| 276 |
+
justify-content: center;
|
| 277 |
+
color: white;
|
| 278 |
+
font-weight: 600;
|
| 279 |
+
font-size: 0.9rem;
|
| 280 |
+
box-shadow: 0 0 10px rgba(168, 85, 247, 0.3);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.user-info-basic {
|
| 284 |
+
flex: 1;
|
| 285 |
+
min-width: 0;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.username-display {
|
| 289 |
+
font-size: 0.9rem;
|
| 290 |
+
font-weight: 600;
|
| 291 |
+
color: var(--text-primary);
|
| 292 |
+
white-space: nowrap;
|
| 293 |
+
overflow: hidden;
|
| 294 |
+
text-overflow: ellipsis;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.user-profile-details {
|
| 298 |
+
margin-top: 0.75rem;
|
| 299 |
+
padding-top: 0.75rem;
|
| 300 |
+
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
| 301 |
+
display: flex;
|
| 302 |
+
flex-direction: column;
|
| 303 |
+
gap: 0.5rem;
|
| 304 |
+
animation: fadeInFast 0.2s ease-out;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
@keyframes fadeInFast {
|
| 308 |
+
from { opacity: 0; transform: translateY(-5px); }
|
| 309 |
+
to { opacity: 1; transform: translateY(0); }
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
.user-detail-item {
|
| 313 |
+
display: flex;
|
| 314 |
+
flex-direction: column;
|
| 315 |
+
gap: 2px;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.detail-label {
|
| 319 |
+
font-size: 0.7rem;
|
| 320 |
+
text-transform: uppercase;
|
| 321 |
+
letter-spacing: 0.05em;
|
| 322 |
+
color: var(--text-muted);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.detail-value {
|
| 326 |
+
font-size: 0.8rem;
|
| 327 |
+
color: var(--text-secondary);
|
| 328 |
+
word-break: break-all;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.logout-action-btn {
|
| 332 |
+
display: flex;
|
| 333 |
+
align-items: center;
|
| 334 |
+
justify-content: center;
|
| 335 |
+
gap: 0.5rem;
|
| 336 |
+
font-size: 0.85rem;
|
| 337 |
+
font-weight: 600;
|
| 338 |
+
border-radius: 8px;
|
| 339 |
+
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
|
| 340 |
+
box-shadow: 0 4px 10px rgba(239, 68, 68, 0.2) !important;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.logout-action-btn:hover {
|
| 344 |
+
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
|
| 345 |
+
box-shadow: 0 6px 15px rgba(239, 68, 68, 0.35) !important;
|
| 346 |
+
transform: translateY(-1px);
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
/* Main Chat Area */
|
| 350 |
+
.chat-main {
|
| 351 |
+
flex: 1;
|
| 352 |
+
display: flex;
|
| 353 |
+
flex-direction: column;
|
| 354 |
+
position: relative;
|
| 355 |
+
background: transparent;
|
| 356 |
+
min-width: 0;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.chat-header {
|
| 360 |
+
height: 4rem;
|
| 361 |
+
padding: 0 2rem;
|
| 362 |
+
display: flex;
|
| 363 |
+
align-items: center;
|
| 364 |
+
justify-content: space-between;
|
| 365 |
+
background: rgba(10, 12, 16, 0.4);
|
| 366 |
+
backdrop-filter: var(--glass-blur);
|
| 367 |
+
border-bottom: 1px solid var(--glass-border);
|
| 368 |
+
z-index: 50;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.header-left {
|
| 372 |
+
display: flex;
|
| 373 |
+
align-items: center;
|
| 374 |
+
gap: 1rem;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.sidebar-toggle {
|
| 378 |
+
background: none;
|
| 379 |
+
border: none;
|
| 380 |
+
color: var(--text-secondary);
|
| 381 |
+
cursor: pointer;
|
| 382 |
+
padding: 8px;
|
| 383 |
+
display: flex;
|
| 384 |
+
align-items: center;
|
| 385 |
+
justify-content: center;
|
| 386 |
+
border-radius: 8px;
|
| 387 |
+
transition: all var(--transition-fast);
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.sidebar-toggle:hover {
|
| 391 |
+
background: var(--bg-surface-elevated);
|
| 392 |
+
color: var(--text-primary);
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.chat-header h1 {
|
| 396 |
+
font-size: 1.125rem;
|
| 397 |
+
font-weight: 600;
|
| 398 |
+
margin: 0;
|
| 399 |
+
color: var(--text-primary);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.chat-messages {
|
| 403 |
+
flex: 1;
|
| 404 |
+
overflow-y: auto;
|
| 405 |
+
padding: 2rem;
|
| 406 |
+
display: flex;
|
| 407 |
+
flex-direction: column;
|
| 408 |
+
gap: 1.5rem;
|
| 409 |
+
scrollbar-width: thin;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.chat-messages::-webkit-scrollbar {
|
| 413 |
+
width: 6px;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.chat-messages::-webkit-scrollbar-thumb {
|
| 417 |
+
background: var(--glass-border);
|
| 418 |
+
border-radius: 10px;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
/* Message Bubbles */
|
| 422 |
+
.message-bubble {
|
| 423 |
+
max-width: 80%;
|
| 424 |
+
display: flex;
|
| 425 |
+
flex-direction: column;
|
| 426 |
+
padding: 1.25rem;
|
| 427 |
+
border-radius: 1.25rem;
|
| 428 |
+
font-size: 0.95rem;
|
| 429 |
+
line-height: 1.6;
|
| 430 |
+
position: relative;
|
| 431 |
+
animation: messageIn 0.3s ease-out forwards;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
@keyframes messageIn {
|
| 435 |
+
from {
|
| 436 |
+
opacity: 0;
|
| 437 |
+
transform: translateY(10px);
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
to {
|
| 441 |
+
opacity: 1;
|
| 442 |
+
transform: translateY(0);
|
| 443 |
+
}
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.message-bubble.user {
|
| 447 |
+
align-self: flex-end;
|
| 448 |
+
background: var(--primary);
|
| 449 |
+
color: white;
|
| 450 |
+
border-bottom-right-radius: 4px;
|
| 451 |
+
box-shadow: 0 4px 15px var(--primary-glow);
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.message-bubble.assistant {
|
| 455 |
+
align-self: flex-start;
|
| 456 |
+
background: var(--bg-surface-elevated);
|
| 457 |
+
color: var(--text-primary);
|
| 458 |
+
border-bottom-left-radius: 4px;
|
| 459 |
+
border: 1px solid var(--glass-border);
|
| 460 |
+
display: flex !important;
|
| 461 |
+
flex-direction: row !important;
|
| 462 |
+
align-items: flex-start;
|
| 463 |
+
gap: 1rem;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.message-content {
|
| 467 |
+
flex: 1;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.speak-btn {
|
| 471 |
+
background: rgba(255, 255, 255, 0.03);
|
| 472 |
+
border: 1px solid var(--glass-border);
|
| 473 |
+
color: var(--text-muted);
|
| 474 |
+
width: 32px;
|
| 475 |
+
height: 32px;
|
| 476 |
+
border-radius: 8px;
|
| 477 |
+
display: flex;
|
| 478 |
+
align-items: center;
|
| 479 |
+
justify-content: center;
|
| 480 |
+
cursor: pointer;
|
| 481 |
+
transition: all var(--transition-fast);
|
| 482 |
+
flex-shrink: 0;
|
| 483 |
+
margin-top: 2px;
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
.speak-btn:hover {
|
| 487 |
+
background: rgba(255, 255, 255, 0.08);
|
| 488 |
+
color: var(--text-primary);
|
| 489 |
+
border-color: var(--glass-border);
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
.speak-btn.speaking {
|
| 493 |
+
background: rgba(99, 102, 241, 0.1);
|
| 494 |
+
color: var(--primary);
|
| 495 |
+
border-color: var(--primary);
|
| 496 |
+
animation: pulse 1.5s infinite;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
@keyframes pulse {
|
| 500 |
+
0% {
|
| 501 |
+
opacity: 1;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
50% {
|
| 505 |
+
opacity: 0.6;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
100% {
|
| 509 |
+
opacity: 1;
|
| 510 |
+
}
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
/* Welcome / Empty state */
|
| 514 |
+
.welcome-screen {
|
| 515 |
+
max-width: 580px;
|
| 516 |
+
margin: auto;
|
| 517 |
+
text-align: center;
|
| 518 |
+
padding: 2.5rem 2rem;
|
| 519 |
+
background: rgba(12, 8, 31, 0.4);
|
| 520 |
+
border: 1px solid rgba(168, 85, 247, 0.12);
|
| 521 |
+
border-radius: 24px;
|
| 522 |
+
backdrop-filter: blur(20px);
|
| 523 |
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
| 524 |
+
animation: cardFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
| 525 |
+
display: flex;
|
| 526 |
+
flex-direction: column;
|
| 527 |
+
align-items: center;
|
| 528 |
+
gap: 1.25rem;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.welcome-screen h2 {
|
| 532 |
+
font-size: 2rem;
|
| 533 |
+
font-weight: 700;
|
| 534 |
+
margin: 0;
|
| 535 |
+
background: linear-gradient(135deg, #ffffff 0%, #cbd5e1 100%);
|
| 536 |
+
-webkit-background-clip: text;
|
| 537 |
+
background-clip: text;
|
| 538 |
+
-webkit-text-fill-color: transparent;
|
| 539 |
+
font-family: 'Lora', serif;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
.welcome-subtitle {
|
| 543 |
+
font-size: 0.9rem;
|
| 544 |
+
color: var(--text-secondary);
|
| 545 |
+
max-width: 480px;
|
| 546 |
+
margin: 0;
|
| 547 |
+
line-height: 1.5;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
/* System Pills Row */
|
| 551 |
+
.system-pills-row {
|
| 552 |
+
display: flex;
|
| 553 |
+
justify-content: center;
|
| 554 |
+
flex-wrap: wrap;
|
| 555 |
+
gap: 0.75rem;
|
| 556 |
+
width: 100%;
|
| 557 |
+
margin: 0.25rem 0;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
.system-pill {
|
| 561 |
+
display: flex;
|
| 562 |
+
align-items: center;
|
| 563 |
+
gap: 0.5rem;
|
| 564 |
+
padding: 0.5rem 1rem;
|
| 565 |
+
background: rgba(168, 85, 247, 0.04);
|
| 566 |
+
border: 1px solid rgba(168, 85, 247, 0.15);
|
| 567 |
+
border-radius: 99px;
|
| 568 |
+
color: var(--text-secondary);
|
| 569 |
+
font-size: 0.8rem;
|
| 570 |
+
font-weight: 500;
|
| 571 |
+
cursor: default;
|
| 572 |
+
transition: all var(--transition-fast);
|
| 573 |
+
user-select: none;
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
.system-pill:hover {
|
| 577 |
+
border-color: rgba(168, 85, 247, 0.3);
|
| 578 |
+
background: rgba(168, 85, 247, 0.06);
|
| 579 |
+
color: var(--text-primary);
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
.pill-icon {
|
| 583 |
+
color: var(--primary);
|
| 584 |
+
flex-shrink: 0;
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
/* Start Chat Button */
|
| 588 |
+
.start-chat-btn {
|
| 589 |
+
display: inline-flex;
|
| 590 |
+
align-items: center;
|
| 591 |
+
justify-content: center;
|
| 592 |
+
padding: 0.75rem 2rem;
|
| 593 |
+
font-size: 0.95rem;
|
| 594 |
+
font-weight: 600;
|
| 595 |
+
color: white;
|
| 596 |
+
background: linear-gradient(135deg, var(--primary) 0%, #7c3aed 100%);
|
| 597 |
+
border: none;
|
| 598 |
+
border-radius: 12px;
|
| 599 |
+
cursor: pointer;
|
| 600 |
+
transition: all var(--transition-fast);
|
| 601 |
+
box-shadow: 0 4px 15px rgba(168, 85, 247, 0.25);
|
| 602 |
+
margin-top: 0.25rem;
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
.start-chat-btn:hover {
|
| 606 |
+
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
| 607 |
+
box-shadow: 0 6px 20px rgba(168, 85, 247, 0.4);
|
| 608 |
+
transform: translateY(-1px);
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
@media (max-width: 768px) {
|
| 612 |
+
.system-pills-row {
|
| 613 |
+
flex-direction: column;
|
| 614 |
+
align-items: center;
|
| 615 |
+
gap: 0.5rem;
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
/* Chat Input Bar */
|
| 620 |
+
.chat-input-area {
|
| 621 |
+
padding: 1.5rem 2rem 2rem;
|
| 622 |
+
background: linear-gradient(to bottom, transparent, var(--bg-deep));
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
.chat-input-wrapper {
|
| 626 |
+
max-width: 800px;
|
| 627 |
+
margin: 0 auto;
|
| 628 |
+
background: var(--bg-surface);
|
| 629 |
+
border: 1px solid var(--glass-border);
|
| 630 |
+
border-radius: 1.25rem;
|
| 631 |
+
padding: 0.5rem;
|
| 632 |
+
display: flex;
|
| 633 |
+
align-items: center;
|
| 634 |
+
gap: 0.5rem;
|
| 635 |
+
box-shadow: var(--shadow-lg);
|
| 636 |
+
transition: border-color var(--transition-fast);
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
.chat-input-wrapper:focus-within {
|
| 640 |
+
border-color: var(--primary);
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
.chat-input-wrapper input {
|
| 644 |
+
flex: 1;
|
| 645 |
+
background: none;
|
| 646 |
+
border: none;
|
| 647 |
+
padding: 0.75rem;
|
| 648 |
+
color: var(--text-primary);
|
| 649 |
+
font-size: 1rem;
|
| 650 |
+
outline: none;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
.icon-btn {
|
| 654 |
+
background: none;
|
| 655 |
+
border: none;
|
| 656 |
+
color: var(--text-muted);
|
| 657 |
+
padding: 0.6rem;
|
| 658 |
+
cursor: pointer;
|
| 659 |
+
border-radius: 10px;
|
| 660 |
+
transition: all var(--transition-fast);
|
| 661 |
+
display: flex;
|
| 662 |
+
align-items: center;
|
| 663 |
+
justify-content: center;
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
.icon-btn:hover:not(:disabled) {
|
| 667 |
+
background: var(--bg-surface-elevated);
|
| 668 |
+
color: var(--text-primary);
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
.send-btn {
|
| 672 |
+
background: var(--primary);
|
| 673 |
+
color: white;
|
| 674 |
+
width: 40px;
|
| 675 |
+
height: 40px;
|
| 676 |
+
border-radius: 10px;
|
| 677 |
+
display: flex;
|
| 678 |
+
align-items: center;
|
| 679 |
+
justify-content: center;
|
| 680 |
+
border: none;
|
| 681 |
+
cursor: pointer;
|
| 682 |
+
transition: all var(--transition-fast);
|
| 683 |
+
box-shadow: 0 4px 10px var(--primary-glow);
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
.send-btn:hover:not(:disabled) {
|
| 687 |
+
transform: scale(1.05);
|
| 688 |
+
background: #4f46e5;
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
.send-btn:disabled {
|
| 692 |
+
opacity: 0.5;
|
| 693 |
+
cursor: not-allowed;
|
| 694 |
+
box-shadow: none;
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
/* Loading Effects */
|
| 698 |
+
.loading-overlay {
|
| 699 |
+
position: fixed;
|
| 700 |
+
inset: 0;
|
| 701 |
+
background: rgba(0, 0, 0, 0.8);
|
| 702 |
+
backdrop-filter: blur(8px);
|
| 703 |
+
z-index: 1000;
|
| 704 |
+
display: flex;
|
| 705 |
+
flex-direction: column;
|
| 706 |
+
align-items: center;
|
| 707 |
+
justify-content: center;
|
| 708 |
+
gap: 1.5rem;
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
.loader-ring {
|
| 712 |
+
width: 60px;
|
| 713 |
+
height: 60px;
|
| 714 |
+
border: 3px solid rgba(255, 255, 255, 0.05);
|
| 715 |
+
border-top: 3px solid var(--primary);
|
| 716 |
+
border-radius: 50%;
|
| 717 |
+
animation: spin 1s linear infinite;
|
| 718 |
+
box-shadow: 0 0 15px var(--primary-glow);
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
@keyframes spin {
|
| 722 |
+
to {
|
| 723 |
+
transform: rotate(360deg);
|
| 724 |
+
}
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
.loading-dots:after {
|
| 728 |
+
content: ' .';
|
| 729 |
+
animation: dots 1.5s infinite;
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
@keyframes dots {
|
| 733 |
+
|
| 734 |
+
0%,
|
| 735 |
+
20% {
|
| 736 |
+
content: ' .';
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
40% {
|
| 740 |
+
content: ' ..';
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
60% {
|
| 744 |
+
content: ' ...';
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
80%,
|
| 748 |
+
100% {
|
| 749 |
+
content: '';
|
| 750 |
+
}
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
/* Unique Split-Screen Auth Page */
|
| 754 |
+
.auth-screen {
|
| 755 |
+
display: flex;
|
| 756 |
+
width: 100vw;
|
| 757 |
+
height: 100vh;
|
| 758 |
+
background: #04020a;
|
| 759 |
+
overflow: hidden;
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
.auth-graphic-side {
|
| 763 |
+
flex: 1.2;
|
| 764 |
+
background: radial-gradient(circle at 30% 20%, rgba(168, 85, 247, 0.22) 0%, rgba(245, 158, 11, 0.06) 40%, #04020a 100%);
|
| 765 |
+
display: flex;
|
| 766 |
+
flex-direction: column;
|
| 767 |
+
justify-content: center;
|
| 768 |
+
padding: 5rem;
|
| 769 |
+
position: relative;
|
| 770 |
+
border-right: 1px solid rgba(168, 85, 247, 0.1);
|
| 771 |
+
overflow: hidden;
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
.auth-graphic-side::before {
|
| 775 |
+
content: '';
|
| 776 |
+
position: absolute;
|
| 777 |
+
top: -20%;
|
| 778 |
+
left: -20%;
|
| 779 |
+
width: 60%;
|
| 780 |
+
height: 60%;
|
| 781 |
+
background: radial-gradient(circle, rgba(168, 85, 247, 0.15) 0%, transparent 60%);
|
| 782 |
+
filter: blur(80px);
|
| 783 |
+
animation: floatOrb 8s infinite alternate ease-in-out;
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
@keyframes floatOrb {
|
| 787 |
+
0% { transform: translate(0, 0) scale(1); }
|
| 788 |
+
100% { transform: translate(30px, 20px) scale(1.1); }
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
.graphic-brand {
|
| 792 |
+
display: flex;
|
| 793 |
+
align-items: center;
|
| 794 |
+
gap: 1rem;
|
| 795 |
+
margin-bottom: 3.5rem;
|
| 796 |
+
z-index: 10;
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
.graphic-brand-logo {
|
| 800 |
+
width: 48px;
|
| 801 |
+
height: 48px;
|
| 802 |
+
background: linear-gradient(135deg, var(--primary), var(--accent));
|
| 803 |
+
border-radius: 12px;
|
| 804 |
+
display: flex;
|
| 805 |
+
align-items: center;
|
| 806 |
+
justify-content: center;
|
| 807 |
+
box-shadow: 0 0 20px rgba(168, 85, 247, 0.35);
|
| 808 |
+
flex-shrink: 0;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
.graphic-brand-name {
|
| 812 |
+
font-family: 'Outfit', sans-serif;
|
| 813 |
+
font-size: 1.5rem;
|
| 814 |
+
font-weight: 700;
|
| 815 |
+
background: linear-gradient(135deg, var(--accent) 0%, #d946ef 50%, var(--primary) 100%);
|
| 816 |
+
-webkit-background-clip: text;
|
| 817 |
+
background-clip: text;
|
| 818 |
+
-webkit-text-fill-color: transparent;
|
| 819 |
+
letter-spacing: -0.02em;
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
.graphic-content {
|
| 823 |
+
z-index: 10;
|
| 824 |
+
max-width: 550px;
|
| 825 |
+
animation: fadeIn 0.8s ease-out;
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
.graphic-content h2 {
|
| 829 |
+
font-size: 2.75rem;
|
| 830 |
+
font-weight: 800;
|
| 831 |
+
line-height: 1.25;
|
| 832 |
+
margin-bottom: 1.5rem;
|
| 833 |
+
background: linear-gradient(to right, #ffffff, #d8b4fe);
|
| 834 |
+
-webkit-background-clip: text;
|
| 835 |
+
background-clip: text;
|
| 836 |
+
-webkit-text-fill-color: transparent;
|
| 837 |
+
font-family: 'Outfit', sans-serif;
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
.graphic-content p {
|
| 841 |
+
font-size: 1.05rem;
|
| 842 |
+
color: var(--text-secondary);
|
| 843 |
+
line-height: 1.7;
|
| 844 |
+
margin-bottom: 3rem;
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
.feature-highlights {
|
| 848 |
+
display: flex;
|
| 849 |
+
flex-direction: column;
|
| 850 |
+
gap: 1.5rem;
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
.feature-item {
|
| 854 |
+
display: flex;
|
| 855 |
+
align-items: center;
|
| 856 |
+
gap: 1rem;
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
.feature-icon-wrapper {
|
| 860 |
+
width: 38px;
|
| 861 |
+
height: 38px;
|
| 862 |
+
border-radius: 10px;
|
| 863 |
+
background: rgba(168, 85, 247, 0.08);
|
| 864 |
+
border: 1px solid rgba(168, 85, 247, 0.15);
|
| 865 |
+
display: flex;
|
| 866 |
+
align-items: center;
|
| 867 |
+
justify-content: center;
|
| 868 |
+
color: var(--primary);
|
| 869 |
+
flex-shrink: 0;
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
.feature-text {
|
| 873 |
+
font-size: 0.95rem;
|
| 874 |
+
color: var(--text-secondary);
|
| 875 |
+
font-weight: 500;
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
+
.auth-form-side {
|
| 879 |
+
flex: 1;
|
| 880 |
+
background: #04020a;
|
| 881 |
+
display: flex;
|
| 882 |
+
align-items: center;
|
| 883 |
+
justify-content: center;
|
| 884 |
+
padding: 3rem;
|
| 885 |
+
position: relative;
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
.auth-form-side::after {
|
| 889 |
+
content: '';
|
| 890 |
+
position: absolute;
|
| 891 |
+
bottom: -20%;
|
| 892 |
+
right: -20%;
|
| 893 |
+
width: 50%;
|
| 894 |
+
height: 50%;
|
| 895 |
+
background: radial-gradient(circle, rgba(245, 158, 11, 0.06) 0%, transparent 60%);
|
| 896 |
+
filter: blur(80px);
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
.auth-card {
|
| 900 |
+
width: 100%;
|
| 901 |
+
max-width: 490px;
|
| 902 |
+
padding: 3rem;
|
| 903 |
+
background: rgba(12, 8, 31, 0.5);
|
| 904 |
+
backdrop-filter: blur(20px);
|
| 905 |
+
border: 1px solid rgba(168, 85, 247, 0.12);
|
| 906 |
+
border-radius: 24px;
|
| 907 |
+
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
| 908 |
+
animation: cardFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
| 909 |
+
z-index: 10;
|
| 910 |
+
}
|
| 911 |
+
|
| 912 |
+
@keyframes cardFadeIn {
|
| 913 |
+
from {
|
| 914 |
+
opacity: 0;
|
| 915 |
+
transform: translateY(20px) scale(0.98);
|
| 916 |
+
}
|
| 917 |
+
to {
|
| 918 |
+
opacity: 1;
|
| 919 |
+
transform: translateY(0) scale(1);
|
| 920 |
+
}
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
.auth-card h1 {
|
| 924 |
+
font-size: 2rem;
|
| 925 |
+
font-weight: 700;
|
| 926 |
+
margin-bottom: 0.5rem;
|
| 927 |
+
text-align: center;
|
| 928 |
+
background: linear-gradient(135deg, #ffffff 0%, #cbd5e1 100%);
|
| 929 |
+
-webkit-background-clip: text;
|
| 930 |
+
background-clip: text;
|
| 931 |
+
-webkit-text-fill-color: transparent;
|
| 932 |
+
}
|
| 933 |
+
|
| 934 |
+
.auth-subtitle {
|
| 935 |
+
text-align: center;
|
| 936 |
+
color: var(--text-secondary);
|
| 937 |
+
margin-bottom: 2rem;
|
| 938 |
+
font-size: 0.9rem;
|
| 939 |
+
line-height: 1.5;
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
.form-group {
|
| 943 |
+
margin-bottom: 1.25rem;
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
.form-group label {
|
| 947 |
+
display: block;
|
| 948 |
+
font-size: 0.8rem;
|
| 949 |
+
font-weight: 600;
|
| 950 |
+
text-transform: uppercase;
|
| 951 |
+
letter-spacing: 0.05em;
|
| 952 |
+
margin-bottom: 0.5rem;
|
| 953 |
+
color: var(--text-muted);
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
.form-group input {
|
| 957 |
+
width: 100%;
|
| 958 |
+
padding: 0.875rem 1rem;
|
| 959 |
+
background: rgba(5, 2, 12, 0.6);
|
| 960 |
+
border: 1px solid rgba(168, 85, 247, 0.15);
|
| 961 |
+
border-radius: 12px;
|
| 962 |
+
color: white;
|
| 963 |
+
outline: none;
|
| 964 |
+
transition: all var(--transition-fast);
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
.form-group input:focus {
|
| 968 |
+
border-color: var(--primary);
|
| 969 |
+
box-shadow: 0 0 0 4px rgba(168, 85, 247, 0.15);
|
| 970 |
+
}
|
| 971 |
+
|
| 972 |
+
.auth-btn {
|
| 973 |
+
width: 100%;
|
| 974 |
+
padding: 1rem;
|
| 975 |
+
background: linear-gradient(135deg, var(--primary) 0%, #7c3aed 100%);
|
| 976 |
+
color: white;
|
| 977 |
+
border: none;
|
| 978 |
+
border-radius: 12px;
|
| 979 |
+
font-size: 1rem;
|
| 980 |
+
font-weight: 600;
|
| 981 |
+
cursor: pointer;
|
| 982 |
+
transition: all var(--transition-fast);
|
| 983 |
+
margin-top: 1rem;
|
| 984 |
+
box-shadow: 0 4px 15px rgba(168, 85, 247, 0.3);
|
| 985 |
+
}
|
| 986 |
+
|
| 987 |
+
.auth-btn:hover:not(:disabled) {
|
| 988 |
+
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
| 989 |
+
box-shadow: 0 6px 20px rgba(168, 85, 247, 0.45);
|
| 990 |
+
transform: translateY(-1px);
|
| 991 |
+
}
|
| 992 |
+
|
| 993 |
+
.auth-btn:disabled {
|
| 994 |
+
opacity: 0.6;
|
| 995 |
+
cursor: not-allowed;
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
.toggle-auth {
|
| 999 |
+
text-align: center;
|
| 1000 |
+
margin-top: 1.5rem;
|
| 1001 |
+
font-size: 0.875rem;
|
| 1002 |
+
color: var(--text-secondary);
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
.toggle-auth span {
|
| 1006 |
+
color: var(--primary);
|
| 1007 |
+
cursor: pointer;
|
| 1008 |
+
font-weight: 600;
|
| 1009 |
+
transition: color var(--transition-fast);
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
.toggle-auth span:hover {
|
| 1013 |
+
color: #c084fc;
|
| 1014 |
+
text-decoration: underline;
|
| 1015 |
+
}
|
| 1016 |
+
|
| 1017 |
+
/* Responsiveness */
|
| 1018 |
+
@media (max-width: 1024px) {
|
| 1019 |
+
.auth-graphic-side {
|
| 1020 |
+
display: none;
|
| 1021 |
+
}
|
| 1022 |
+
.auth-form-side {
|
| 1023 |
+
flex: 1;
|
| 1024 |
+
padding: 2rem;
|
| 1025 |
+
}
|
| 1026 |
+
}
|
| 1027 |
+
|
| 1028 |
+
/* Voice Button recording animation */
|
| 1029 |
+
.voice-btn.recording {
|
| 1030 |
+
background: rgba(239, 68, 68, 0.1);
|
| 1031 |
+
color: #ef4444;
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
.voice-stop-icon {
|
| 1035 |
+
width: 12px;
|
| 1036 |
+
height: 12px;
|
| 1037 |
+
background: currentColor;
|
| 1038 |
+
border-radius: 2px;
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
/* Premium Selector Panel (Inspired by ڈیزائن) */
|
| 1042 |
+
.selector-panel {
|
| 1043 |
+
background: var(--bg-surface);
|
| 1044 |
+
border: 1px solid var(--glass-border);
|
| 1045 |
+
border-radius: 1.5rem;
|
| 1046 |
+
max-width: 800px;
|
| 1047 |
+
margin: 0 auto 1.5rem;
|
| 1048 |
+
padding: 1.5rem;
|
| 1049 |
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
| 1050 |
+
animation: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1051 |
+
}
|
| 1052 |
+
|
| 1053 |
+
@keyframes slideUp {
|
| 1054 |
+
from {
|
| 1055 |
+
opacity: 0;
|
| 1056 |
+
transform: translateY(20px);
|
| 1057 |
+
}
|
| 1058 |
+
|
| 1059 |
+
to {
|
| 1060 |
+
opacity: 1;
|
| 1061 |
+
transform: translateY(0);
|
| 1062 |
+
}
|
| 1063 |
+
}
|
| 1064 |
+
|
| 1065 |
+
.selector-header {
|
| 1066 |
+
display: flex;
|
| 1067 |
+
justify-content: space-between;
|
| 1068 |
+
align-items: center;
|
| 1069 |
+
margin-bottom: 1.5rem;
|
| 1070 |
+
padding: 0 0.5rem;
|
| 1071 |
+
}
|
| 1072 |
+
|
| 1073 |
+
.selector-header h3 {
|
| 1074 |
+
font-size: 1.25rem;
|
| 1075 |
+
font-weight: 600;
|
| 1076 |
+
margin: 0;
|
| 1077 |
+
color: var(--text-primary);
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
.selector-pagination {
|
| 1081 |
+
font-size: 0.875rem;
|
| 1082 |
+
color: var(--text-muted);
|
| 1083 |
+
font-family: 'Plus Jakarta Sans', sans-serif;
|
| 1084 |
+
letter-spacing: 0.05em;
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
.selector-list {
|
| 1088 |
+
display: flex;
|
| 1089 |
+
flex-direction: column;
|
| 1090 |
+
}
|
| 1091 |
+
|
| 1092 |
+
.selector-item {
|
| 1093 |
+
display: flex;
|
| 1094 |
+
align-items: center;
|
| 1095 |
+
gap: 1.25rem;
|
| 1096 |
+
padding: 1.25rem 1.5rem;
|
| 1097 |
+
cursor: pointer;
|
| 1098 |
+
transition: all var(--transition-fast);
|
| 1099 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
| 1100 |
+
position: relative;
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
.selector-item:last-child {
|
| 1104 |
+
border-bottom: none;
|
| 1105 |
+
}
|
| 1106 |
+
|
| 1107 |
+
.selector-item:hover {
|
| 1108 |
+
background: rgba(255, 255, 255, 0.03);
|
| 1109 |
+
}
|
| 1110 |
+
|
| 1111 |
+
.selector-item.active {
|
| 1112 |
+
background: rgba(99, 102, 241, 0.08);
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
+
.item-number {
|
| 1116 |
+
width: 32px;
|
| 1117 |
+
height: 32px;
|
| 1118 |
+
background: #1a1d24;
|
| 1119 |
+
border-radius: 8px;
|
| 1120 |
+
display: flex;
|
| 1121 |
+
align-items: center;
|
| 1122 |
+
justify-content: center;
|
| 1123 |
+
font-size: 0.875rem;
|
| 1124 |
+
font-weight: 600;
|
| 1125 |
+
color: var(--text-secondary);
|
| 1126 |
+
flex-shrink: 0;
|
| 1127 |
+
}
|
| 1128 |
+
|
| 1129 |
+
.selector-item.active .item-number {
|
| 1130 |
+
background: var(--primary);
|
| 1131 |
+
color: white;
|
| 1132 |
+
box-shadow: 0 0 10px var(--primary-glow);
|
| 1133 |
+
}
|
| 1134 |
+
|
| 1135 |
+
.item-info {
|
| 1136 |
+
flex: 1;
|
| 1137 |
+
min-width: 0;
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
.item-name {
|
| 1141 |
+
font-size: 1rem;
|
| 1142 |
+
font-weight: 500;
|
| 1143 |
+
color: var(--text-secondary);
|
| 1144 |
+
white-space: nowrap;
|
| 1145 |
+
overflow: hidden;
|
| 1146 |
+
text-overflow: ellipsis;
|
| 1147 |
+
transition: color var(--transition-fast);
|
| 1148 |
+
}
|
| 1149 |
+
|
| 1150 |
+
.selector-item:hover .item-name,
|
| 1151 |
+
.selector-item.active .item-name {
|
| 1152 |
+
color: var(--text-primary);
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
.item-arrow {
|
| 1156 |
+
color: var(--text-muted);
|
| 1157 |
+
opacity: 0.3;
|
| 1158 |
+
transition: all var(--transition-fast);
|
| 1159 |
+
}
|
| 1160 |
+
|
| 1161 |
+
.selector-item:hover .item-arrow,
|
| 1162 |
+
.selector-item.active .item-arrow {
|
| 1163 |
+
opacity: 1;
|
| 1164 |
+
color: var(--primary);
|
| 1165 |
+
transform: translateX(4px);
|
| 1166 |
+
}
|
| 1167 |
+
|
| 1168 |
+
.selector-footer {
|
| 1169 |
+
padding: 1.5rem 0.5rem 0.5rem;
|
| 1170 |
+
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
| 1171 |
+
display: flex;
|
| 1172 |
+
justify-content: center;
|
| 1173 |
+
}
|
| 1174 |
+
|
| 1175 |
+
.selector-confirm-btn {
|
| 1176 |
+
width: 100%;
|
| 1177 |
+
padding: 1rem;
|
| 1178 |
+
background: var(--primary);
|
| 1179 |
+
color: white;
|
| 1180 |
+
border: none;
|
| 1181 |
+
border-radius: 12px;
|
| 1182 |
+
font-size: 1rem;
|
| 1183 |
+
font-weight: 600;
|
| 1184 |
+
cursor: pointer;
|
| 1185 |
+
transition: all var(--transition-fast);
|
| 1186 |
+
box-shadow: 0 4px 15px var(--primary-glow);
|
| 1187 |
+
display: flex;
|
| 1188 |
+
align-items: center;
|
| 1189 |
+
justify-content: center;
|
| 1190 |
+
gap: 0.75rem;
|
| 1191 |
+
}
|
| 1192 |
+
|
| 1193 |
+
.selector-confirm-btn:hover {
|
| 1194 |
+
background: #4f46e5;
|
| 1195 |
+
transform: translateY(-2px);
|
| 1196 |
+
box-shadow: 0 8px 25px var(--primary-glow);
|
| 1197 |
+
}
|
| 1198 |
+
|
| 1199 |
+
.selector-confirm-btn:active {
|
| 1200 |
+
transform: translateY(0);
|
| 1201 |
+
}
|
| 1202 |
+
|
| 1203 |
+
.library-nav-btn {
|
| 1204 |
+
width: 100%;
|
| 1205 |
+
padding: 0.875rem;
|
| 1206 |
+
background: transparent;
|
| 1207 |
+
color: var(--text-secondary);
|
| 1208 |
+
border: 1px solid var(--glass-border);
|
| 1209 |
+
border-radius: var(--border-radius);
|
| 1210 |
+
font-weight: 500;
|
| 1211 |
+
cursor: pointer;
|
| 1212 |
+
display: flex;
|
| 1213 |
+
align-items: center;
|
| 1214 |
+
justify-content: center;
|
| 1215 |
+
gap: 0.5rem;
|
| 1216 |
+
transition: all var(--transition-fast);
|
| 1217 |
+
margin-bottom: 1.5rem;
|
| 1218 |
+
}
|
| 1219 |
+
|
| 1220 |
+
.library-nav-btn:hover {
|
| 1221 |
+
background: rgba(255, 255, 255, 0.05);
|
| 1222 |
+
color: var(--text-primary);
|
| 1223 |
+
border-color: var(--primary-glow);
|
| 1224 |
+
}
|
| 1225 |
+
|
| 1226 |
+
.library-nav-btn.active {
|
| 1227 |
+
background: var(--bg-surface-elevated);
|
| 1228 |
+
border-color: var(--primary);
|
| 1229 |
+
color: var(--primary);
|
| 1230 |
+
box-shadow: 0 0 15px var(--primary-glow);
|
| 1231 |
+
}
|
| 1232 |
+
|
| 1233 |
+
.library-main-container {
|
| 1234 |
+
flex: 1;
|
| 1235 |
+
display: flex;
|
| 1236 |
+
flex-direction: column;
|
| 1237 |
+
background: transparent;
|
| 1238 |
+
}
|
| 1239 |
+
|
| 1240 |
+
.library-container {
|
| 1241 |
+
padding: 2rem;
|
| 1242 |
+
flex: 1;
|
| 1243 |
+
overflow-y: auto;
|
| 1244 |
+
}
|
| 1245 |
+
|
| 1246 |
+
.library-header-actions {
|
| 1247 |
+
display: flex;
|
| 1248 |
+
justify-content: space-between;
|
| 1249 |
+
align-items: center;
|
| 1250 |
+
margin-bottom: 2rem;
|
| 1251 |
+
gap: 1.5rem;
|
| 1252 |
+
}
|
| 1253 |
+
|
| 1254 |
+
.search-bar {
|
| 1255 |
+
flex: 1;
|
| 1256 |
+
max-width: 500px;
|
| 1257 |
+
background: var(--bg-surface);
|
| 1258 |
+
border: 1px solid var(--glass-border);
|
| 1259 |
+
padding: 0.75rem 1rem;
|
| 1260 |
+
border-radius: 12px;
|
| 1261 |
+
display: flex;
|
| 1262 |
+
align-items: center;
|
| 1263 |
+
gap: 0.75rem;
|
| 1264 |
+
color: var(--text-muted);
|
| 1265 |
+
}
|
| 1266 |
+
|
| 1267 |
+
.search-bar input {
|
| 1268 |
+
background: none;
|
| 1269 |
+
border: none;
|
| 1270 |
+
color: var(--text-primary);
|
| 1271 |
+
font-size: 0.95rem;
|
| 1272 |
+
flex: 1;
|
| 1273 |
+
outline: none;
|
| 1274 |
+
}
|
| 1275 |
+
|
| 1276 |
+
.stat-chip {
|
| 1277 |
+
padding: 0.5rem 1rem;
|
| 1278 |
+
background: rgba(16, 185, 129, 0.1);
|
| 1279 |
+
color: var(--accent);
|
| 1280 |
+
border: 1px solid var(--accent-glow);
|
| 1281 |
+
border-radius: 30px;
|
| 1282 |
+
font-size: 0.85rem;
|
| 1283 |
+
font-weight: 600;
|
| 1284 |
+
display: flex;
|
| 1285 |
+
align-items: center;
|
| 1286 |
+
gap: 8px;
|
| 1287 |
+
}
|
| 1288 |
+
|
| 1289 |
+
.library-table-wrapper {
|
| 1290 |
+
background: var(--bg-surface);
|
| 1291 |
+
border: 1px solid var(--glass-border);
|
| 1292 |
+
border-radius: 1.25rem;
|
| 1293 |
+
overflow: hidden;
|
| 1294 |
+
box-shadow: var(--shadow-lg);
|
| 1295 |
+
}
|
| 1296 |
+
|
| 1297 |
+
.library-table {
|
| 1298 |
+
width: 100%;
|
| 1299 |
+
border-collapse: collapse;
|
| 1300 |
+
text-align: left;
|
| 1301 |
+
}
|
| 1302 |
+
|
| 1303 |
+
.library-table th {
|
| 1304 |
+
padding: 1.25rem;
|
| 1305 |
+
background: rgba(255, 255, 255, 0.02);
|
| 1306 |
+
color: var(--text-muted);
|
| 1307 |
+
font-size: 0.75rem;
|
| 1308 |
+
text-transform: uppercase;
|
| 1309 |
+
letter-spacing: 0.05em;
|
| 1310 |
+
font-weight: 600;
|
| 1311 |
+
border-bottom: 1px solid var(--glass-border);
|
| 1312 |
+
}
|
| 1313 |
+
|
| 1314 |
+
.library-table td {
|
| 1315 |
+
padding: 1.25rem;
|
| 1316 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
| 1317 |
+
font-size: 0.9rem;
|
| 1318 |
+
vertical-align: middle;
|
| 1319 |
+
}
|
| 1320 |
+
|
| 1321 |
+
.doc-primary-cell {
|
| 1322 |
+
display: flex;
|
| 1323 |
+
align-items: center;
|
| 1324 |
+
gap: 0.75rem;
|
| 1325 |
+
}
|
| 1326 |
+
|
| 1327 |
+
.doc-icon-small {
|
| 1328 |
+
width: 32px;
|
| 1329 |
+
height: 32px;
|
| 1330 |
+
background: rgba(99, 102, 241, 0.1);
|
| 1331 |
+
color: var(--primary);
|
| 1332 |
+
border-radius: 8px;
|
| 1333 |
+
display: flex;
|
| 1334 |
+
align-items: center;
|
| 1335 |
+
justify-content: center;
|
| 1336 |
+
}
|
| 1337 |
+
|
| 1338 |
+
.doc-name {
|
| 1339 |
+
font-weight: 500;
|
| 1340 |
+
color: var(--text-primary);
|
| 1341 |
+
}
|
| 1342 |
+
|
| 1343 |
+
.metadata-cell {
|
| 1344 |
+
display: flex;
|
| 1345 |
+
align-items: center;
|
| 1346 |
+
gap: 8px;
|
| 1347 |
+
color: var(--text-secondary);
|
| 1348 |
+
}
|
| 1349 |
+
|
| 1350 |
+
.chat-link-btn {
|
| 1351 |
+
background: rgba(255, 255, 255, 0.03);
|
| 1352 |
+
border: 1px solid var(--glass-border);
|
| 1353 |
+
color: var(--text-secondary);
|
| 1354 |
+
padding: 6px 12px;
|
| 1355 |
+
border-radius: 8px;
|
| 1356 |
+
cursor: pointer;
|
| 1357 |
+
display: flex;
|
| 1358 |
+
align-items: center;
|
| 1359 |
+
gap: 8px;
|
| 1360 |
+
transition: all var(--transition-fast);
|
| 1361 |
+
font-size: 0.85rem;
|
| 1362 |
+
max-width: 200px;
|
| 1363 |
+
}
|
| 1364 |
+
|
| 1365 |
+
.chat-link-btn:hover {
|
| 1366 |
+
background: rgba(99, 102, 241, 0.1);
|
| 1367 |
+
color: var(--primary);
|
| 1368 |
+
border-color: var(--primary);
|
| 1369 |
+
}
|
| 1370 |
+
|
| 1371 |
+
.chat-link-btn span {
|
| 1372 |
+
white-space: nowrap;
|
| 1373 |
+
overflow: hidden;
|
| 1374 |
+
text-overflow: ellipsis;
|
| 1375 |
+
}
|
| 1376 |
+
|
| 1377 |
+
.delete-row-btn {
|
| 1378 |
+
background: rgba(239, 68, 68, 0.05);
|
| 1379 |
+
border: 1px solid transparent;
|
| 1380 |
+
color: var(--text-muted);
|
| 1381 |
+
width: 36px;
|
| 1382 |
+
height: 36px;
|
| 1383 |
+
border-radius: 8px;
|
| 1384 |
+
display: flex;
|
| 1385 |
+
align-items: center;
|
| 1386 |
+
justify-content: center;
|
| 1387 |
+
cursor: pointer;
|
| 1388 |
+
transition: all var(--transition-fast);
|
| 1389 |
+
}
|
| 1390 |
+
|
| 1391 |
+
.delete-row-btn:hover {
|
| 1392 |
+
background: rgba(239, 68, 68, 0.15);
|
| 1393 |
+
color: #ef4444;
|
| 1394 |
+
border-color: rgba(239, 68, 68, 0.2);
|
| 1395 |
+
}
|
| 1396 |
+
|
| 1397 |
+
.library-loader {
|
| 1398 |
+
display: flex;
|
| 1399 |
+
flex-direction: column;
|
| 1400 |
+
align-items: center;
|
| 1401 |
+
justify-content: center;
|
| 1402 |
+
padding: 5rem;
|
| 1403 |
+
gap: 1.5rem;
|
| 1404 |
+
color: var(--text-secondary);
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
.empty-library {
|
| 1408 |
+
text-align: center;
|
| 1409 |
+
padding: 8rem 2rem;
|
| 1410 |
+
color: var(--text-secondary);
|
| 1411 |
+
}
|
| 1412 |
+
|
| 1413 |
+
.empty-library h3 {
|
| 1414 |
+
color: var(--text-primary);
|
| 1415 |
+
margin-bottom: 0.5rem;
|
| 1416 |
+
}
|
| 1417 |
+
.message-citation {
|
| 1418 |
+
margin-top: 1rem;
|
| 1419 |
+
padding-top: 0.75rem;
|
| 1420 |
+
border-top: 1px solid var(--glass-border);
|
| 1421 |
+
font-size: 0.8rem;
|
| 1422 |
+
color: var(--text-secondary);
|
| 1423 |
+
font-family: 'Plus Jakarta Sans', sans-serif;
|
| 1424 |
+
font-style: italic;
|
| 1425 |
+
line-height: 1.4;
|
| 1426 |
+
white-space: pre-wrap;
|
| 1427 |
+
}
|
| 1428 |
+
|
| 1429 |
+
.message-content .highlight-active {
|
| 1430 |
+
background: rgba(147, 51, 234, 0.1);
|
| 1431 |
+
border-radius: 4px;
|
| 1432 |
+
}
|
| 1433 |
+
|
| 1434 |
+
.message-content .highlight-word {
|
| 1435 |
+
background: rgba(147, 51, 234, 0.4);
|
| 1436 |
+
color: #fff;
|
| 1437 |
+
border-radius: 2px;
|
| 1438 |
+
box-shadow: 0 0 10px rgba(147, 51, 234, 0.5);
|
| 1439 |
+
padding: 0 2px;
|
| 1440 |
+
}
|
| 1441 |
+
|
| 1442 |
+
mark.highlight-word {
|
| 1443 |
+
background: rgba(147, 51, 234, 0.4) !important;
|
| 1444 |
+
color: #fff !important;
|
| 1445 |
+
border-radius: 2px;
|
| 1446 |
+
box-shadow: 0 0 10px rgba(147, 51, 234, 0.5);
|
| 1447 |
+
padding: 0 2px;
|
| 1448 |
+
text-decoration: none;
|
| 1449 |
+
}
|
| 1450 |
+
|
| 1451 |
+
mark.highlight-sentence {
|
| 1452 |
+
background: rgba(147, 51, 234, 0.2) !important;
|
| 1453 |
+
color: inherit !important;
|
| 1454 |
+
border-radius: 4px;
|
| 1455 |
+
box-shadow: 0 0 8px rgba(147, 51, 234, 0.3);
|
| 1456 |
+
padding: 0 4px;
|
| 1457 |
+
text-decoration: none;
|
| 1458 |
+
transition: background 0.3s ease;
|
| 1459 |
+
}
|
frontend-react/src/App.jsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import Auth from './components/Auth';
|
| 3 |
+
import Sidebar from './components/Sidebar';
|
| 4 |
+
import ChatWindow from './components/ChatWindow';
|
| 5 |
+
import DocsLibrary from './components/DocsLibrary';
|
| 6 |
+
import { PanelLeftOpen, PanelLeftClose } from 'lucide-react';
|
| 7 |
+
import './App.css';
|
| 8 |
+
|
| 9 |
+
const generateId = () => {
|
| 10 |
+
try {
|
| 11 |
+
return (typeof crypto !== 'undefined' && crypto.randomUUID)
|
| 12 |
+
? crypto.randomUUID()
|
| 13 |
+
: Math.random().toString(36).substring(2, 15);
|
| 14 |
+
} catch (e) {
|
| 15 |
+
return Math.random().toString(36).substring(2, 15);
|
| 16 |
+
}
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
function App() {
|
| 20 |
+
const [token, setToken] = useState(localStorage.getItem('rag_token') || '');
|
| 21 |
+
const [email, setEmail] = useState(localStorage.getItem('rag_email') || '');
|
| 22 |
+
const [messages, setMessages] = useState([]);
|
| 23 |
+
const [isUploading, setIsUploading] = useState(false);
|
| 24 |
+
const [currentSessionId, setCurrentSessionId] = useState(generateId());
|
| 25 |
+
const [currentView, setCurrentView] = useState('chat');
|
| 26 |
+
const [sessionDocuments, setSessionDocuments] = useState([]);
|
| 27 |
+
const [refreshSessions, setRefreshSessions] = useState(0);
|
| 28 |
+
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
| 29 |
+
|
| 30 |
+
const handleLoginSuccess = (newToken, newEmail) => {
|
| 31 |
+
setToken(newToken);
|
| 32 |
+
setEmail(newEmail);
|
| 33 |
+
localStorage.setItem('rag_token', newToken);
|
| 34 |
+
localStorage.setItem('rag_email', newEmail);
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
const handleLogout = () => {
|
| 38 |
+
setToken('');
|
| 39 |
+
setEmail('');
|
| 40 |
+
setMessages([]);
|
| 41 |
+
setSessionDocuments([]);
|
| 42 |
+
setCurrentView('chat');
|
| 43 |
+
localStorage.removeItem('rag_token');
|
| 44 |
+
localStorage.removeItem('rag_email');
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const handleNewChat = () => {
|
| 48 |
+
setCurrentSessionId(generateId());
|
| 49 |
+
setMessages([]);
|
| 50 |
+
setSessionDocuments([]);
|
| 51 |
+
setCurrentView('chat');
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const handleSelectSession = (sessionId) => {
|
| 55 |
+
setCurrentSessionId(sessionId);
|
| 56 |
+
setCurrentView('chat');
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
if (!token) {
|
| 60 |
+
return <Auth onLoginSuccess={handleLoginSuccess} />;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
return (
|
| 64 |
+
<>
|
| 65 |
+
{isUploading && (
|
| 66 |
+
<div className="loading-overlay">
|
| 67 |
+
<div className="loader-ring"></div>
|
| 68 |
+
<p>Processing and Embedding Documents...</p>
|
| 69 |
+
</div>
|
| 70 |
+
)}
|
| 71 |
+
<div className={`App ${isUploading ? 'blurred' : ''}`}>
|
| 72 |
+
<Sidebar
|
| 73 |
+
token={token}
|
| 74 |
+
email={email}
|
| 75 |
+
onLogout={handleLogout}
|
| 76 |
+
onNewChat={handleNewChat}
|
| 77 |
+
onSelectSession={handleSelectSession}
|
| 78 |
+
setIsUploading={setIsUploading}
|
| 79 |
+
currentSessionId={currentSessionId}
|
| 80 |
+
refreshSessions={refreshSessions}
|
| 81 |
+
isCollapsed={isSidebarCollapsed}
|
| 82 |
+
setIsCollapsed={setIsSidebarCollapsed}
|
| 83 |
+
currentView={currentView}
|
| 84 |
+
onViewLibrary={() => setCurrentView('library')}
|
| 85 |
+
sessionDocuments={sessionDocuments}
|
| 86 |
+
/>
|
| 87 |
+
|
| 88 |
+
{currentView === 'library' ? (
|
| 89 |
+
<div className="library-main-container">
|
| 90 |
+
<header className="chat-header">
|
| 91 |
+
<div className="header-left">
|
| 92 |
+
<button className="sidebar-toggle" onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}>
|
| 93 |
+
{isSidebarCollapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={20} />}
|
| 94 |
+
</button>
|
| 95 |
+
<h1>Management Library</h1>
|
| 96 |
+
</div>
|
| 97 |
+
</header>
|
| 98 |
+
<DocsLibrary
|
| 99 |
+
token={token}
|
| 100 |
+
onSelectChat={handleSelectSession}
|
| 101 |
+
onDeleteSuccess={() => setRefreshSessions(prev => prev + 1)}
|
| 102 |
+
/>
|
| 103 |
+
</div>
|
| 104 |
+
) : (
|
| 105 |
+
<ChatWindow
|
| 106 |
+
token={token}
|
| 107 |
+
messages={messages}
|
| 108 |
+
setMessages={setMessages}
|
| 109 |
+
sessionId={currentSessionId}
|
| 110 |
+
onFirstMessage={() => setRefreshSessions(prev => prev + 1)}
|
| 111 |
+
setIsUploading={setIsUploading}
|
| 112 |
+
onUploadSuccess={() => setRefreshSessions(prev => prev + 1)}
|
| 113 |
+
isSidebarCollapsed={isSidebarCollapsed}
|
| 114 |
+
setIsSidebarCollapsed={setIsSidebarCollapsed}
|
| 115 |
+
sessionDocuments={sessionDocuments}
|
| 116 |
+
setSessionDocuments={setSessionDocuments}
|
| 117 |
+
/>
|
| 118 |
+
)}
|
| 119 |
+
</div>
|
| 120 |
+
</>
|
| 121 |
+
);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
export default App;
|
frontend-react/src/api.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const BASE_URL = import.meta.env.VITE_API_URL || (
|
| 2 |
+
window.location.port && (window.location.port === '5173' || window.location.port === '3000')
|
| 3 |
+
? 'http://localhost:8001'
|
| 4 |
+
: `${window.location.origin}/api`
|
| 5 |
+
);
|
| 6 |
+
|
| 7 |
+
export async function login(email, password) {
|
| 8 |
+
const formData = new URLSearchParams();
|
| 9 |
+
formData.append('username', email); // OAuth2 expects 'username' field
|
| 10 |
+
formData.append('password', password);
|
| 11 |
+
|
| 12 |
+
const response = await fetch(`${BASE_URL}/token`, {
|
| 13 |
+
method: 'POST',
|
| 14 |
+
headers: {
|
| 15 |
+
'Content-Type': 'application/x-www-form-urlencoded',
|
| 16 |
+
},
|
| 17 |
+
body: formData,
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
if (!response.ok) {
|
| 21 |
+
const error = await response.json();
|
| 22 |
+
throw new Error(error.detail || 'Login failed');
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return await response.json();
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export async function register(username, email, password) {
|
| 29 |
+
const response = await fetch(`${BASE_URL}/register`, {
|
| 30 |
+
method: 'POST',
|
| 31 |
+
headers: {
|
| 32 |
+
'Content-Type': 'application/json',
|
| 33 |
+
},
|
| 34 |
+
body: JSON.stringify({ username, email, password }),
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
if (!response.ok) {
|
| 38 |
+
const error = await response.json();
|
| 39 |
+
throw new Error(error.detail || 'Registration failed');
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return await response.json();
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export async function fetchDocuments(token) {
|
| 46 |
+
const response = await fetch(`${BASE_URL}/documents/`, {
|
| 47 |
+
headers: {
|
| 48 |
+
Authorization: `Bearer ${token}`,
|
| 49 |
+
},
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
if (!response.ok) {
|
| 53 |
+
throw new Error('Failed to fetch documents');
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return await response.json();
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export async function uploadDocument(token, file, sessionId = null) {
|
| 60 |
+
const formData = new FormData();
|
| 61 |
+
formData.append('file', file);
|
| 62 |
+
if (sessionId) {
|
| 63 |
+
formData.append('session_id', sessionId);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const response = await fetch(`${BASE_URL}/documents/upload`, {
|
| 67 |
+
method: 'POST',
|
| 68 |
+
headers: {
|
| 69 |
+
Authorization: `Bearer ${token}`,
|
| 70 |
+
},
|
| 71 |
+
body: formData,
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
if (!response.ok) {
|
| 75 |
+
const error = await response.json();
|
| 76 |
+
throw new Error(error.detail || 'Upload failed');
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return await response.json();
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export async function deleteDocument(token, filename) {
|
| 83 |
+
const response = await fetch(`${BASE_URL}/documents/${encodeURIComponent(filename)}`, {
|
| 84 |
+
method: 'DELETE',
|
| 85 |
+
headers: {
|
| 86 |
+
Authorization: `Bearer ${token}`,
|
| 87 |
+
},
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
if (!response.ok) {
|
| 91 |
+
const error = await response.json();
|
| 92 |
+
throw new Error(error.detail || 'Delete failed');
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
return await response.json();
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
export async function chatQuery(token, query, sessionId, filename = null, filenames = null) {
|
| 99 |
+
const response = await fetch(`${BASE_URL}/chat/`, {
|
| 100 |
+
method: 'POST',
|
| 101 |
+
headers: {
|
| 102 |
+
'Content-Type': 'application/json',
|
| 103 |
+
Authorization: `Bearer ${token}`,
|
| 104 |
+
},
|
| 105 |
+
body: JSON.stringify({
|
| 106 |
+
query,
|
| 107 |
+
session_id: sessionId,
|
| 108 |
+
filename,
|
| 109 |
+
filenames
|
| 110 |
+
}),
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
if (!response.ok) {
|
| 114 |
+
const error = await response.json();
|
| 115 |
+
throw new Error(error.detail || 'Chat query failed');
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Return the raw response so the UI can read the stream
|
| 119 |
+
return response;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
export async function fetchChatSessions(token) {
|
| 123 |
+
const response = await fetch(`${BASE_URL}/chat/sessions`, {
|
| 124 |
+
headers: {
|
| 125 |
+
Authorization: `Bearer ${token}`,
|
| 126 |
+
},
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
if (!response.ok) {
|
| 130 |
+
throw new Error('Failed to fetch chat sessions');
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
return await response.json();
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
export async function fetchChatHistory(token, sessionId) {
|
| 137 |
+
const response = await fetch(`${BASE_URL}/chat/history/${sessionId}`, {
|
| 138 |
+
headers: {
|
| 139 |
+
Authorization: `Bearer ${token}`,
|
| 140 |
+
},
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
if (!response.ok) {
|
| 144 |
+
throw new Error('Failed to fetch chat history');
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
return await response.json();
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
export async function fetchSessionDocuments(token, sessionId) {
|
| 151 |
+
const response = await fetch(`${BASE_URL}/documents/session/${sessionId}`, {
|
| 152 |
+
headers: {
|
| 153 |
+
Authorization: `Bearer ${token}`,
|
| 154 |
+
},
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
if (!response.ok) {
|
| 158 |
+
throw new Error('Failed to fetch session documents');
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
return await response.json();
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
export async function deleteChatSession(token, sessionId) {
|
| 165 |
+
const response = await fetch(`${BASE_URL}/chat/session/${sessionId}`, {
|
| 166 |
+
method: 'DELETE',
|
| 167 |
+
headers: {
|
| 168 |
+
Authorization: `Bearer ${token}`,
|
| 169 |
+
},
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
if (!response.ok) {
|
| 173 |
+
throw new Error('Failed to delete chat session');
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
return await response.json();
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// Voice API functions
|
| 180 |
+
export async function startVoiceRecording(token) {
|
| 181 |
+
const response = await fetch(`${BASE_URL}/voice/start`, {
|
| 182 |
+
method: 'POST',
|
| 183 |
+
headers: {
|
| 184 |
+
Authorization: `Bearer ${token}`,
|
| 185 |
+
},
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
if (!response.ok) {
|
| 189 |
+
throw new Error('Failed to start recording');
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
return await response.json();
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
export async function stopVoiceRecording(token) {
|
| 196 |
+
const response = await fetch(`${BASE_URL}/voice/stop`, {
|
| 197 |
+
method: 'POST',
|
| 198 |
+
headers: {
|
| 199 |
+
Authorization: `Bearer ${token}`,
|
| 200 |
+
},
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
if (!response.ok) {
|
| 204 |
+
const error = await response.json();
|
| 205 |
+
throw new Error(error.detail || 'Failed to stop recording');
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
return await response.json();
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
export async function getTtsAudio(token, text, signal) {
|
| 212 |
+
const response = await fetch(`${BASE_URL}/chat/speak`, {
|
| 213 |
+
method: 'POST',
|
| 214 |
+
headers: {
|
| 215 |
+
'Content-Type': 'application/json',
|
| 216 |
+
Authorization: `Bearer ${token}`,
|
| 217 |
+
},
|
| 218 |
+
body: JSON.stringify({ text }),
|
| 219 |
+
signal
|
| 220 |
+
});
|
| 221 |
+
|
| 222 |
+
if (!response.ok) {
|
| 223 |
+
const error = await response.json();
|
| 224 |
+
throw new Error(error.detail || 'TTS request failed');
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
return response;
|
| 228 |
+
}
|