Spaces:
Running
Running
chore: merge remote dev — keep Hall of Fame feature
Browse files- .env.example +119 -8
- CHANGELOG.MD +54 -0
- Makefile +72 -0
- README.md +87 -22
- backend/app/auth.py +18 -4
- backend/app/config.py +12 -2
- backend/app/main.py +2 -1
- backend/app/rag/agent.py +19 -0
- backend/app/routes/auth.py +37 -6
- backend/app/routes/documents.py +6 -2
- backend/app/schemas.py +5 -0
- docker-compose.yml +6 -0
- frontend/src/app/dashboard/page.tsx +18 -3
- frontend/src/components/chat/ChatPanel.tsx +42 -4
- frontend/src/components/chat/SourceCard.tsx +4 -4
- frontend/src/components/document/DocumentSidebar.tsx +28 -17
- frontend/src/components/ui/textarea.tsx +8 -2
- frontend/src/lib/api.ts +62 -16
.env.example
CHANGED
|
@@ -1,24 +1,135 @@
|
|
| 1 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
SECRET_KEY=change-me-in-production
|
| 3 |
-
DATABASE_URL=sqlite:///./data/app.db
|
| 4 |
|
| 5 |
-
# ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
HF_TOKEN=your_huggingface_token_here
|
| 7 |
|
| 8 |
-
# ── LLM
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
# LLM_MODEL=mistralai/Mistral-7B-Instruct-v0.3
|
|
|
|
|
|
|
|
|
|
| 10 |
# LLM_TEMPERATURE=0.3
|
|
|
|
|
|
|
|
|
|
| 11 |
# LLM_MAX_NEW_TOKENS=1024
|
| 12 |
|
| 13 |
-
# ── Embeddings (Optional — defaults shown)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
# EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
# ── RAG Config (Optional — defaults shown) ───────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
# CHUNK_SIZE=1000
|
|
|
|
|
|
|
|
|
|
| 18 |
# CHUNK_OVERLAP=200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
# TOP_K_RETRIEVAL=10
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
# TOP_K_RERANK=5
|
| 21 |
|
| 22 |
-
#
|
| 23 |
-
#
|
| 24 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Document AI Analyst — Environment Configuration
|
| 2 |
+
# Copy this file to backend/.env and fill in your values:
|
| 3 |
+
# cp .env.example backend/.env
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# ── Application Config ──────────────────────────────────────────────
|
| 7 |
+
|
| 8 |
+
# Secret key for signing JWT tokens and Flask sessions.
|
| 9 |
+
# Generate one: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
| 10 |
+
# Required
|
| 11 |
SECRET_KEY=change-me-in-production
|
|
|
|
| 12 |
|
| 13 |
+
# ── Environment & CORS ──────────────────────────────
|
| 14 |
+
|
| 15 |
+
# Runtime environment. Set to "production" in production.
|
| 16 |
+
# In production, ALLOWED_ORIGINS must be set explicitly (CORS will reject all others).
|
| 17 |
+
# Optional — defaults to "development"
|
| 18 |
+
ENVIRONMENT=development
|
| 19 |
+
|
| 20 |
+
# Debug mode. Enables detailed error pages and auto-reload.
|
| 21 |
+
# Do NOT enable in production.
|
| 22 |
+
# Optional — defaults to False
|
| 23 |
+
# DEBUG=False
|
| 24 |
+
|
| 25 |
+
# Comma-separated list of allowed CORS origins.
|
| 26 |
+
# Only used when ENVIRONMENT=production. When empty or during development, all origins are allowed.
|
| 27 |
+
# Optional — defaults to "http://localhost:3000,http://localhost:7860"
|
| 28 |
+
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:7860
|
| 29 |
+
|
| 30 |
+
# ── Database ─────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
# SQLAlchemy database connection string.
|
| 33 |
+
# Default: SQLite stored at ./data/app.db
|
| 34 |
+
# For Postgres: postgresql+asyncpg://user:pass@host:5432/dbname
|
| 35 |
+
# Optional — defaults to sqlite:///./data/app.db
|
| 36 |
+
# DATABASE_URL=sqlite:///./data/app.db
|
| 37 |
+
|
| 38 |
+
# ── Authentication ──────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
# JWT signing algorithm. Leave as default unless you know what you're doing.
|
| 41 |
+
# Optional — defaults to "HS256"
|
| 42 |
+
# JWT_ALGORITHM=HS256
|
| 43 |
+
|
| 44 |
+
# JWT token expiry in hours. After this period, users must re-login.
|
| 45 |
+
# Optional — defaults to 72
|
| 46 |
+
# JWT_EXPIRY_HOURS=72
|
| 47 |
+
|
| 48 |
+
# ── File Upload ─────────────────────────────────────────────
|
| 49 |
+
|
| 50 |
+
# Directory where uploaded documents (PDFs, DOCXs, etc.) are stored.
|
| 51 |
+
# Optional — defaults to "./data/uploads"
|
| 52 |
+
# UPLOAD_DIR=./data/uploads
|
| 53 |
+
|
| 54 |
+
# Maximum upload file size in megabytes.
|
| 55 |
+
# Optional — defaults to 50
|
| 56 |
+
# MAX_FILE_SIZE_MB=50
|
| 57 |
+
|
| 58 |
+
# Comma-separated list of allowed file extensions for upload.
|
| 59 |
+
# Optional — defaults to "pdf,docx,txt,md"
|
| 60 |
+
# ALLOWED_EXTENSIONS=pdf,docx,txt,md
|
| 61 |
+
|
| 62 |
+
# ── HuggingFace (Required for LLM inference) ────────────────
|
| 63 |
+
|
| 64 |
+
# HuggingFace API token. Used to call the Inference API for LLM responses.
|
| 65 |
+
# Get yours: https://huggingface.co/settings/tokens (free tier available)
|
| 66 |
+
# Required (app won't generate answers without it)
|
| 67 |
HF_TOKEN=your_huggingface_token_here
|
| 68 |
|
| 69 |
+
# ── LLM Configuration ───────────────────────────────────────
|
| 70 |
+
|
| 71 |
+
# HuggingFace model ID used for answer generation.
|
| 72 |
+
# Check available models: https://huggingface.co/models?inference=warm&sort=trending
|
| 73 |
+
# Optional — defaults to "mistralai/Mistral-7B-Instruct-v0.3"
|
| 74 |
# LLM_MODEL=mistralai/Mistral-7B-Instruct-v0.3
|
| 75 |
+
|
| 76 |
+
# Sampling temperature (0.0 = deterministic, 1.0 = very creative).
|
| 77 |
+
# Optional — defaults to 0.3
|
| 78 |
# LLM_TEMPERATURE=0.3
|
| 79 |
+
|
| 80 |
+
# Maximum number of tokens the LLM can generate per response.
|
| 81 |
+
# Optional — defaults to 1024
|
| 82 |
# LLM_MAX_NEW_TOKENS=1024
|
| 83 |
|
| 84 |
+
# ── Embeddings (Optional — defaults shown)──────────────────────────────────────────────
|
| 85 |
+
|
| 86 |
+
# SentenceTransformer model ID for generating document embeddings.
|
| 87 |
+
# Model is downloaded once and cached locally. No external API call.
|
| 88 |
+
# Optional — defaults to "sentence-transformers/all-MiniLM-L6-v2"
|
| 89 |
# EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
| 90 |
|
| 91 |
+
# Dimension of the embedding vectors (must match the model output).
|
| 92 |
+
# Optional — defaults to 384
|
| 93 |
+
# EMBEDDING_DIMENSION=384
|
| 94 |
+
|
| 95 |
# ── RAG Config (Optional — defaults shown) ───────────
|
| 96 |
+
|
| 97 |
+
# ── ChromaDB (Vector Store) ─────────────────────────────────
|
| 98 |
+
|
| 99 |
+
# Directory where ChromaDB persists its vector index to disk.
|
| 100 |
+
# Optional — defaults to "./data/chroma_db"
|
| 101 |
+
# CHROMA_PERSIST_DIR=./data/chroma_db
|
| 102 |
+
|
| 103 |
+
# ── Document Chunking ───��───────────────────────────────────
|
| 104 |
+
|
| 105 |
+
# Number of characters per document chunk.
|
| 106 |
+
# Larger chunks give more context; smaller chunks improve retrieval precision.
|
| 107 |
+
# Optional — defaults to 1000
|
| 108 |
# CHUNK_SIZE=1000
|
| 109 |
+
|
| 110 |
+
# Character overlap between consecutive chunks. Helps maintain context at boundaries.
|
| 111 |
+
# Optional — defaults to 200
|
| 112 |
# CHUNK_OVERLAP=200
|
| 113 |
+
|
| 114 |
+
# ── Retrieval ───────────────────────────────────────────────
|
| 115 |
+
|
| 116 |
+
# Number of candidate chunks retrieved from the vector store during semantic search.
|
| 117 |
+
# Optional — defaults to 10
|
| 118 |
# TOP_K_RETRIEVAL=10
|
| 119 |
+
|
| 120 |
+
# Number of top chunks passed to the LLM after cross-encoder reranking.
|
| 121 |
+
# Must be ≤ TOP_K_RETRIEVAL.
|
| 122 |
+
# Optional — defaults to 5
|
| 123 |
# TOP_K_RERANK=5
|
| 124 |
|
| 125 |
+
# Cross-encoder model used for reranking retrieved chunks by relevance.
|
| 126 |
+
# Optional — defaults to "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
| 127 |
+
# RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
|
| 128 |
+
|
| 129 |
+
# ── (Legacy) Flask-Only Variables ───────────────────────────
|
| 130 |
+
# These are only used if you run the old Flask app (app.py) instead of FastAPI.
|
| 131 |
+
# They are ignored by the new FastAPI backend.
|
| 132 |
+
|
| 133 |
+
# MONGO_URI=mongodb://localhost:27017/pdf_assistant
|
| 134 |
+
# GOOGLE_CLIENT_ID=your_google_client_id
|
| 135 |
+
# GOOGLE_CLIENT_SECRET=your_google_client_secret
|
CHANGELOG.MD
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
All notable changes to this project will be documented in this file.
|
| 4 |
+
|
| 5 |
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
| 6 |
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
| 7 |
+
|
| 8 |
+
## [Unreleased]
|
| 9 |
+
|
| 10 |
+
## [0.4.0] - 2026-05-16
|
| 11 |
+
### Added
|
| 12 |
+
- Configured GSSOC contributor workflow on the `dev` branch.
|
| 13 |
+
|
| 14 |
+
### Fixed
|
| 15 |
+
- Resolved React Hook linting errors (`react-hooks/set-state-in-effect`) in CI pipelines by lazy-initializing the loading state to prevent `setLoading(false)` execution inside the effect body.
|
| 16 |
+
- Fixed chat scroll component tracking using a `bottomRef` sentinel and `scrollIntoView` mechanism to replace the broken `scrollRef` on the ScrollArea wrapper.
|
| 17 |
+
|
| 18 |
+
### Documentation
|
| 19 |
+
- Extensively overhauled `README.md` to add full project documentation, an explicit RAG pipeline architectural diagram, comprehensive API reference, and GSSOC contribution guides.
|
| 20 |
+
|
| 21 |
+
## [0.3.0] - 2026-04-15
|
| 22 |
+
### Added
|
| 23 |
+
- Implemented a brand new UI and upgraded internal RAG model architectures.
|
| 24 |
+
- Configured native Hugging Face Spaces Docker deployment handling non-root user execution, model pre-download caching, and custom keep-alive timeouts.
|
| 25 |
+
|
| 26 |
+
### Changed
|
| 27 |
+
- Switched default open-source inference engine to `Qwen2.5-72B-Instruct` to leverage Hugging Face free-tier hardware.
|
| 28 |
+
|
| 29 |
+
### Fixed
|
| 30 |
+
- Patched critical `list index out of range` runtime crash by explicitly handling empty choice selections from LLM responses.
|
| 31 |
+
- Adjusted production API routing to enforce same-origin API calls and added native `HEAD` method support to satisfy Next.js route prefetching rules.
|
| 32 |
+
- Upgraded text chunking modules to use `langchain_text_splitters` to ensure compatibility with LangChain v0.3+.
|
| 33 |
+
- Removed bulky compiled binary assets before pushing to Hugging Face Spaces storage layers.
|
| 34 |
+
|
| 35 |
+
## [0.2.0] - 2026-02-26
|
| 36 |
+
### Added
|
| 37 |
+
- Implemented an alternative lightweight RAG pipeline utilizing a Pinecone vector database index, Gemini embeddings, and Render hosting deployment profiles.
|
| 38 |
+
- Integrated automated Google Cloud Run continuous deployment (CD) workflows via Google Cloud Platform (GCP).
|
| 39 |
+
- Added `ProxyFix` middleware support to securely preserve OAuth authentication headers behind Render's reverse proxy structure.
|
| 40 |
+
|
| 41 |
+
### Fixed
|
| 42 |
+
- Cleaned hardcoded testing credentials and placeholder URIs flagged during automated GitHub secret scanning routines.
|
| 43 |
+
|
| 44 |
+
## [0.1.0] - 2024-06-25
|
| 45 |
+
### Added
|
| 46 |
+
- Initialized core repository, licensing infrastructure, and baseline documentation assets.
|
| 47 |
+
- Built initial RAG application architecture featuring file ingestion systems parsing raw `.txt`, `.docx`, and `.md` formats.
|
| 48 |
+
- Implemented native Google Authentication security layers.
|
| 49 |
+
|
| 50 |
+
[unreleased]: https://github.com/param20h/PDF-Assistant-RAG/compare/v0.4.0...HEAD
|
| 51 |
+
[0.4.0]: https://github.com/param20h/PDF-Assistant-RAG/compare/v0.3.0...v0.4.0
|
| 52 |
+
[0.3.0]: https://github.com/param20h/PDF-Assistant-RAG/compare/v0.2.0...v0.3.0
|
| 53 |
+
[0.2.0]: https://github.com/param20h/PDF-Assistant-RAG/compare/v0.1.0...v0.2.0
|
| 54 |
+
[0.1.0]: https://github.com/param20h/PDF-Assistant-RAG/commits/dev
|
Makefile
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: dev-backend dev-frontend dev test lint format install install-backend install-frontend build clean docker-up docker-down docker-logs help
|
| 2 |
+
|
| 3 |
+
BACKEND_DIR = backend
|
| 4 |
+
FRONTEND_DIR = frontend
|
| 5 |
+
BACKEND_PORT ?= 7860
|
| 6 |
+
|
| 7 |
+
help:
|
| 8 |
+
@echo "Usage:"
|
| 9 |
+
@echo " make dev-backend Start FastAPI (uvicorn) on port $(BACKEND_PORT)"
|
| 10 |
+
@echo " make dev-frontend Start Next.js dev server on port 3000"
|
| 11 |
+
@echo " make dev Start both backend and frontend concurrently"
|
| 12 |
+
@echo " make test Run pytest"
|
| 13 |
+
@echo " make lint Run flake8 (backend) + eslint (frontend)"
|
| 14 |
+
@echo " make format Auto-format Python with black (backend)"
|
| 15 |
+
@echo " make install Install all dependencies (backend + frontend)"
|
| 16 |
+
@echo " make install-backend Install Python dependencies"
|
| 17 |
+
@echo " make install-frontend Install Node.js dependencies"
|
| 18 |
+
@echo " make build Build frontend for production"
|
| 19 |
+
@echo " make clean Remove __pycache__, .next, build artifacts"
|
| 20 |
+
@echo " make docker-up Start all Docker services"
|
| 21 |
+
@echo " make docker-down Stop all Docker services"
|
| 22 |
+
@echo " make docker-logs Tail Docker logs"
|
| 23 |
+
|
| 24 |
+
dev-backend:
|
| 25 |
+
cd $(BACKEND_DIR) && uvicorn app.main:app --host 0.0.0.0 --port $(BACKEND_PORT) --reload
|
| 26 |
+
|
| 27 |
+
dev-frontend:
|
| 28 |
+
cd $(FRONTEND_DIR) && npm run dev
|
| 29 |
+
|
| 30 |
+
dev:
|
| 31 |
+
@echo "Starting backend (port $(BACKEND_PORT)) and frontend (port 3000)..."
|
| 32 |
+
npx concurrently --kill-others --names "BACKEND,FRONTEND" --prefix-colors "blue,green" \
|
| 33 |
+
"$(MAKE) dev-backend" \
|
| 34 |
+
"$(MAKE) dev-frontend"
|
| 35 |
+
|
| 36 |
+
test:
|
| 37 |
+
cd $(BACKEND_DIR) && python -m pytest -v
|
| 38 |
+
|
| 39 |
+
lint:
|
| 40 |
+
cd $(BACKEND_DIR) && flake8 .
|
| 41 |
+
cd $(FRONTEND_DIR) && npm run lint
|
| 42 |
+
|
| 43 |
+
format:
|
| 44 |
+
cd $(BACKEND_DIR) && black .
|
| 45 |
+
|
| 46 |
+
install: install-backend install-frontend
|
| 47 |
+
|
| 48 |
+
install-backend:
|
| 49 |
+
pip install -r $(BACKEND_DIR)/requirements.txt
|
| 50 |
+
|
| 51 |
+
install-frontend:
|
| 52 |
+
cd $(FRONTEND_DIR) && npm install
|
| 53 |
+
|
| 54 |
+
build:
|
| 55 |
+
cd $(FRONTEND_DIR) && npm run build
|
| 56 |
+
|
| 57 |
+
clean:
|
| 58 |
+
rm -rf $(BACKEND_DIR)/__pycache__
|
| 59 |
+
find $(BACKEND_DIR) -type d -name __pycache__ -exec rm -rf {} +
|
| 60 |
+
rm -rf $(FRONTEND_DIR)/.next
|
| 61 |
+
rm -rf $(FRONTEND_DIR)/out
|
| 62 |
+
rm -rf $(FRONTEND_DIR)/build
|
| 63 |
+
rm -rf .pytest_cache
|
| 64 |
+
|
| 65 |
+
docker-up:
|
| 66 |
+
docker compose up -d
|
| 67 |
+
|
| 68 |
+
docker-down:
|
| 69 |
+
docker compose down
|
| 70 |
+
|
| 71 |
+
docker-logs:
|
| 72 |
+
docker compose logs -f
|
README.md
CHANGED
|
@@ -69,8 +69,6 @@ Thanks to all the amazing people who have contributed to **PDF-Assistant-RAG**!
|
|
| 69 |
|
| 70 |
<br/>
|
| 71 |
|
| 72 |
-
> 🌟 **GSSOC Contributors** — This project is open for [GirlScript Summer of Code](https://gssoc.girlscript.tech/). Check out our [CONTRIBUTING.md](CONTRIBUTING.md) to get started and browse [open issues](https://github.com/param20h/PDF-Assistant-RAG/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tagged `good first issue`.
|
| 73 |
-
|
| 74 |
---
|
| 75 |
|
| 76 |
<br/>
|
|
@@ -83,6 +81,65 @@ The system uses **semantic search + cross-encoder reranking** to find the most r
|
|
| 83 |
|
| 84 |
<br/>
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
## 🛠 Tech Stack
|
| 87 |
|
| 88 |
<div align="center">
|
|
@@ -235,7 +292,7 @@ PDF-Assistant-RAG/
|
|
| 235 |
│
|
| 236 |
├── Dockerfile # Multi-stage: Node build → Python serve
|
| 237 |
├── docker-compose.yml # Local Docker stack
|
| 238 |
-
├── CONTRIBUTING.md #
|
| 239 |
└── .env.example # Template for environment variables
|
| 240 |
```
|
| 241 |
|
|
@@ -378,22 +435,30 @@ docker compose up --build
|
|
| 378 |
|
| 379 |
## 📦 Environment Variables
|
| 380 |
|
| 381 |
-
| Variable | Required | Default | Description |
|
| 382 |
-
|---|---|---|---|
|
| 383 |
-
| `
|
| 384 |
-
| `
|
| 385 |
-
| `
|
| 386 |
-
| `
|
| 387 |
-
| `
|
| 388 |
-
| `
|
| 389 |
-
| `
|
| 390 |
-
| `
|
| 391 |
-
| `
|
| 392 |
-
| `
|
| 393 |
-
| `
|
| 394 |
-
| `
|
| 395 |
-
| `
|
| 396 |
-
| `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
|
| 398 |
<br/>
|
| 399 |
|
|
@@ -449,7 +514,7 @@ docker compose up -d --build
|
|
| 449 |
|
| 450 |
<br/>
|
| 451 |
|
| 452 |
-
## 🤝 Contributing
|
| 453 |
|
| 454 |
This project is participating in **GirlScript Summer of Code**! We welcome contributors of all skill levels.
|
| 455 |
|
|
@@ -485,7 +550,7 @@ Distributed under the **MIT License**. See [`LICENSE`](license) for more informa
|
|
| 485 |
|
| 486 |
**Built with 💙 as a flagship AI engineering project**
|
| 487 |
|
| 488 |
-
*If you found this project helpful, please give it a ⭐ — it helps
|
| 489 |
|
| 490 |
<br/>
|
| 491 |
|
|
@@ -495,4 +560,4 @@ Distributed under the **MIT License**. See [`LICENSE`](license) for more informa
|
|
| 495 |
|
| 496 |
**[⬆ Back to top](#)**
|
| 497 |
|
| 498 |
-
</div>
|
|
|
|
| 69 |
|
| 70 |
<br/>
|
| 71 |
|
|
|
|
|
|
|
| 72 |
---
|
| 73 |
|
| 74 |
<br/>
|
|
|
|
| 81 |
|
| 82 |
<br/>
|
| 83 |
|
| 84 |
+
## 🏗️ Architecture
|
| 85 |
+
|
| 86 |
+
```mermaid
|
| 87 |
+
graph TD
|
| 88 |
+
subgraph Frontend["Frontend (Next.js 16)"]
|
| 89 |
+
UI["Dashboard UI (React)"]
|
| 90 |
+
Chat["Chat Panel (SSE)"]
|
| 91 |
+
Viewer["PDF Viewer (iframe)"]
|
| 92 |
+
end
|
| 93 |
+
|
| 94 |
+
subgraph Backend["Backend (FastAPI 0.115+)"]
|
| 95 |
+
API["API Router (/api/v1)"]
|
| 96 |
+
Auth["Auth (JWT/bcrypt)"]
|
| 97 |
+
DB[(SQLite Metadata)]
|
| 98 |
+
|
| 99 |
+
subgraph RAG["RAG Pipeline"]
|
| 100 |
+
Upload["Ingestion Task (Chunking)"]
|
| 101 |
+
Embed["Local Embeddings (all-MiniLM-L6-v2)"]
|
| 102 |
+
Retriever["Two-Stage Retriever"]
|
| 103 |
+
Rerank["Cross-Encoder Reranker"]
|
| 104 |
+
Agent["Agent/Generator"]
|
| 105 |
+
end
|
| 106 |
+
end
|
| 107 |
+
|
| 108 |
+
subgraph Storage["Vector Storage"]
|
| 109 |
+
Chroma[(ChromaDB)]
|
| 110 |
+
end
|
| 111 |
+
|
| 112 |
+
subgraph External["External Services"]
|
| 113 |
+
HF["HuggingFace Inference API (Qwen 72B)"]
|
| 114 |
+
end
|
| 115 |
+
|
| 116 |
+
%% Frontend to Backend Connections
|
| 117 |
+
UI <-->|REST / Auth| API
|
| 118 |
+
Chat <-->|SSE Streaming| API
|
| 119 |
+
Viewer -->|Fetch PDF| API
|
| 120 |
+
|
| 121 |
+
%% Backend Internals
|
| 122 |
+
API <--> Auth
|
| 123 |
+
API <--> DB
|
| 124 |
+
API --> Upload
|
| 125 |
+
API <--> Retriever
|
| 126 |
+
API <--> Agent
|
| 127 |
+
|
| 128 |
+
%% RAG Ingestion Flow
|
| 129 |
+
Upload --> Embed
|
| 130 |
+
Embed -->|Store Vectors| Chroma
|
| 131 |
+
|
| 132 |
+
%% RAG Query Flow
|
| 133 |
+
Retriever -->|1. Semantic Search| Chroma
|
| 134 |
+
Retriever -->|2. Score & Sort| Rerank
|
| 135 |
+
Retriever -->|Context| Agent
|
| 136 |
+
|
| 137 |
+
%% External LLM Flow
|
| 138 |
+
Agent <-->|LLM Generation| HF
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
<br/>
|
| 142 |
+
|
| 143 |
## 🛠 Tech Stack
|
| 144 |
|
| 145 |
<div align="center">
|
|
|
|
| 292 |
│
|
| 293 |
├── Dockerfile # Multi-stage: Node build → Python serve
|
| 294 |
├── docker-compose.yml # Local Docker stack
|
| 295 |
+
├── CONTRIBUTING.md # contributor guide
|
| 296 |
└── .env.example # Template for environment variables
|
| 297 |
```
|
| 298 |
|
|
|
|
| 435 |
|
| 436 |
## 📦 Environment Variables
|
| 437 |
|
| 438 |
+
| Variable | Required | Default | Description | Where to Get It |
|
| 439 |
+
|---|---|---|---|---|
|
| 440 |
+
| `SECRET_KEY` | ✅ | — | JWT signing & session secret. Use a strong random string. | Generate: `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
| 441 |
+
| `HF_TOKEN` | ✅ | — | HuggingFace API token for LLM inference via Inference API. | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) (free) |
|
| 442 |
+
| `ENVIRONMENT` | ❌ | `development` | Runtime mode. Set to `production` for deployment to lock CORS. | — |
|
| 443 |
+
| `DEBUG` | ❌ | `False` | Enable debug mode with detailed error pages. Never enable in production. | — |
|
| 444 |
+
| `ALLOWED_ORIGINS` | ❌ | `http://localhost:3000,http://localhost:7860` | Comma-separated CORS origins (only enforced in production). | Your deployed domain(s) |
|
| 445 |
+
| `DATABASE_URL` | ❌ | `sqlite:///./data/app.db` | SQLAlchemy database connection string. | SQLite (default), or your Postgres/MySQL connection string |
|
| 446 |
+
| `JWT_ALGORITHM` | ❌ | `HS256` | JWT signing algorithm. | — |
|
| 447 |
+
| `JWT_EXPIRY_HOURS` | ❌ | `72` | JWT token lifetime in hours before re-login is required. | — |
|
| 448 |
+
| `UPLOAD_DIR` | ❌ | `./data/uploads` | Local directory for storing uploaded documents. | — |
|
| 449 |
+
| `MAX_FILE_SIZE_MB` | ❌ | `50` | Maximum allowed upload file size in MB. | — |
|
| 450 |
+
| `ALLOWED_EXTENSIONS` | ❌ | `pdf,docx,txt,md` | Comma-separated list of permitted file extensions. | — |
|
| 451 |
+
| `CHROMA_PERSIST_DIR` | ❌ | `./data/chroma_db` | Directory where ChromaDB persists its vector index. | — |
|
| 452 |
+
| `LLM_MODEL` | ❌ | `Qwen/Qwen2.5-72B-Instruct` | HuggingFace model ID for answer generation. | [huggingface.co/models](https://huggingface.co/models?inference=warm&sort=trending) |
|
| 453 |
+
| `LLM_TEMPERATURE` | ❌ | `0.3` | LLM sampling temperature (0 = deterministic, 1 = creative). | — |
|
| 454 |
+
| `LLM_MAX_NEW_TOKENS` | ❌ | `1024` | Maximum tokens per LLM response. | — |
|
| 455 |
+
| `EMBEDDING_MODEL` | ❌ | `sentence-transformers/all-MiniLM-L6-v2` | SentenceTransformer model for local embeddings (no external API). | [huggingface.co/sentence-transformers](https://huggingface.co/sentence-transformers) |
|
| 456 |
+
| `EMBEDDING_DIMENSION` | ❌ | `384` | Embedding vector dimension (must match the model). | — |
|
| 457 |
+
| `RERANKER_MODEL` | ❌ | `cross-encoder/ms-marco-MiniLM-L-6-v2` | Cross-encoder model for reranking retrieved chunks by relevance. | [huggingface.co/cross-encoder](https://huggingface.co/cross-encoder) |
|
| 458 |
+
| `CHUNK_SIZE` | ❌ | `1000` | Characters per document chunk. Larger = more context, smaller = better precision. | — |
|
| 459 |
+
| `CHUNK_OVERLAP` | ❌ | `200` | Overlap between consecutive chunks to maintain boundary context. | — |
|
| 460 |
+
| `TOP_K_RETRIEVAL` | ❌ | `10` | Candidate chunks retrieved from vector store during semantic search. | — |
|
| 461 |
+
| `TOP_K_RERANK` | ❌ | `5` | Final chunks passed to the LLM after reranking (must be ≤ `TOP_K_RETRIEVAL`). | — |
|
| 462 |
|
| 463 |
<br/>
|
| 464 |
|
|
|
|
| 514 |
|
| 515 |
<br/>
|
| 516 |
|
| 517 |
+
## 🤝 Contributing
|
| 518 |
|
| 519 |
This project is participating in **GirlScript Summer of Code**! We welcome contributors of all skill levels.
|
| 520 |
|
|
|
|
| 550 |
|
| 551 |
**Built with 💙 as a flagship AI engineering project**
|
| 552 |
|
| 553 |
+
*If you found this project helpful, please give it a ⭐ — it helps contributors discover it!*
|
| 554 |
|
| 555 |
<br/>
|
| 556 |
|
|
|
|
| 560 |
|
| 561 |
**[⬆ Back to top](#)**
|
| 562 |
|
| 563 |
+
</div>
|
backend/app/auth.py
CHANGED
|
@@ -30,20 +30,34 @@ def verify_password(plain: str, hashed: str) -> bool:
|
|
| 30 |
|
| 31 |
# ── JWT Token ────────────────────────────────────────
|
| 32 |
|
| 33 |
-
def
|
| 34 |
-
"""Create a JWT token with user_id as the subject."""
|
| 35 |
payload = {
|
| 36 |
"sub": user_id,
|
| 37 |
-
"
|
|
|
|
| 38 |
"iat": datetime.now(timezone.utc),
|
| 39 |
}
|
| 40 |
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 41 |
|
| 42 |
|
| 43 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
"""Decode JWT and return user_id, or None if invalid."""
|
| 45 |
try:
|
| 46 |
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
|
|
|
|
|
|
| 47 |
return payload.get("sub")
|
| 48 |
except jwt.ExpiredSignatureError:
|
| 49 |
return None
|
|
|
|
| 30 |
|
| 31 |
# ── JWT Token ────────────────────────────────────────
|
| 32 |
|
| 33 |
+
def create_access_token(user_id: str) -> str:
|
| 34 |
+
"""Create a JWT access token with user_id as the subject."""
|
| 35 |
payload = {
|
| 36 |
"sub": user_id,
|
| 37 |
+
"type": "access",
|
| 38 |
+
"exp": datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_ACCESS_EXPIRY_MINUTES),
|
| 39 |
"iat": datetime.now(timezone.utc),
|
| 40 |
}
|
| 41 |
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 42 |
|
| 43 |
|
| 44 |
+
def create_refresh_token(user_id: str) -> str:
|
| 45 |
+
"""Create a JWT refresh token with user_id as the subject."""
|
| 46 |
+
payload = {
|
| 47 |
+
"sub": user_id,
|
| 48 |
+
"type": "refresh",
|
| 49 |
+
"exp": datetime.now(timezone.utc) + timedelta(days=settings.JWT_REFRESH_EXPIRY_DAYS),
|
| 50 |
+
"iat": datetime.now(timezone.utc),
|
| 51 |
+
}
|
| 52 |
+
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def decode_token(token: str, token_type: str = "access") -> Optional[str]:
|
| 56 |
"""Decode JWT and return user_id, or None if invalid."""
|
| 57 |
try:
|
| 58 |
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
| 59 |
+
if payload.get("type") != token_type:
|
| 60 |
+
return None
|
| 61 |
return payload.get("sub")
|
| 62 |
except jwt.ExpiredSignatureError:
|
| 63 |
return None
|
backend/app/config.py
CHANGED
|
@@ -12,17 +12,20 @@ class Settings(BaseSettings):
|
|
| 12 |
APP_NAME: str = "Document AI Analyst"
|
| 13 |
SECRET_KEY: str = "change-me-in-production-please"
|
| 14 |
DEBUG: bool = False
|
|
|
|
|
|
|
| 15 |
|
| 16 |
# ── Database ─────────────────────────────────────────
|
| 17 |
DATABASE_URL: str = "sqlite:///./data/app.db"
|
| 18 |
|
| 19 |
# ── Auth ─────────────────────────────────────────────
|
| 20 |
JWT_ALGORITHM: str = "HS256"
|
| 21 |
-
|
|
|
|
| 22 |
|
| 23 |
# ── File Upload ──────────────────────────────────────
|
| 24 |
UPLOAD_DIR: str = "./data/uploads"
|
| 25 |
-
|
| 26 |
ALLOWED_EXTENSIONS: set = {"pdf", "docx", "txt", "md"}
|
| 27 |
|
| 28 |
# ── RAG Pipeline ─────────────────────────────────────
|
|
@@ -47,6 +50,13 @@ class Settings(BaseSettings):
|
|
| 47 |
# ── Reranker ─────────────────────────────────────────
|
| 48 |
RERANKER_MODEL: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
class Config:
|
| 51 |
env_file = ".env"
|
| 52 |
env_file_encoding = "utf-8"
|
|
|
|
| 12 |
APP_NAME: str = "Document AI Analyst"
|
| 13 |
SECRET_KEY: str = "change-me-in-production-please"
|
| 14 |
DEBUG: bool = False
|
| 15 |
+
ENVIRONMENT: str = "development"
|
| 16 |
+
ALLOWED_ORIGINS: str = "http://localhost:3000,http://localhost:7860"
|
| 17 |
|
| 18 |
# ── Database ─────────────────────────────────────────
|
| 19 |
DATABASE_URL: str = "sqlite:///./data/app.db"
|
| 20 |
|
| 21 |
# ── Auth ─────────────────────────────────────────────
|
| 22 |
JWT_ALGORITHM: str = "HS256"
|
| 23 |
+
JWT_ACCESS_EXPIRY_MINUTES: int = 15
|
| 24 |
+
JWT_REFRESH_EXPIRY_DAYS: int = 7
|
| 25 |
|
| 26 |
# ── File Upload ──────────────────────────────────────
|
| 27 |
UPLOAD_DIR: str = "./data/uploads"
|
| 28 |
+
MAX_UPLOAD_SIZE_MB: int = 20
|
| 29 |
ALLOWED_EXTENSIONS: set = {"pdf", "docx", "txt", "md"}
|
| 30 |
|
| 31 |
# ── RAG Pipeline ─────────────────────────────────────
|
|
|
|
| 50 |
# ── Reranker ─────────────────────────────────────────
|
| 51 |
RERANKER_MODEL: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
| 52 |
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def cors_origins(self) -> list[str]:
|
| 56 |
+
if self.ENVIRONMENT == "production":
|
| 57 |
+
return [o.strip() for o in self.ALLOWED_ORIGINS.split(",")]
|
| 58 |
+
return ["*"]
|
| 59 |
+
|
| 60 |
class Config:
|
| 61 |
env_file = ".env"
|
| 62 |
env_file_encoding = "utf-8"
|
backend/app/main.py
CHANGED
|
@@ -63,11 +63,12 @@ app = FastAPI(
|
|
| 63 |
# ── CORS (allow frontend dev server) ─────────────────
|
| 64 |
app.add_middleware(
|
| 65 |
CORSMiddleware,
|
| 66 |
-
allow_origins=
|
| 67 |
allow_credentials=True,
|
| 68 |
allow_methods=["*"],
|
| 69 |
allow_headers=["*"],
|
| 70 |
)
|
|
|
|
| 71 |
|
| 72 |
# ── Mount API Routes ─────────────────────────────────
|
| 73 |
from app.routes.auth import router as auth_router
|
|
|
|
| 63 |
# ── CORS (allow frontend dev server) ─────────────────
|
| 64 |
app.add_middleware(
|
| 65 |
CORSMiddleware,
|
| 66 |
+
allow_origins=settings.cors_origins,
|
| 67 |
allow_credentials=True,
|
| 68 |
allow_methods=["*"],
|
| 69 |
allow_headers=["*"],
|
| 70 |
)
|
| 71 |
+
logger.info(f"CORS origins: {settings.cors_origins}")
|
| 72 |
|
| 73 |
# ── Mount API Routes ─────────────────────────────────
|
| 74 |
from app.routes.auth import router as auth_router
|
backend/app/rag/agent.py
CHANGED
|
@@ -74,11 +74,14 @@ def generate_answer(
|
|
| 74 |
Full RAG pipeline: retrieve → build context → generate answer.
|
| 75 |
Returns dict with 'answer' and 'sources'.
|
| 76 |
"""
|
|
|
|
| 77 |
client = get_llm_client()
|
| 78 |
|
| 79 |
# ── Handle greetings ─────────────────────────────
|
|
|
|
| 80 |
if is_greeting(question):
|
| 81 |
try:
|
|
|
|
| 82 |
messages = _chat_messages(
|
| 83 |
"You are Document AI Analyst, a friendly AI assistant for document analysis.",
|
| 84 |
question,
|
|
@@ -96,6 +99,7 @@ def generate_answer(
|
|
| 96 |
return {"answer": answer, "sources": []}
|
| 97 |
|
| 98 |
# ── Retrieve relevant chunks ─────────────────────
|
|
|
|
| 99 |
chunks = retrieve(
|
| 100 |
query=question,
|
| 101 |
user_id=user_id,
|
|
@@ -103,11 +107,13 @@ def generate_answer(
|
|
| 103 |
)
|
| 104 |
|
| 105 |
# ── Build prompt ─────────────────────────────────
|
|
|
|
| 106 |
context = build_context(chunks)
|
| 107 |
user_content = RAG_PROMPT_TEMPLATE.format(context=context, question=question)
|
| 108 |
messages = _chat_messages(SYSTEM_PROMPT, user_content)
|
| 109 |
|
| 110 |
# ── Generate answer ──────────────────────────────
|
|
|
|
| 111 |
try:
|
| 112 |
response = client.chat_completion(
|
| 113 |
messages=messages,
|
|
@@ -124,6 +130,7 @@ def generate_answer(
|
|
| 124 |
answer = f"I encountered an error generating a response. Please try again. Error: {str(e)}"
|
| 125 |
|
| 126 |
# ── Format sources ───────────────────────────────
|
|
|
|
| 127 |
sources = [
|
| 128 |
{
|
| 129 |
"text": chunk["text"][:300] + ("..." if len(chunk["text"]) > 300 else ""),
|
|
@@ -147,17 +154,22 @@ def generate_answer_stream(
|
|
| 147 |
Streaming RAG pipeline — yields SSE-formatted chunks.
|
| 148 |
First yields sources, then streams answer tokens.
|
| 149 |
"""
|
|
|
|
| 150 |
client = get_llm_client()
|
| 151 |
|
| 152 |
# ── Handle greetings ─────────────────────────────
|
|
|
|
| 153 |
if is_greeting(question):
|
|
|
|
| 154 |
yield f"data: {json.dumps({'type': 'sources', 'data': []})}\n\n"
|
| 155 |
|
| 156 |
try:
|
|
|
|
| 157 |
messages = _chat_messages(
|
| 158 |
"You are Document AI Analyst, a friendly AI assistant for document analysis.",
|
| 159 |
question,
|
| 160 |
)
|
|
|
|
| 161 |
stream = client.chat_completion(
|
| 162 |
messages=messages,
|
| 163 |
model=settings.LLM_MODEL,
|
|
@@ -173,10 +185,12 @@ def generate_answer_stream(
|
|
| 173 |
except Exception as e:
|
| 174 |
yield f"data: {json.dumps({'type': 'error', 'data': str(e)})}\n\n"
|
| 175 |
|
|
|
|
| 176 |
yield f"data: {json.dumps({'type': 'done'})}\n\n"
|
| 177 |
return
|
| 178 |
|
| 179 |
# ── Retrieve relevant chunks ─────────────────────
|
|
|
|
| 180 |
chunks = retrieve(
|
| 181 |
query=question,
|
| 182 |
user_id=user_id,
|
|
@@ -184,6 +198,7 @@ def generate_answer_stream(
|
|
| 184 |
)
|
| 185 |
|
| 186 |
# ── Yield sources first ──────────────────────────
|
|
|
|
| 187 |
sources = [
|
| 188 |
{
|
| 189 |
"text": chunk["text"][:300] + ("..." if len(chunk["text"]) > 300 else ""),
|
|
@@ -197,11 +212,13 @@ def generate_answer_stream(
|
|
| 197 |
yield f"data: {json.dumps({'type': 'sources', 'data': sources})}\n\n"
|
| 198 |
|
| 199 |
# ── Build prompt ─────────────────────────────────
|
|
|
|
| 200 |
context = build_context(chunks)
|
| 201 |
user_content = RAG_PROMPT_TEMPLATE.format(context=context, question=question)
|
| 202 |
messages = _chat_messages(SYSTEM_PROMPT, user_content)
|
| 203 |
|
| 204 |
# ── Stream answer tokens ─────────────────────────
|
|
|
|
| 205 |
try:
|
| 206 |
stream = client.chat_completion(
|
| 207 |
messages=messages,
|
|
@@ -216,8 +233,10 @@ def generate_answer_stream(
|
|
| 216 |
if delta:
|
| 217 |
yield f"data: {json.dumps({'type': 'token', 'data': delta})}\n\n"
|
| 218 |
|
|
|
|
| 219 |
except Exception as e:
|
| 220 |
logger.error(f"LLM streaming error: {e}")
|
| 221 |
yield f"data: {json.dumps({'type': 'error', 'data': str(e)})}\n\n"
|
| 222 |
|
|
|
|
| 223 |
yield f"data: {json.dumps({'type': 'done'})}\n\n"
|
|
|
|
| 74 |
Full RAG pipeline: retrieve → build context → generate answer.
|
| 75 |
Returns dict with 'answer' and 'sources'.
|
| 76 |
"""
|
| 77 |
+
# Get HuggingFace InferenceClient singleton (created once, reused)
|
| 78 |
client = get_llm_client()
|
| 79 |
|
| 80 |
# ── Handle greetings ─────────────────────────────
|
| 81 |
+
# Short-circuit: if user just says "hello", skip RAG entirely
|
| 82 |
if is_greeting(question):
|
| 83 |
try:
|
| 84 |
+
# Send greeting to LLM with a friendly system prompt (no document context)
|
| 85 |
messages = _chat_messages(
|
| 86 |
"You are Document AI Analyst, a friendly AI assistant for document analysis.",
|
| 87 |
question,
|
|
|
|
| 99 |
return {"answer": answer, "sources": []}
|
| 100 |
|
| 101 |
# ── Retrieve relevant chunks ─────────────────────
|
| 102 |
+
# STAGE 1+2: Semantic search (ChromaDB) + cross-encoder reranking → top 5 chunks
|
| 103 |
chunks = retrieve(
|
| 104 |
query=question,
|
| 105 |
user_id=user_id,
|
|
|
|
| 107 |
)
|
| 108 |
|
| 109 |
# ── Build prompt ─────────────────────────────────
|
| 110 |
+
# Format retrieved chunks into a readable context block, then inject into the RAG prompt template
|
| 111 |
context = build_context(chunks)
|
| 112 |
user_content = RAG_PROMPT_TEMPLATE.format(context=context, question=question)
|
| 113 |
messages = _chat_messages(SYSTEM_PROMPT, user_content)
|
| 114 |
|
| 115 |
# ── Generate answer ──────────────────────────────
|
| 116 |
+
# STAGE 3: Send prompt to HuggingFace Inference API and get the generated answer
|
| 117 |
try:
|
| 118 |
response = client.chat_completion(
|
| 119 |
messages=messages,
|
|
|
|
| 130 |
answer = f"I encountered an error generating a response. Please try again. Error: {str(e)}"
|
| 131 |
|
| 132 |
# ── Format sources ───────────────────────────────
|
| 133 |
+
# Truncate chunk text to 300 chars and attach metadata (filename, page, score, confidence) for frontend citation display
|
| 134 |
sources = [
|
| 135 |
{
|
| 136 |
"text": chunk["text"][:300] + ("..." if len(chunk["text"]) > 300 else ""),
|
|
|
|
| 154 |
Streaming RAG pipeline — yields SSE-formatted chunks.
|
| 155 |
First yields sources, then streams answer tokens.
|
| 156 |
"""
|
| 157 |
+
# Get HuggingFace InferenceClient singleton (created once, reused)
|
| 158 |
client = get_llm_client()
|
| 159 |
|
| 160 |
# ── Handle greetings ─────────────────────────────
|
| 161 |
+
# Short-circuit: if user just says "hello", skip RAG entirely
|
| 162 |
if is_greeting(question):
|
| 163 |
+
# Yield empty sources array first so frontend resets its citation display
|
| 164 |
yield f"data: {json.dumps({'type': 'sources', 'data': []})}\n\n"
|
| 165 |
|
| 166 |
try:
|
| 167 |
+
# Send greeting to LLM with a friendly system prompt (no document context)
|
| 168 |
messages = _chat_messages(
|
| 169 |
"You are Document AI Analyst, a friendly AI assistant for document analysis.",
|
| 170 |
question,
|
| 171 |
)
|
| 172 |
+
# Stream greeting response token-by-token via SSE
|
| 173 |
stream = client.chat_completion(
|
| 174 |
messages=messages,
|
| 175 |
model=settings.LLM_MODEL,
|
|
|
|
| 185 |
except Exception as e:
|
| 186 |
yield f"data: {json.dumps({'type': 'error', 'data': str(e)})}\n\n"
|
| 187 |
|
| 188 |
+
# Signal end of stream, then exit early (no RAG)
|
| 189 |
yield f"data: {json.dumps({'type': 'done'})}\n\n"
|
| 190 |
return
|
| 191 |
|
| 192 |
# ── Retrieve relevant chunks ─────────────────────
|
| 193 |
+
# STAGE 1+2: Semantic search (ChromaDB) + cross-encoder reranking → top 5 chunks
|
| 194 |
chunks = retrieve(
|
| 195 |
query=question,
|
| 196 |
user_id=user_id,
|
|
|
|
| 198 |
)
|
| 199 |
|
| 200 |
# ── Yield sources first ──────────────────────────
|
| 201 |
+
# Yield all sources first — frontend needs them to render citation cards before the answer starts appearing
|
| 202 |
sources = [
|
| 203 |
{
|
| 204 |
"text": chunk["text"][:300] + ("..." if len(chunk["text"]) > 300 else ""),
|
|
|
|
| 212 |
yield f"data: {json.dumps({'type': 'sources', 'data': sources})}\n\n"
|
| 213 |
|
| 214 |
# ── Build prompt ─────────────────────────────────
|
| 215 |
+
# Format retrieved chunks into a readable context block, then inject into the RAG prompt template
|
| 216 |
context = build_context(chunks)
|
| 217 |
user_content = RAG_PROMPT_TEMPLATE.format(context=context, question=question)
|
| 218 |
messages = _chat_messages(SYSTEM_PROMPT, user_content)
|
| 219 |
|
| 220 |
# ── Stream answer tokens ─────────────────────────
|
| 221 |
+
# STAGE 3: Stream tokens from HuggingFace Inference API → forward each as an SSE 'token' event
|
| 222 |
try:
|
| 223 |
stream = client.chat_completion(
|
| 224 |
messages=messages,
|
|
|
|
| 233 |
if delta:
|
| 234 |
yield f"data: {json.dumps({'type': 'token', 'data': delta})}\n\n"
|
| 235 |
|
| 236 |
+
# If LLM fails mid-stream, yield an error event so frontend can display the message
|
| 237 |
except Exception as e:
|
| 238 |
logger.error(f"LLM streaming error: {e}")
|
| 239 |
yield f"data: {json.dumps({'type': 'error', 'data': str(e)})}\n\n"
|
| 240 |
|
| 241 |
+
# Signal end of stream to frontend (stops the streaming indicator)
|
| 242 |
yield f"data: {json.dumps({'type': 'done'})}\n\n"
|
backend/app/routes/auth.py
CHANGED
|
@@ -6,8 +6,8 @@ from sqlalchemy.orm import Session
|
|
| 6 |
|
| 7 |
from app.database import get_db
|
| 8 |
from app.models import User
|
| 9 |
-
from app.schemas import UserRegister, UserLogin, TokenResponse, UserResponse
|
| 10 |
-
from app.auth import hash_password, verify_password,
|
| 11 |
|
| 12 |
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 13 |
|
|
@@ -40,10 +40,12 @@ def register(payload: UserRegister, db: Session = Depends(get_db)):
|
|
| 40 |
db.refresh(user)
|
| 41 |
|
| 42 |
# Generate token
|
| 43 |
-
|
|
|
|
| 44 |
|
| 45 |
return TokenResponse(
|
| 46 |
-
access_token=
|
|
|
|
| 47 |
user=UserResponse.model_validate(user),
|
| 48 |
)
|
| 49 |
|
|
@@ -59,10 +61,39 @@ def login(payload: UserLogin, db: Session = Depends(get_db)):
|
|
| 59 |
detail="Invalid email or password",
|
| 60 |
)
|
| 61 |
|
| 62 |
-
|
|
|
|
| 63 |
|
| 64 |
return TokenResponse(
|
| 65 |
-
access_token=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
user=UserResponse.model_validate(user),
|
| 67 |
)
|
| 68 |
|
|
|
|
| 6 |
|
| 7 |
from app.database import get_db
|
| 8 |
from app.models import User
|
| 9 |
+
from app.schemas import UserRegister, UserLogin, TokenResponse, UserResponse, RefreshRequest
|
| 10 |
+
from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, get_current_user, decode_token
|
| 11 |
|
| 12 |
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 13 |
|
|
|
|
| 40 |
db.refresh(user)
|
| 41 |
|
| 42 |
# Generate token
|
| 43 |
+
access_token = create_access_token(user.id)
|
| 44 |
+
refresh_token = create_refresh_token(user.id)
|
| 45 |
|
| 46 |
return TokenResponse(
|
| 47 |
+
access_token=access_token,
|
| 48 |
+
refresh_token=refresh_token,
|
| 49 |
user=UserResponse.model_validate(user),
|
| 50 |
)
|
| 51 |
|
|
|
|
| 61 |
detail="Invalid email or password",
|
| 62 |
)
|
| 63 |
|
| 64 |
+
access_token = create_access_token(user.id)
|
| 65 |
+
refresh_token = create_refresh_token(user.id)
|
| 66 |
|
| 67 |
return TokenResponse(
|
| 68 |
+
access_token=access_token,
|
| 69 |
+
refresh_token=refresh_token,
|
| 70 |
+
user=UserResponse.model_validate(user),
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.post("/refresh", response_model=TokenResponse)
|
| 75 |
+
def refresh_token(payload: RefreshRequest, db: Session = Depends(get_db)):
|
| 76 |
+
"""Refresh access token."""
|
| 77 |
+
user_id = decode_token(payload.refresh_token, token_type="refresh")
|
| 78 |
+
if not user_id:
|
| 79 |
+
raise HTTPException(
|
| 80 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 81 |
+
detail="Invalid or expired refresh token",
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 85 |
+
if not user:
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 88 |
+
detail="User not found",
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
new_access_token = create_access_token(user.id)
|
| 92 |
+
new_refresh_token = create_refresh_token(user.id)
|
| 93 |
+
|
| 94 |
+
return TokenResponse(
|
| 95 |
+
access_token=new_access_token,
|
| 96 |
+
refresh_token=new_refresh_token,
|
| 97 |
user=UserResponse.model_validate(user),
|
| 98 |
)
|
| 99 |
|
backend/app/routes/documents.py
CHANGED
|
@@ -108,10 +108,14 @@ async def upload_document(
|
|
| 108 |
content = await file.read()
|
| 109 |
file_size = len(content)
|
| 110 |
|
| 111 |
-
if file_size > settings.
|
|
|
|
| 112 |
raise HTTPException(
|
| 113 |
status_code=400,
|
| 114 |
-
detail=
|
|
|
|
|
|
|
|
|
|
| 115 |
)
|
| 116 |
|
| 117 |
# ── Save file to disk ────────────────────────────
|
|
|
|
| 108 |
content = await file.read()
|
| 109 |
file_size = len(content)
|
| 110 |
|
| 111 |
+
if file_size > settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024:
|
| 112 |
+
size_mb = file_size / (1024 * 1024)
|
| 113 |
raise HTTPException(
|
| 114 |
status_code=400,
|
| 115 |
+
detail=(
|
| 116 |
+
f"Upload rejected: file size ({size_mb:.1f} MB) exceeds the maximum "
|
| 117 |
+
f"allowed size of {settings.MAX_UPLOAD_SIZE_MB} MB."
|
| 118 |
+
),
|
| 119 |
)
|
| 120 |
|
| 121 |
# ── Save file to disk ────────────────────────────
|
backend/app/schemas.py
CHANGED
|
@@ -21,10 +21,15 @@ class UserLogin(BaseModel):
|
|
| 21 |
|
| 22 |
class TokenResponse(BaseModel):
|
| 23 |
access_token: str
|
|
|
|
| 24 |
token_type: str = "bearer"
|
| 25 |
user: "UserResponse"
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
class UserResponse(BaseModel):
|
| 29 |
id: str
|
| 30 |
username: str
|
|
|
|
| 21 |
|
| 22 |
class TokenResponse(BaseModel):
|
| 23 |
access_token: str
|
| 24 |
+
refresh_token: str
|
| 25 |
token_type: str = "bearer"
|
| 26 |
user: "UserResponse"
|
| 27 |
|
| 28 |
|
| 29 |
+
class RefreshRequest(BaseModel):
|
| 30 |
+
refresh_token: str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
class UserResponse(BaseModel):
|
| 34 |
id: str
|
| 35 |
username: str
|
docker-compose.yml
CHANGED
|
@@ -14,6 +14,12 @@ services:
|
|
| 14 |
- UPLOAD_DIR=./data/uploads
|
| 15 |
- CHROMA_PERSIST_DIR=./data/chroma_db
|
| 16 |
restart: unless-stopped
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
volumes:
|
| 19 |
app_data:
|
|
|
|
| 14 |
- UPLOAD_DIR=./data/uploads
|
| 15 |
- CHROMA_PERSIST_DIR=./data/chroma_db
|
| 16 |
restart: unless-stopped
|
| 17 |
+
healthcheck:
|
| 18 |
+
test: ["CMD", "curl", "-f", "http://localhost:7860/api/health"]
|
| 19 |
+
interval: 30s
|
| 20 |
+
timeout: 10s
|
| 21 |
+
retries: 3
|
| 22 |
+
start_period: 60s
|
| 23 |
|
| 24 |
volumes:
|
| 25 |
app_data:
|
frontend/src/app/dashboard/page.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
import { useEffect, useState, useCallback } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
| 6 |
-
import { api } from "@/lib/api";
|
| 7 |
import Header from "@/components/layout/Header";
|
| 8 |
import DocumentSidebar from "@/components/document/DocumentSidebar";
|
| 9 |
import ChatPanel from "@/components/chat/ChatPanel";
|
|
@@ -43,8 +43,14 @@ export default function DashboardPage() {
|
|
| 43 |
try {
|
| 44 |
const data = await api.get<{ documents: DocInfo[] }>("/api/v1/documents/");
|
| 45 |
setDocuments(data.documents);
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
}, []);
|
| 50 |
|
|
@@ -90,6 +96,15 @@ export default function DashboardPage() {
|
|
| 90 |
onOpenContributors={() => setHallOfFameOpen(true)}
|
| 91 |
/>
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
<div className="flex-1 flex overflow-hidden">
|
| 94 |
{/* ── Left: Document Sidebar ──────────────── */}
|
| 95 |
{sidebarOpen && (
|
|
|
|
| 3 |
import { useEffect, useState, useCallback } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
| 6 |
+
import { api, CONNECTION_ERROR_BANNER_MESSAGE, CONNECTION_ERROR_MESSAGE } from "@/lib/api";
|
| 7 |
import Header from "@/components/layout/Header";
|
| 8 |
import DocumentSidebar from "@/components/document/DocumentSidebar";
|
| 9 |
import ChatPanel from "@/components/chat/ChatPanel";
|
|
|
|
| 43 |
try {
|
| 44 |
const data = await api.get<{ documents: DocInfo[] }>("/api/v1/documents/");
|
| 45 |
setDocuments(data.documents);
|
| 46 |
+
setConnectionError("");
|
| 47 |
+
} catch (err) {
|
| 48 |
+
const message = err instanceof Error ? err.message : CONNECTION_ERROR_MESSAGE;
|
| 49 |
+
setConnectionError(
|
| 50 |
+
message === CONNECTION_ERROR_MESSAGE
|
| 51 |
+
? CONNECTION_ERROR_BANNER_MESSAGE
|
| 52 |
+
: `⚠️ ${message}`
|
| 53 |
+
);
|
| 54 |
}
|
| 55 |
}, []);
|
| 56 |
|
|
|
|
| 96 |
onOpenContributors={() => setHallOfFameOpen(true)}
|
| 97 |
/>
|
| 98 |
|
| 99 |
+
{connectionError && (
|
| 100 |
+
<div
|
| 101 |
+
role="alert"
|
| 102 |
+
className="border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive"
|
| 103 |
+
>
|
| 104 |
+
{connectionError}
|
| 105 |
+
</div>
|
| 106 |
+
)}
|
| 107 |
+
|
| 108 |
<div className="flex-1 flex overflow-hidden">
|
| 109 |
{/* ── Left: Document Sidebar ──────────────── */}
|
| 110 |
{sidebarOpen && (
|
frontend/src/components/chat/ChatPanel.tsx
CHANGED
|
@@ -35,9 +35,27 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 35 |
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
| 36 |
const [input, setInput] = useState("");
|
| 37 |
const [streaming, setStreaming] = useState(false);
|
|
|
|
| 38 |
const bottomRef = useRef<HTMLDivElement>(null);
|
| 39 |
const prevDocId = useRef<string | null>(null);
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
// Auto-scroll to bottom whenever messages change
|
| 42 |
useEffect(() => {
|
| 43 |
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
@@ -45,14 +63,26 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 45 |
|
| 46 |
// Load history on doc change
|
| 47 |
useEffect(() => {
|
| 48 |
-
if (!activeDoc
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
api
|
| 52 |
.get<{ messages: Array<{ id: string; role: string; content: string; sources?: SourceChunk[] }> }>(
|
| 53 |
-
`/api/v1/chat/history/${
|
| 54 |
)
|
| 55 |
.then((data) => {
|
|
|
|
|
|
|
| 56 |
setMessages(
|
| 57 |
data.messages.map((m) => ({
|
| 58 |
id: m.id,
|
|
@@ -62,7 +92,14 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 62 |
}))
|
| 63 |
);
|
| 64 |
})
|
| 65 |
-
.catch(() =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}, [activeDoc]);
|
| 67 |
|
| 68 |
const handleSend = async () => {
|
|
@@ -205,6 +242,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 205 |
<div className="border-t border-border/50 p-4 bg-card/30 backdrop-blur-sm">
|
| 206 |
<div className="max-w-3xl mx-auto flex gap-2 items-end">
|
| 207 |
<Textarea
|
|
|
|
| 208 |
id="chat-input"
|
| 209 |
value={input}
|
| 210 |
onChange={(e) => setInput(e.target.value)}
|
|
|
|
| 35 |
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
| 36 |
const [input, setInput] = useState("");
|
| 37 |
const [streaming, setStreaming] = useState(false);
|
| 38 |
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 39 |
const bottomRef = useRef<HTMLDivElement>(null);
|
| 40 |
const prevDocId = useRef<string | null>(null);
|
| 41 |
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
const textarea = textareaRef.current;
|
| 44 |
+
if (!textarea) return;
|
| 45 |
+
|
| 46 |
+
textarea.style.height = "auto";
|
| 47 |
+
const computedMaxHeight = Number.parseFloat(
|
| 48 |
+
window.getComputedStyle(textarea).maxHeight
|
| 49 |
+
);
|
| 50 |
+
const maxHeight = Number.isFinite(computedMaxHeight)
|
| 51 |
+
? computedMaxHeight
|
| 52 |
+
: textarea.scrollHeight;
|
| 53 |
+
const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
|
| 54 |
+
textarea.style.height = `${nextHeight}px`;
|
| 55 |
+
textarea.style.overflowY =
|
| 56 |
+
textarea.scrollHeight > maxHeight ? "auto" : "hidden";
|
| 57 |
+
}, [input]);
|
| 58 |
+
|
| 59 |
// Auto-scroll to bottom whenever messages change
|
| 60 |
useEffect(() => {
|
| 61 |
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
|
|
| 63 |
|
| 64 |
// Load history on doc change
|
| 65 |
useEffect(() => {
|
| 66 |
+
if (!activeDoc) {
|
| 67 |
+
prevDocId.current = null;
|
| 68 |
+
setMessages([]);
|
| 69 |
+
return;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
if (activeDoc.id === prevDocId.current) return;
|
| 73 |
+
|
| 74 |
+
const documentId = activeDoc.id;
|
| 75 |
+
prevDocId.current = documentId;
|
| 76 |
+
setMessages([]);
|
| 77 |
+
let cancelled = false;
|
| 78 |
|
| 79 |
api
|
| 80 |
.get<{ messages: Array<{ id: string; role: string; content: string; sources?: SourceChunk[] }> }>(
|
| 81 |
+
`/api/v1/chat/history/${documentId}`
|
| 82 |
)
|
| 83 |
.then((data) => {
|
| 84 |
+
if (cancelled || prevDocId.current !== documentId) return;
|
| 85 |
+
|
| 86 |
setMessages(
|
| 87 |
data.messages.map((m) => ({
|
| 88 |
id: m.id,
|
|
|
|
| 92 |
}))
|
| 93 |
);
|
| 94 |
})
|
| 95 |
+
.catch(() => {
|
| 96 |
+
if (cancelled || prevDocId.current !== documentId) return;
|
| 97 |
+
setMessages([]);
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
return () => {
|
| 101 |
+
cancelled = true;
|
| 102 |
+
};
|
| 103 |
}, [activeDoc]);
|
| 104 |
|
| 105 |
const handleSend = async () => {
|
|
|
|
| 242 |
<div className="border-t border-border/50 p-4 bg-card/30 backdrop-blur-sm">
|
| 243 |
<div className="max-w-3xl mx-auto flex gap-2 items-end">
|
| 244 |
<Textarea
|
| 245 |
+
ref={textareaRef}
|
| 246 |
id="chat-input"
|
| 247 |
value={input}
|
| 248 |
onChange={(e) => setInput(e.target.value)}
|
frontend/src/components/chat/SourceCard.tsx
CHANGED
|
@@ -42,9 +42,9 @@ export default function SourceCard({ sources, onPageClick }: Props) {
|
|
| 42 |
key={i}
|
| 43 |
variant="secondary"
|
| 44 |
className="text-[10px] h-5 cursor-pointer hover:bg-primary/20 transition-colors"
|
| 45 |
-
onClick={() => onPageClick(src.page)}
|
| 46 |
>
|
| 47 |
-
p.{src.page} • {src.confidence}%
|
| 48 |
</Badge>
|
| 49 |
))}
|
| 50 |
</div>
|
|
@@ -64,7 +64,7 @@ export default function SourceCard({ sources, onPageClick }: Props) {
|
|
| 64 |
{src.filename}
|
| 65 |
</span>
|
| 66 |
<Badge variant="outline" className="text-[9px] h-4 px-1.5">
|
| 67 |
-
Page {src.page}
|
| 68 |
</Badge>
|
| 69 |
<Badge
|
| 70 |
variant="secondary"
|
|
@@ -83,7 +83,7 @@ export default function SourceCard({ sources, onPageClick }: Props) {
|
|
| 83 |
variant="ghost"
|
| 84 |
size="sm"
|
| 85 |
className="h-6 px-2 text-[10px]"
|
| 86 |
-
onClick={() => onPageClick(src.page)}
|
| 87 |
>
|
| 88 |
<Eye className="w-3 h-3 mr-1" />
|
| 89 |
View
|
|
|
|
| 42 |
key={i}
|
| 43 |
variant="secondary"
|
| 44 |
className="text-[10px] h-5 cursor-pointer hover:bg-primary/20 transition-colors"
|
| 45 |
+
onClick={() => onPageClick(src.page + 1)}
|
| 46 |
>
|
| 47 |
+
p.{src.page + 1} • {src.confidence}%
|
| 48 |
</Badge>
|
| 49 |
))}
|
| 50 |
</div>
|
|
|
|
| 64 |
{src.filename}
|
| 65 |
</span>
|
| 66 |
<Badge variant="outline" className="text-[9px] h-4 px-1.5">
|
| 67 |
+
Page {src.page + 1}
|
| 68 |
</Badge>
|
| 69 |
<Badge
|
| 70 |
variant="secondary"
|
|
|
|
| 83 |
variant="ghost"
|
| 84 |
size="sm"
|
| 85 |
className="h-6 px-2 text-[10px]"
|
| 86 |
+
onClick={() => onPageClick(src.page + 1)}
|
| 87 |
>
|
| 88 |
<Eye className="w-3 h-3 mr-1" />
|
| 89 |
View
|
frontend/src/components/document/DocumentSidebar.tsx
CHANGED
|
@@ -22,28 +22,34 @@ interface Props {
|
|
| 22 |
export default function DocumentSidebar({ documents, activeDoc, onSelectDoc, onDocumentsChange }: Props) {
|
| 23 |
const [uploading, setUploading] = useState(false);
|
| 24 |
const [uploadProgress, setUploadProgress] = useState(0);
|
|
|
|
| 25 |
const [deleting, setDeleting] = useState<string | null>(null);
|
| 26 |
|
| 27 |
const onDrop = useCallback(
|
| 28 |
-
|
| 29 |
if (acceptedFiles.length === 0) return;
|
| 30 |
-
setUploading(true);
|
| 31 |
-
setUploadProgress(0);
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
formData.append("file", acceptedFiles[i]);
|
| 37 |
-
await api.postForm("/api/v1/documents/upload", formData);
|
| 38 |
-
setUploadProgress(((i + 1) / acceptedFiles.length) * 100);
|
| 39 |
-
}
|
| 40 |
-
onDocumentsChange();
|
| 41 |
-
} catch (err) {
|
| 42 |
-
console.error("Upload failed:", err);
|
| 43 |
-
} finally {
|
| 44 |
-
setUploading(false);
|
| 45 |
setUploadProgress(0);
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
},
|
| 48 |
[onDocumentsChange]
|
| 49 |
);
|
|
@@ -97,7 +103,12 @@ export default function DocumentSidebar({ documents, activeDoc, onSelectDoc, onD
|
|
| 97 |
return (
|
| 98 |
<div className="h-full flex flex-col bg-sidebar">
|
| 99 |
{/* ── Upload Zone ─────────────────────────────── */}
|
| 100 |
-
<div className="p-3 border-b border-sidebar-border">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
<div
|
| 102 |
{...getRootProps()}
|
| 103 |
className={`relative rounded-lg border-2 border-dashed p-4 text-center cursor-pointer transition-all duration-200
|
|
|
|
| 22 |
export default function DocumentSidebar({ documents, activeDoc, onSelectDoc, onDocumentsChange }: Props) {
|
| 23 |
const [uploading, setUploading] = useState(false);
|
| 24 |
const [uploadProgress, setUploadProgress] = useState(0);
|
| 25 |
+
const [uploadError, setUploadError] = useState("");
|
| 26 |
const [deleting, setDeleting] = useState<string | null>(null);
|
| 27 |
|
| 28 |
const onDrop = useCallback(
|
| 29 |
+
(acceptedFiles: File[]) => {
|
| 30 |
if (acceptedFiles.length === 0) return;
|
|
|
|
|
|
|
| 31 |
|
| 32 |
+
void (async () => {
|
| 33 |
+
setUploadError("");
|
| 34 |
+
setUploading(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
setUploadProgress(0);
|
| 36 |
+
|
| 37 |
+
try {
|
| 38 |
+
for (let i = 0; i < acceptedFiles.length; i++) {
|
| 39 |
+
const formData = new FormData();
|
| 40 |
+
formData.append("file", acceptedFiles[i]);
|
| 41 |
+
await api.postForm("/api/v1/documents/upload", formData);
|
| 42 |
+
setUploadProgress(((i + 1) / acceptedFiles.length) * 100);
|
| 43 |
+
}
|
| 44 |
+
onDocumentsChange();
|
| 45 |
+
} catch (err) {
|
| 46 |
+
const message = err instanceof Error ? err.message : "Upload failed";
|
| 47 |
+
setUploadError(message);
|
| 48 |
+
} finally {
|
| 49 |
+
setUploading(false);
|
| 50 |
+
setUploadProgress(0);
|
| 51 |
+
}
|
| 52 |
+
})();
|
| 53 |
},
|
| 54 |
[onDocumentsChange]
|
| 55 |
);
|
|
|
|
| 103 |
return (
|
| 104 |
<div className="h-full flex flex-col bg-sidebar">
|
| 105 |
{/* ── Upload Zone ─────────────────────────────── */}
|
| 106 |
+
<div className="p-3 border-b border-sidebar-border space-y-2">
|
| 107 |
+
{uploadError && (
|
| 108 |
+
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
|
| 109 |
+
{uploadError}
|
| 110 |
+
</div>
|
| 111 |
+
)}
|
| 112 |
<div
|
| 113 |
{...getRootProps()}
|
| 114 |
className={`relative rounded-lg border-2 border-dashed p-4 text-center cursor-pointer transition-all duration-200
|
frontend/src/components/ui/textarea.tsx
CHANGED
|
@@ -2,9 +2,13 @@ import * as React from "react"
|
|
| 2 |
|
| 3 |
import { cn } from "@/lib/utils"
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
| 6 |
return (
|
| 7 |
<textarea
|
|
|
|
| 8 |
data-slot="textarea"
|
| 9 |
className={cn(
|
| 10 |
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
@@ -13,6 +17,8 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|
| 13 |
{...props}
|
| 14 |
/>
|
| 15 |
)
|
| 16 |
-
}
|
|
|
|
|
|
|
| 17 |
|
| 18 |
export { Textarea }
|
|
|
|
| 2 |
|
| 3 |
import { cn } from "@/lib/utils"
|
| 4 |
|
| 5 |
+
const Textarea = React.forwardRef<
|
| 6 |
+
HTMLTextAreaElement,
|
| 7 |
+
React.ComponentProps<"textarea">
|
| 8 |
+
>(({ className, ...props }, ref) => {
|
| 9 |
return (
|
| 10 |
<textarea
|
| 11 |
+
ref={ref}
|
| 12 |
data-slot="textarea"
|
| 13 |
className={cn(
|
| 14 |
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
|
|
| 17 |
{...props}
|
| 18 |
/>
|
| 19 |
)
|
| 20 |
+
})
|
| 21 |
+
|
| 22 |
+
Textarea.displayName = "Textarea"
|
| 23 |
|
| 24 |
export { Textarea }
|
frontend/src/lib/api.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
| 4 |
*/
|
| 5 |
|
| 6 |
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
|
|
|
|
|
|
| 7 |
|
| 8 |
interface FetchOptions extends RequestInit {
|
| 9 |
token?: string;
|
|
@@ -34,23 +36,71 @@ class ApiClient {
|
|
| 34 |
return headers;
|
| 35 |
}
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
async get<T>(path: string, options?: FetchOptions): Promise<T> {
|
| 38 |
-
const res = await
|
| 39 |
method: "GET",
|
| 40 |
headers: this.getHeaders(options?.token),
|
| 41 |
...options,
|
| 42 |
});
|
| 43 |
|
| 44 |
if (!res.ok) {
|
| 45 |
-
|
| 46 |
-
throw new Error(error.detail || "Request failed");
|
| 47 |
}
|
| 48 |
|
| 49 |
return res.json();
|
| 50 |
}
|
| 51 |
|
| 52 |
async post<T>(path: string, body?: unknown, options?: FetchOptions): Promise<T> {
|
| 53 |
-
const res = await
|
| 54 |
method: "POST",
|
| 55 |
headers: this.getHeaders(options?.token),
|
| 56 |
body: body ? JSON.stringify(body) : undefined,
|
|
@@ -58,8 +108,7 @@ class ApiClient {
|
|
| 58 |
});
|
| 59 |
|
| 60 |
if (!res.ok) {
|
| 61 |
-
|
| 62 |
-
throw new Error(error.detail || "Request failed");
|
| 63 |
}
|
| 64 |
|
| 65 |
return res.json();
|
|
@@ -73,7 +122,7 @@ class ApiClient {
|
|
| 73 |
}
|
| 74 |
// Don't set Content-Type — browser sets multipart boundary automatically
|
| 75 |
|
| 76 |
-
const res = await
|
| 77 |
method: "POST",
|
| 78 |
headers,
|
| 79 |
body: formData,
|
|
@@ -81,23 +130,21 @@ class ApiClient {
|
|
| 81 |
});
|
| 82 |
|
| 83 |
if (!res.ok) {
|
| 84 |
-
|
| 85 |
-
throw new Error(error.detail || "Upload failed");
|
| 86 |
}
|
| 87 |
|
| 88 |
return res.json();
|
| 89 |
}
|
| 90 |
|
| 91 |
async delete<T>(path: string, options?: FetchOptions): Promise<T> {
|
| 92 |
-
const res = await
|
| 93 |
method: "DELETE",
|
| 94 |
headers: this.getHeaders(options?.token),
|
| 95 |
...options,
|
| 96 |
});
|
| 97 |
|
| 98 |
if (!res.ok) {
|
| 99 |
-
|
| 100 |
-
throw new Error(error.detail || "Delete failed");
|
| 101 |
}
|
| 102 |
|
| 103 |
return res.json();
|
|
@@ -108,15 +155,14 @@ class ApiClient {
|
|
| 108 |
* Yields parsed SSE data objects.
|
| 109 |
*/
|
| 110 |
async *streamPost(path: string, body: unknown): AsyncGenerator<{ type: string; data?: unknown }> {
|
| 111 |
-
const res = await
|
| 112 |
method: "POST",
|
| 113 |
headers: this.getHeaders(),
|
| 114 |
body: JSON.stringify(body),
|
| 115 |
});
|
| 116 |
|
| 117 |
if (!res.ok) {
|
| 118 |
-
|
| 119 |
-
throw new Error(error.detail || "Stream request failed");
|
| 120 |
}
|
| 121 |
|
| 122 |
const reader = res.body?.getReader();
|
|
@@ -153,4 +199,4 @@ class ApiClient {
|
|
| 153 |
}
|
| 154 |
|
| 155 |
export const api = new ApiClient(API_BASE);
|
| 156 |
-
export { API_BASE };
|
|
|
|
| 4 |
*/
|
| 5 |
|
| 6 |
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
| 7 |
+
const CONNECTION_ERROR_MESSAGE = "Could not connect to the server. Please try again later.";
|
| 8 |
+
const CONNECTION_ERROR_BANNER_MESSAGE = `⚠️ ${CONNECTION_ERROR_MESSAGE}`;
|
| 9 |
|
| 10 |
interface FetchOptions extends RequestInit {
|
| 11 |
token?: string;
|
|
|
|
| 36 |
return headers;
|
| 37 |
}
|
| 38 |
|
| 39 |
+
private async fetchWithConnectionError(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
| 40 |
+
try {
|
| 41 |
+
return await fetch(input, init);
|
| 42 |
+
} catch (error) {
|
| 43 |
+
if (error instanceof TypeError) {
|
| 44 |
+
throw new Error(CONNECTION_ERROR_MESSAGE);
|
| 45 |
+
}
|
| 46 |
+
throw error;
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
private getPayloadMessage(payload: unknown): string | null {
|
| 51 |
+
if (typeof payload === "string" && payload.trim()) {
|
| 52 |
+
return payload;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
if (Array.isArray(payload)) {
|
| 56 |
+
const messages = payload
|
| 57 |
+
.map((item) => {
|
| 58 |
+
if (typeof item === "string") return item;
|
| 59 |
+
if (item && typeof item === "object" && "msg" in item && typeof item.msg === "string") {
|
| 60 |
+
return item.msg;
|
| 61 |
+
}
|
| 62 |
+
return null;
|
| 63 |
+
})
|
| 64 |
+
.filter((message): message is string => Boolean(message));
|
| 65 |
+
|
| 66 |
+
return messages.length > 0 ? messages.join(", ") : null;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return null;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
private async getErrorMessage(res: Response, fallback: string): Promise<string> {
|
| 73 |
+
const payload = await res.json().catch(() => null);
|
| 74 |
+
|
| 75 |
+
if (payload && typeof payload === "object") {
|
| 76 |
+
const errorPayload = payload as { detail?: unknown; error?: unknown; message?: unknown };
|
| 77 |
+
return (
|
| 78 |
+
this.getPayloadMessage(errorPayload.detail) ||
|
| 79 |
+
this.getPayloadMessage(errorPayload.message) ||
|
| 80 |
+
this.getPayloadMessage(errorPayload.error) ||
|
| 81 |
+
fallback
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
return this.getPayloadMessage(payload) || fallback;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
async get<T>(path: string, options?: FetchOptions): Promise<T> {
|
| 89 |
+
const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
|
| 90 |
method: "GET",
|
| 91 |
headers: this.getHeaders(options?.token),
|
| 92 |
...options,
|
| 93 |
});
|
| 94 |
|
| 95 |
if (!res.ok) {
|
| 96 |
+
throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
|
|
|
|
| 97 |
}
|
| 98 |
|
| 99 |
return res.json();
|
| 100 |
}
|
| 101 |
|
| 102 |
async post<T>(path: string, body?: unknown, options?: FetchOptions): Promise<T> {
|
| 103 |
+
const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
|
| 104 |
method: "POST",
|
| 105 |
headers: this.getHeaders(options?.token),
|
| 106 |
body: body ? JSON.stringify(body) : undefined,
|
|
|
|
| 108 |
});
|
| 109 |
|
| 110 |
if (!res.ok) {
|
| 111 |
+
throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
|
|
|
|
| 112 |
}
|
| 113 |
|
| 114 |
return res.json();
|
|
|
|
| 122 |
}
|
| 123 |
// Don't set Content-Type — browser sets multipart boundary automatically
|
| 124 |
|
| 125 |
+
const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
|
| 126 |
method: "POST",
|
| 127 |
headers,
|
| 128 |
body: formData,
|
|
|
|
| 130 |
});
|
| 131 |
|
| 132 |
if (!res.ok) {
|
| 133 |
+
throw new Error(await this.getErrorMessage(res, res.statusText || "Upload failed"));
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
return res.json();
|
| 137 |
}
|
| 138 |
|
| 139 |
async delete<T>(path: string, options?: FetchOptions): Promise<T> {
|
| 140 |
+
const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
|
| 141 |
method: "DELETE",
|
| 142 |
headers: this.getHeaders(options?.token),
|
| 143 |
...options,
|
| 144 |
});
|
| 145 |
|
| 146 |
if (!res.ok) {
|
| 147 |
+
throw new Error(await this.getErrorMessage(res, res.statusText || "Delete failed"));
|
|
|
|
| 148 |
}
|
| 149 |
|
| 150 |
return res.json();
|
|
|
|
| 155 |
* Yields parsed SSE data objects.
|
| 156 |
*/
|
| 157 |
async *streamPost(path: string, body: unknown): AsyncGenerator<{ type: string; data?: unknown }> {
|
| 158 |
+
const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
|
| 159 |
method: "POST",
|
| 160 |
headers: this.getHeaders(),
|
| 161 |
body: JSON.stringify(body),
|
| 162 |
});
|
| 163 |
|
| 164 |
if (!res.ok) {
|
| 165 |
+
throw new Error(await this.getErrorMessage(res, res.statusText || "Stream request failed"));
|
|
|
|
| 166 |
}
|
| 167 |
|
| 168 |
const reader = res.body?.getReader();
|
|
|
|
| 199 |
}
|
| 200 |
|
| 201 |
export const api = new ApiClient(API_BASE);
|
| 202 |
+
export { API_BASE, CONNECTION_ERROR_BANNER_MESSAGE, CONNECTION_ERROR_MESSAGE };
|