JenishMakwana commited on
Commit
69524c2
·
0 Parent(s):

Initial clean commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +32 -0
  2. .env.example +58 -0
  3. .gitattributes +1 -0
  4. .gitignore +60 -0
  5. Dockerfile +33 -0
  6. README.md +70 -0
  7. backend/.dockerignore +33 -0
  8. backend/Dockerfile +33 -0
  9. backend/app/__init__.py +0 -0
  10. backend/app/api/__init__.py +0 -0
  11. backend/app/api/api_v1/__init__.py +0 -0
  12. backend/app/api/api_v1/api.py +11 -0
  13. backend/app/api/api_v1/endpoints/__init__.py +0 -0
  14. backend/app/api/api_v1/endpoints/auth.py +58 -0
  15. backend/app/api/api_v1/endpoints/chat.py +231 -0
  16. backend/app/api/api_v1/endpoints/documents.py +131 -0
  17. backend/app/api/deps.py +49 -0
  18. backend/app/core/__init__.py +0 -0
  19. backend/app/core/config.py +85 -0
  20. backend/app/core/security.py +44 -0
  21. backend/app/core/utils.py +19 -0
  22. backend/app/db/__init__.py +0 -0
  23. backend/app/db/base_class.py +11 -0
  24. backend/app/db/init_db.py +122 -0
  25. backend/app/db/session.py +26 -0
  26. backend/app/main.py +30 -0
  27. backend/app/models/__init__.py +0 -0
  28. backend/app/models/chat.py +20 -0
  29. backend/app/models/document.py +13 -0
  30. backend/app/models/user.py +10 -0
  31. backend/app/schemas/__init__.py +0 -0
  32. backend/app/schemas/chat.py +33 -0
  33. backend/app/schemas/document.py +16 -0
  34. backend/app/schemas/token.py +9 -0
  35. backend/app/schemas/user.py +40 -0
  36. backend/app/services/__init__.py +0 -0
  37. backend/app/services/pdf_processor.py +76 -0
  38. backend/app/services/rag_service.py +238 -0
  39. backend/app/services/tts.py +331 -0
  40. backend/app/services/voice_service.py +159 -0
  41. docker-compose.yml +84 -0
  42. frontend-react/.dockerignore +7 -0
  43. frontend-react/Dockerfile +27 -0
  44. frontend-react/index.html +16 -0
  45. frontend-react/nginx.conf +32 -0
  46. frontend-react/package-lock.json +0 -0
  47. frontend-react/package.json +31 -0
  48. frontend-react/src/App.css +1459 -0
  49. frontend-react/src/App.jsx +124 -0
  50. 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
+ }