Spaces:
Running
Running
Resolve merge conflict in dashboard/page.tsx wrt document rename and updated auth guard
Browse files- .env.example +18 -1
- .gitignore +2 -1
- README.md +15 -1
- backend/app/auth.py +17 -5
- backend/app/celery_app.py +23 -0
- backend/app/config.py +9 -0
- backend/app/evaluation/__init__.py +2 -0
- backend/app/evaluation/ragas_pipeline.py +292 -0
- backend/app/routes/auth.py +206 -1
- backend/app/routes/documents.py +16 -151
- backend/app/schemas.py +1 -0
- backend/app/services/document_ingestion.py +27 -3
- backend/app/tasks.py +22 -0
- backend/evaluation/ragas_sample_questions.jsonl +50 -0
- backend/requirements.txt +2 -0
- backend/scripts/run_ragas_eval.py +59 -0
- backend/tests/test_auth.py +77 -0
- backend/tests/test_document_upload_validation.py +7 -3
- backend/tests/test_documents.py +5 -5
- backend/tests/test_ragas_pipeline.py +76 -0
- docker-compose.yml +46 -2
- docs/ARCHITECTURE.md +8 -6
- frontend/e2e/auth-and-chat.spec.ts +8 -2
- frontend/package-lock.json +1 -0
- frontend/src/app/dashboard/page.tsx +12 -9
- frontend/src/app/login/page.tsx +24 -4
- frontend/src/app/register/page.tsx +12 -3
- frontend/src/components/auth/HuggingFaceSignInButton.tsx +58 -0
- frontend/src/components/layout/Header.tsx +9 -1
- frontend/src/lib/api.ts +6 -2
- frontend/src/store/auth-store.ts +17 -9
- grafana_dashboard.json +1025 -0
- package-lock.json +0 -6
.env.example
CHANGED
|
@@ -55,6 +55,16 @@ ALLOWED_ORIGINS=http://localhost:3000,http://localhost:7860
|
|
| 55 |
# Optional — required only for Google sign-in.
|
| 56 |
# NEXT_PUBLIC_GOOGLE_CLIENT_ID=your_google_oauth_client_id.apps.googleusercontent.com
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
# ── File Upload ─────────────────────────────────────────────
|
| 59 |
|
| 60 |
# Directory where uploaded documents (PDFs, DOCXs, etc.) are stored.
|
|
@@ -69,13 +79,20 @@ ALLOWED_ORIGINS=http://localhost:3000,http://localhost:7860
|
|
| 69 |
# Optional — defaults to "pdf,docx,txt,md"
|
| 70 |
# ALLOWED_EXTENSIONS=pdf,docx,txt,md
|
| 71 |
|
| 72 |
-
# ── HuggingFace (Required for LLM inference) ───────
|
| 73 |
|
| 74 |
# HuggingFace API token. Used to call the Inference API for LLM responses.
|
| 75 |
# Get yours: https://huggingface.co/settings/tokens (free tier available)
|
| 76 |
# Required (app won't generate answers without it)
|
| 77 |
HF_TOKEN=your_huggingface_token_here
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
# ── LLM Configuration ───────────────────────────────────────
|
| 80 |
|
| 81 |
# HuggingFace model ID used for answer generation.
|
|
|
|
| 55 |
# Optional — required only for Google sign-in.
|
| 56 |
# NEXT_PUBLIC_GOOGLE_CLIENT_ID=your_google_oauth_client_id.apps.googleusercontent.com
|
| 57 |
|
| 58 |
+
# ── Celery / Redis Background Processing ───────────────────
|
| 59 |
+
|
| 60 |
+
# Redis URL used by FastAPI to enqueue PDF processing jobs.
|
| 61 |
+
# Optional — defaults to redis://localhost:6379/0
|
| 62 |
+
# CELERY_BROKER_URL=redis://localhost:6379/0
|
| 63 |
+
|
| 64 |
+
# Redis URL used by Celery to store task results/status.
|
| 65 |
+
# Optional — defaults to redis://localhost:6379/1
|
| 66 |
+
# CELERY_RESULT_BACKEND=redis://localhost:6379/1
|
| 67 |
+
|
| 68 |
# ── File Upload ─────────────────────────────────────────────
|
| 69 |
|
| 70 |
# Directory where uploaded documents (PDFs, DOCXs, etc.) are stored.
|
|
|
|
| 79 |
# Optional — defaults to "pdf,docx,txt,md"
|
| 80 |
# ALLOWED_EXTENSIONS=pdf,docx,txt,md
|
| 81 |
|
| 82 |
+
# ── HuggingFace (Required for LLM inference and OAuth) ───────
|
| 83 |
|
| 84 |
# HuggingFace API token. Used to call the Inference API for LLM responses.
|
| 85 |
# Get yours: https://huggingface.co/settings/tokens (free tier available)
|
| 86 |
# Required (app won't generate answers without it)
|
| 87 |
HF_TOKEN=your_huggingface_token_here
|
| 88 |
|
| 89 |
+
# HuggingFace OAuth variables for native login support
|
| 90 |
+
# Optional — required only for Hugging Face sign-in
|
| 91 |
+
HF_CLIENT_ID=your_hf_oauth_client_id
|
| 92 |
+
HF_CLIENT_SECRET=your_hf_oauth_client_secret
|
| 93 |
+
HF_REDIRECT_URI=http://localhost:8000/api/v1/auth/callback/huggingface
|
| 94 |
+
FRONTEND_URL=http://localhost:3000
|
| 95 |
+
|
| 96 |
# ── LLM Configuration ───────────────────────────────────────
|
| 97 |
|
| 98 |
# HuggingFace model ID used for answer generation.
|
.gitignore
CHANGED
|
@@ -8,6 +8,7 @@ __pycache__/
|
|
| 8 |
# Data (runtime generated)
|
| 9 |
data/
|
| 10 |
*.db
|
|
|
|
| 11 |
|
| 12 |
# Environment
|
| 13 |
.env
|
|
@@ -29,4 +30,4 @@ Thumbs.db
|
|
| 29 |
# Misc
|
| 30 |
*.log
|
| 31 |
static/
|
| 32 |
-
.planning/
|
|
|
|
| 8 |
# Data (runtime generated)
|
| 9 |
data/
|
| 10 |
*.db
|
| 11 |
+
backend/evaluation/ragas_results.json
|
| 12 |
|
| 13 |
# Environment
|
| 14 |
.env
|
|
|
|
| 30 |
# Misc
|
| 31 |
*.log
|
| 32 |
static/
|
| 33 |
+
.planning/
|
README.md
CHANGED
|
@@ -362,6 +362,8 @@ DATABASE_URL=sqlite:///./data/app.db
|
|
| 362 |
HF_TOKEN=hf_your_huggingface_token_here
|
| 363 |
UPLOAD_DIR=./data/uploads
|
| 364 |
CHROMA_PERSIST_DIR=./data/chroma_db
|
|
|
|
|
|
|
| 365 |
```
|
| 366 |
|
| 367 |
> Get your free HuggingFace token at [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens)
|
|
@@ -410,7 +412,7 @@ npm run dev
|
|
| 410 |
|
| 411 |
```bash
|
| 412 |
docker compose up --build
|
| 413 |
-
# →
|
| 414 |
```
|
| 415 |
|
| 416 |
<br/>
|
|
@@ -491,6 +493,10 @@ docker compose up --build
|
|
| 491 |
|---|---|---|---|---|
|
| 492 |
| `SECRET_KEY` | ✅ | — | JWT signing & session secret. Use a strong random string. | Generate: `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
| 493 |
| `HF_TOKEN` | ✅ | — | HuggingFace API token for LLM inference via Inference API. | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) (free) |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
| `ENVIRONMENT` | ❌ | `development` | Runtime mode. Set to `production` for deployment to lock CORS. | — |
|
| 495 |
| `DEBUG` | ❌ | `False` | Enable debug mode with detailed error pages. Never enable in production. | — |
|
| 496 |
| `ALLOWED_ORIGINS` | ❌ | `http://localhost:3000,http://localhost:7860` | Comma-separated CORS origins (only enforced in production). | Your deployed domain(s) |
|
|
@@ -499,6 +505,8 @@ docker compose up --build
|
|
| 499 |
| `JWT_EXPIRY_HOURS` | ❌ | `72` | JWT token lifetime in hours before re-login is required. | — |
|
| 500 |
| `GOOGLE_CLIENT_ID` | ❌ | — | Google OAuth web client ID used by FastAPI to verify ID tokens. | [Google Cloud Console](https://console.cloud.google.com/apis/credentials) |
|
| 501 |
| `NEXT_PUBLIC_GOOGLE_CLIENT_ID` | ❌ | — | Google OAuth web client ID exposed to the Next.js Google sign-in button. | [Google Cloud Console](https://console.cloud.google.com/apis/credentials) |
|
|
|
|
|
|
|
| 502 |
| `UPLOAD_DIR` | ❌ | `./data/uploads` | Local directory for storing uploaded documents. | — |
|
| 503 |
| `MAX_FILE_SIZE_MB` | ❌ | `50` | Maximum allowed upload file size in MB. | — |
|
| 504 |
| `ALLOWED_EXTENSIONS` | ❌ | `pdf,docx,txt,md` | Comma-separated list of permitted file extensions. | — |
|
|
@@ -524,6 +532,12 @@ docker compose up --build
|
|
| 524 |
|---------|-------------|
|
| 525 |
| `uvicorn app.main:app --reload` | Start FastAPI with hot reload |
|
| 526 |
| `uvicorn app.main:app --port 8000` | Start FastAPI on port 8000 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
|
| 528 |
### Frontend (`frontend/`)
|
| 529 |
|
|
|
|
| 362 |
HF_TOKEN=hf_your_huggingface_token_here
|
| 363 |
UPLOAD_DIR=./data/uploads
|
| 364 |
CHROMA_PERSIST_DIR=./data/chroma_db
|
| 365 |
+
CELERY_BROKER_URL=redis://localhost:6379/0
|
| 366 |
+
CELERY_RESULT_BACKEND=redis://localhost:6379/1
|
| 367 |
```
|
| 368 |
|
| 369 |
> Get your free HuggingFace token at [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens)
|
|
|
|
| 412 |
|
| 413 |
```bash
|
| 414 |
docker compose up --build
|
| 415 |
+
# → FastAPI, Redis, Celery worker, and Postgres at http://localhost:7860
|
| 416 |
```
|
| 417 |
|
| 418 |
<br/>
|
|
|
|
| 493 |
|---|---|---|---|---|
|
| 494 |
| `SECRET_KEY` | ✅ | — | JWT signing & session secret. Use a strong random string. | Generate: `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
| 495 |
| `HF_TOKEN` | ✅ | — | HuggingFace API token for LLM inference via Inference API. | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) (free) |
|
| 496 |
+
| `HF_CLIENT_ID` | ❌ | — | HuggingFace OAuth client ID. Required only for Hugging Face sign-in. | [HuggingFace Developer Settings](https://huggingface.co/settings/connected-applications) |
|
| 497 |
+
| `HF_CLIENT_SECRET` | ❌ | — | HuggingFace OAuth client secret. Required only for Hugging Face sign-in. | [HuggingFace Developer Settings](https://huggingface.co/settings/connected-applications) |
|
| 498 |
+
| `HF_REDIRECT_URI` | ❌ | `http://localhost:8000/api/v1/auth/callback/huggingface` | HuggingFace OAuth callback redirect URI. | — |
|
| 499 |
+
| `FRONTEND_URL` | ❌ | `http://localhost:3000` | Frontend URL to redirect to after OAuth callback finishes. | — |
|
| 500 |
| `ENVIRONMENT` | ❌ | `development` | Runtime mode. Set to `production` for deployment to lock CORS. | — |
|
| 501 |
| `DEBUG` | ❌ | `False` | Enable debug mode with detailed error pages. Never enable in production. | — |
|
| 502 |
| `ALLOWED_ORIGINS` | ❌ | `http://localhost:3000,http://localhost:7860` | Comma-separated CORS origins (only enforced in production). | Your deployed domain(s) |
|
|
|
|
| 505 |
| `JWT_EXPIRY_HOURS` | ❌ | `72` | JWT token lifetime in hours before re-login is required. | — |
|
| 506 |
| `GOOGLE_CLIENT_ID` | ❌ | — | Google OAuth web client ID used by FastAPI to verify ID tokens. | [Google Cloud Console](https://console.cloud.google.com/apis/credentials) |
|
| 507 |
| `NEXT_PUBLIC_GOOGLE_CLIENT_ID` | ❌ | — | Google OAuth web client ID exposed to the Next.js Google sign-in button. | [Google Cloud Console](https://console.cloud.google.com/apis/credentials) |
|
| 508 |
+
| `CELERY_BROKER_URL` | ❌ | `redis://localhost:6379/0` | Redis broker URL used by FastAPI to queue document ingestion jobs. | Redis |
|
| 509 |
+
| `CELERY_RESULT_BACKEND` | ❌ | `redis://localhost:6379/1` | Redis backend URL used by Celery to store task state/results. | Redis |
|
| 510 |
| `UPLOAD_DIR` | ❌ | `./data/uploads` | Local directory for storing uploaded documents. | — |
|
| 511 |
| `MAX_FILE_SIZE_MB` | ❌ | `50` | Maximum allowed upload file size in MB. | — |
|
| 512 |
| `ALLOWED_EXTENSIONS` | ❌ | `pdf,docx,txt,md` | Comma-separated list of permitted file extensions. | — |
|
|
|
|
| 532 |
|---------|-------------|
|
| 533 |
| `uvicorn app.main:app --reload` | Start FastAPI with hot reload |
|
| 534 |
| `uvicorn app.main:app --port 8000` | Start FastAPI on port 8000 |
|
| 535 |
+
| `python scripts/run_ragas_eval.py --user-id <user-id>` | Run the 50-question RAGAS comparison for vector search vs GraphRAG |
|
| 536 |
+
|
| 537 |
+
The RAGAS script reads `backend/evaluation/ragas_sample_questions.jsonl`,
|
| 538 |
+
generates answers from standard vector contexts and vector-plus-GraphRAG
|
| 539 |
+
contexts, then writes aggregate scores to `backend/evaluation/ragas_results.json`.
|
| 540 |
+
Pass `--document-id <document-id>` to evaluate one indexed document.
|
| 541 |
|
| 542 |
### Frontend (`frontend/`)
|
| 543 |
|
backend/app/auth.py
CHANGED
|
@@ -6,7 +6,7 @@ from typing import Optional, Any
|
|
| 6 |
|
| 7 |
import jwt
|
| 8 |
import bcrypt
|
| 9 |
-
from fastapi import Depends, HTTPException, status
|
| 10 |
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 11 |
from sqlalchemy.orm import Session
|
| 12 |
|
|
@@ -15,7 +15,7 @@ from app.database import get_db
|
|
| 15 |
from app.models import User, UserRole
|
| 16 |
|
| 17 |
settings = get_settings()
|
| 18 |
-
security = HTTPBearer()
|
| 19 |
|
| 20 |
|
| 21 |
# ── Password Hashing ─────────────────────────────────
|
|
@@ -96,11 +96,23 @@ def decode_invite_token(token: str) -> Optional[dict[str, Any]]:
|
|
| 96 |
import hashlib
|
| 97 |
|
| 98 |
def get_current_user(
|
| 99 |
-
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
|
|
| 100 |
db: Session = Depends(get_db),
|
| 101 |
) -> User:
|
| 102 |
-
"""Dependency: extract and validate user from JWT bearer token
|
| 103 |
-
token =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
# Check if token is an API key
|
| 106 |
if token.startswith("pdf_rag_"):
|
|
|
|
| 6 |
|
| 7 |
import jwt
|
| 8 |
import bcrypt
|
| 9 |
+
from fastapi import Depends, HTTPException, status, Cookie
|
| 10 |
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 11 |
from sqlalchemy.orm import Session
|
| 12 |
|
|
|
|
| 15 |
from app.models import User, UserRole
|
| 16 |
|
| 17 |
settings = get_settings()
|
| 18 |
+
security = HTTPBearer(auto_error=False)
|
| 19 |
|
| 20 |
|
| 21 |
# ── Password Hashing ─────────────────────────────────
|
|
|
|
| 96 |
import hashlib
|
| 97 |
|
| 98 |
def get_current_user(
|
| 99 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
| 100 |
+
access_token: Optional[str] = Cookie(None),
|
| 101 |
db: Session = Depends(get_db),
|
| 102 |
) -> User:
|
| 103 |
+
"""Dependency: extract and validate user from JWT bearer token, API key, or secure cookie."""
|
| 104 |
+
token = None
|
| 105 |
+
if credentials:
|
| 106 |
+
token = credentials.credentials
|
| 107 |
+
elif access_token:
|
| 108 |
+
token = access_token
|
| 109 |
+
|
| 110 |
+
if not token:
|
| 111 |
+
raise HTTPException(
|
| 112 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 113 |
+
detail="Invalid or expired token",
|
| 114 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 115 |
+
)
|
| 116 |
|
| 117 |
# Check if token is an API key
|
| 118 |
if token.startswith("pdf_rag_"):
|
backend/app/celery_app.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Celery application configured for Redis-backed background jobs."""
|
| 2 |
+
from celery import Celery
|
| 3 |
+
|
| 4 |
+
from app.config import get_settings
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
settings = get_settings()
|
| 8 |
+
|
| 9 |
+
celery_app = Celery(
|
| 10 |
+
"pdf_assistant_rag",
|
| 11 |
+
broker=settings.CELERY_BROKER_URL,
|
| 12 |
+
backend=settings.CELERY_RESULT_BACKEND,
|
| 13 |
+
include=["app.tasks"],
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
celery_app.conf.update(
|
| 17 |
+
task_track_started=settings.CELERY_TASK_TRACK_STARTED,
|
| 18 |
+
task_serializer="json",
|
| 19 |
+
result_serializer="json",
|
| 20 |
+
accept_content=["json"],
|
| 21 |
+
timezone="UTC",
|
| 22 |
+
)
|
| 23 |
+
|
backend/app/config.py
CHANGED
|
@@ -23,12 +23,21 @@ class Settings(BaseSettings):
|
|
| 23 |
JWT_ACCESS_EXPIRY_MINUTES: int = 15
|
| 24 |
JWT_REFRESH_EXPIRY_DAYS: int = 7
|
| 25 |
GOOGLE_CLIENT_ID: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
# Google Drive background sync
|
| 28 |
DRIVE_SYNC_ENABLED: bool = False
|
| 29 |
DRIVE_SYNC_INTERVAL_MINUTES: int = 60
|
| 30 |
GOOGLE_SERVICE_ACCOUNT_FILE: str = ""
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
# ── File Upload ──────────────────────────────────────
|
| 33 |
UPLOAD_DIR: str = "./data/uploads"
|
| 34 |
MAX_UPLOAD_SIZE_MB: int = 20
|
|
|
|
| 23 |
JWT_ACCESS_EXPIRY_MINUTES: int = 15
|
| 24 |
JWT_REFRESH_EXPIRY_DAYS: int = 7
|
| 25 |
GOOGLE_CLIENT_ID: str = ""
|
| 26 |
+
HF_CLIENT_ID: str = ""
|
| 27 |
+
HF_CLIENT_SECRET: str = ""
|
| 28 |
+
HF_REDIRECT_URI: str = ""
|
| 29 |
+
FRONTEND_URL: str = "http://localhost:3000"
|
| 30 |
|
| 31 |
# Google Drive background sync
|
| 32 |
DRIVE_SYNC_ENABLED: bool = False
|
| 33 |
DRIVE_SYNC_INTERVAL_MINUTES: int = 60
|
| 34 |
GOOGLE_SERVICE_ACCOUNT_FILE: str = ""
|
| 35 |
|
| 36 |
+
# Celery / Redis background processing
|
| 37 |
+
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
|
| 38 |
+
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
| 39 |
+
CELERY_TASK_TRACK_STARTED: bool = True
|
| 40 |
+
|
| 41 |
# ── File Upload ──────────────────────────────────────
|
| 42 |
UPLOAD_DIR: str = "./data/uploads"
|
| 43 |
MAX_UPLOAD_SIZE_MB: int = 20
|
backend/app/evaluation/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation helpers for offline RAG quality checks."""
|
| 2 |
+
|
backend/app/evaluation/ragas_pipeline.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RAGAS evaluation pipeline for vector search versus GraphRAG."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from statistics import mean
|
| 8 |
+
from typing import Any, Callable, Iterable, Optional
|
| 9 |
+
|
| 10 |
+
from huggingface_hub import InferenceClient
|
| 11 |
+
|
| 12 |
+
from app.config import get_settings
|
| 13 |
+
from app.rag.embeddings import embed_query
|
| 14 |
+
from app.rag.graph_retriever import get_entity_context
|
| 15 |
+
from app.rag.vectorstore import query_chunks
|
| 16 |
+
|
| 17 |
+
settings = get_settings()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
AnswerGenerator = Callable[[str, list[str]], str]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class EvaluationQuestion:
|
| 25 |
+
id: str
|
| 26 |
+
question: str
|
| 27 |
+
reference: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass(frozen=True)
|
| 31 |
+
class EvaluationRecord:
|
| 32 |
+
id: str
|
| 33 |
+
mode: str
|
| 34 |
+
question: str
|
| 35 |
+
reference: str
|
| 36 |
+
response: str
|
| 37 |
+
contexts: list[str]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def load_questions(dataset_path: Path, limit: int = 50) -> list[EvaluationQuestion]:
|
| 41 |
+
"""Load a JSONL RAGAS dataset and validate the required fields."""
|
| 42 |
+
questions: list[EvaluationQuestion] = []
|
| 43 |
+
|
| 44 |
+
with dataset_path.open("r", encoding="utf-8") as handle:
|
| 45 |
+
for line_number, line in enumerate(handle, start=1):
|
| 46 |
+
stripped = line.strip()
|
| 47 |
+
if not stripped:
|
| 48 |
+
continue
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
row = json.loads(stripped)
|
| 52 |
+
except json.JSONDecodeError as exc:
|
| 53 |
+
raise ValueError(f"Invalid JSON on line {line_number}: {exc}") from exc
|
| 54 |
+
|
| 55 |
+
missing = {"id", "question", "reference"} - set(row)
|
| 56 |
+
if missing:
|
| 57 |
+
fields = ", ".join(sorted(missing))
|
| 58 |
+
raise ValueError(f"Line {line_number} is missing required field(s): {fields}")
|
| 59 |
+
|
| 60 |
+
questions.append(
|
| 61 |
+
EvaluationQuestion(
|
| 62 |
+
id=str(row["id"]),
|
| 63 |
+
question=str(row["question"]).strip(),
|
| 64 |
+
reference=str(row["reference"]).strip(),
|
| 65 |
+
)
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
if len(questions) >= limit:
|
| 69 |
+
break
|
| 70 |
+
|
| 71 |
+
if len(questions) < limit:
|
| 72 |
+
raise ValueError(f"Expected {limit} evaluation questions, found {len(questions)}")
|
| 73 |
+
|
| 74 |
+
return questions
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def retrieve_vector_contexts(
|
| 78 |
+
question: str,
|
| 79 |
+
user_id: str,
|
| 80 |
+
document_id: Optional[str] = None,
|
| 81 |
+
top_k: Optional[int] = None,
|
| 82 |
+
) -> list[str]:
|
| 83 |
+
"""Retrieve plain vector-search contexts for a question."""
|
| 84 |
+
query_embedding = embed_query(question)
|
| 85 |
+
chunks = query_chunks(
|
| 86 |
+
query_embedding=query_embedding,
|
| 87 |
+
user_id=user_id,
|
| 88 |
+
document_id=document_id,
|
| 89 |
+
top_k=top_k or settings.TOP_K_RETRIEVAL,
|
| 90 |
+
)
|
| 91 |
+
return _chunk_texts(chunks)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def retrieve_graphrag_contexts(
|
| 95 |
+
question: str,
|
| 96 |
+
user_id: str,
|
| 97 |
+
document_id: Optional[str] = None,
|
| 98 |
+
top_k: Optional[int] = None,
|
| 99 |
+
) -> list[str]:
|
| 100 |
+
"""Retrieve vector contexts and append GraphRAG relationship context."""
|
| 101 |
+
contexts = retrieve_vector_contexts(
|
| 102 |
+
question=question,
|
| 103 |
+
user_id=user_id,
|
| 104 |
+
document_id=document_id,
|
| 105 |
+
top_k=top_k,
|
| 106 |
+
)
|
| 107 |
+
graph_context = get_entity_context(
|
| 108 |
+
query=question,
|
| 109 |
+
user_id=user_id,
|
| 110 |
+
document_id=document_id,
|
| 111 |
+
)
|
| 112 |
+
return append_graph_context(contexts, graph_context)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def append_graph_context(contexts: list[str], graph_context: str) -> list[str]:
|
| 116 |
+
"""Return contexts plus graph context when GraphRAG found relationships."""
|
| 117 |
+
clean_graph_context = graph_context.strip()
|
| 118 |
+
if not clean_graph_context:
|
| 119 |
+
return contexts
|
| 120 |
+
return [*contexts, clean_graph_context]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def generate_grounded_answer(question: str, contexts: list[str]) -> str:
|
| 124 |
+
"""Generate an answer using only retrieved contexts."""
|
| 125 |
+
if not contexts:
|
| 126 |
+
return "I do not have enough retrieved context to answer this question."
|
| 127 |
+
|
| 128 |
+
client = InferenceClient(token=settings.HF_TOKEN)
|
| 129 |
+
context_block = "\n\n".join(
|
| 130 |
+
f"Context {index}:\n{context}" for index, context in enumerate(contexts, start=1)
|
| 131 |
+
)
|
| 132 |
+
prompt = (
|
| 133 |
+
"Answer the question using only the provided context. "
|
| 134 |
+
"If the context is insufficient, say that the answer is not available in the context.\n\n"
|
| 135 |
+
f"{context_block}\n\nQuestion: {question}"
|
| 136 |
+
)
|
| 137 |
+
response = client.chat_completion(
|
| 138 |
+
messages=[
|
| 139 |
+
{
|
| 140 |
+
"role": "system",
|
| 141 |
+
"content": "You are a careful RAG evaluator that only uses supplied evidence.",
|
| 142 |
+
},
|
| 143 |
+
{"role": "user", "content": prompt},
|
| 144 |
+
],
|
| 145 |
+
model=settings.LLM_MODEL,
|
| 146 |
+
max_tokens=min(settings.LLM_MAX_NEW_TOKENS, 512),
|
| 147 |
+
temperature=0.0,
|
| 148 |
+
)
|
| 149 |
+
if not response.choices:
|
| 150 |
+
return ""
|
| 151 |
+
return (response.choices[0].message.content or "").strip()
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def collect_records(
|
| 155 |
+
questions: Iterable[EvaluationQuestion],
|
| 156 |
+
user_id: str,
|
| 157 |
+
document_id: Optional[str] = None,
|
| 158 |
+
answer_generator: AnswerGenerator = generate_grounded_answer,
|
| 159 |
+
) -> dict[str, list[EvaluationRecord]]:
|
| 160 |
+
"""Build vector and GraphRAG samples ready for RAGAS."""
|
| 161 |
+
grouped: dict[str, list[EvaluationRecord]] = {"vector": [], "graphrag": []}
|
| 162 |
+
|
| 163 |
+
for item in questions:
|
| 164 |
+
vector_contexts = retrieve_vector_contexts(
|
| 165 |
+
question=item.question,
|
| 166 |
+
user_id=user_id,
|
| 167 |
+
document_id=document_id,
|
| 168 |
+
)
|
| 169 |
+
graphrag_contexts = retrieve_graphrag_contexts(
|
| 170 |
+
question=item.question,
|
| 171 |
+
user_id=user_id,
|
| 172 |
+
document_id=document_id,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
grouped["vector"].append(
|
| 176 |
+
EvaluationRecord(
|
| 177 |
+
id=item.id,
|
| 178 |
+
mode="vector",
|
| 179 |
+
question=item.question,
|
| 180 |
+
reference=item.reference,
|
| 181 |
+
response=answer_generator(item.question, vector_contexts),
|
| 182 |
+
contexts=vector_contexts,
|
| 183 |
+
)
|
| 184 |
+
)
|
| 185 |
+
grouped["graphrag"].append(
|
| 186 |
+
EvaluationRecord(
|
| 187 |
+
id=item.id,
|
| 188 |
+
mode="graphrag",
|
| 189 |
+
question=item.question,
|
| 190 |
+
reference=item.reference,
|
| 191 |
+
response=answer_generator(item.question, graphrag_contexts),
|
| 192 |
+
contexts=graphrag_contexts,
|
| 193 |
+
)
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
return grouped
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def evaluate_records(records: list[EvaluationRecord]) -> dict[str, float]:
|
| 200 |
+
"""Run RAGAS over collected records and return mean metric scores."""
|
| 201 |
+
from langchain_huggingface import HuggingFaceEndpoint
|
| 202 |
+
from ragas import EvaluationDataset, evaluate
|
| 203 |
+
from ragas.llms import LangchainLLMWrapper
|
| 204 |
+
from ragas.metrics import Faithfulness, FactualCorrectness, LLMContextRecall
|
| 205 |
+
|
| 206 |
+
dataset = EvaluationDataset.from_list(
|
| 207 |
+
[
|
| 208 |
+
{
|
| 209 |
+
"user_input": record.question,
|
| 210 |
+
"retrieved_contexts": record.contexts,
|
| 211 |
+
"response": record.response,
|
| 212 |
+
"reference": record.reference,
|
| 213 |
+
}
|
| 214 |
+
for record in records
|
| 215 |
+
]
|
| 216 |
+
)
|
| 217 |
+
evaluator_llm = LangchainLLMWrapper(
|
| 218 |
+
HuggingFaceEndpoint(
|
| 219 |
+
repo_id=settings.LLM_MODEL,
|
| 220 |
+
huggingfacehub_api_token=settings.HF_TOKEN,
|
| 221 |
+
max_new_tokens=512,
|
| 222 |
+
temperature=0.0,
|
| 223 |
+
timeout=300,
|
| 224 |
+
)
|
| 225 |
+
)
|
| 226 |
+
result = evaluate(
|
| 227 |
+
dataset=dataset,
|
| 228 |
+
metrics=[
|
| 229 |
+
Faithfulness(),
|
| 230 |
+
FactualCorrectness(),
|
| 231 |
+
LLMContextRecall(),
|
| 232 |
+
],
|
| 233 |
+
llm=evaluator_llm,
|
| 234 |
+
)
|
| 235 |
+
return summarize_ragas_result(result)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def compare_pipelines(grouped_records: dict[str, list[EvaluationRecord]]) -> dict[str, Any]:
|
| 239 |
+
"""Evaluate both retrieval modes and include metric deltas."""
|
| 240 |
+
vector_scores = evaluate_records(grouped_records["vector"])
|
| 241 |
+
graphrag_scores = evaluate_records(grouped_records["graphrag"])
|
| 242 |
+
metrics = sorted(set(vector_scores) | set(graphrag_scores))
|
| 243 |
+
|
| 244 |
+
return {
|
| 245 |
+
"vector": vector_scores,
|
| 246 |
+
"graphrag": graphrag_scores,
|
| 247 |
+
"delta": {
|
| 248 |
+
metric: round(graphrag_scores.get(metric, 0.0) - vector_scores.get(metric, 0.0), 4)
|
| 249 |
+
for metric in metrics
|
| 250 |
+
},
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def summarize_ragas_result(result: Any) -> dict[str, float]:
|
| 255 |
+
"""Normalize RAGAS result objects into mean metric scores."""
|
| 256 |
+
if hasattr(result, "to_pandas"):
|
| 257 |
+
dataframe = result.to_pandas()
|
| 258 |
+
scores: dict[str, float] = {}
|
| 259 |
+
for column in dataframe.columns:
|
| 260 |
+
values = [
|
| 261 |
+
float(value)
|
| 262 |
+
for value in dataframe[column].tolist()
|
| 263 |
+
if isinstance(value, (int, float)) and value == value
|
| 264 |
+
]
|
| 265 |
+
if values:
|
| 266 |
+
scores[str(column)] = round(mean(values), 4)
|
| 267 |
+
return scores
|
| 268 |
+
|
| 269 |
+
if isinstance(result, dict):
|
| 270 |
+
return {
|
| 271 |
+
str(key): round(float(value), 4)
|
| 272 |
+
for key, value in result.items()
|
| 273 |
+
if isinstance(value, (int, float))
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
scores = getattr(result, "scores", None)
|
| 277 |
+
if isinstance(scores, list):
|
| 278 |
+
by_metric: dict[str, list[float]] = {}
|
| 279 |
+
for row in scores:
|
| 280 |
+
if not isinstance(row, dict):
|
| 281 |
+
continue
|
| 282 |
+
for key, value in row.items():
|
| 283 |
+
if isinstance(value, (int, float)):
|
| 284 |
+
by_metric.setdefault(str(key), []).append(float(value))
|
| 285 |
+
return {key: round(mean(values), 4) for key, values in by_metric.items()}
|
| 286 |
+
|
| 287 |
+
raise TypeError(f"Unsupported RAGAS result type: {type(result)!r}")
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _chunk_texts(chunks: list[dict[str, Any]]) -> list[str]:
|
| 291 |
+
return [str(chunk["text"]) for chunk in chunks if chunk.get("text")]
|
| 292 |
+
|
backend/app/routes/auth.py
CHANGED
|
@@ -3,8 +3,11 @@ Auth API routes — register, login, and user profile.
|
|
| 3 |
"""
|
| 4 |
import re
|
| 5 |
import secrets
|
|
|
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
-
from fastapi import APIRouter,
|
|
|
|
|
|
|
| 8 |
from langsmith import expect
|
| 9 |
from sqlalchemy.exc import SQLAlchemyError
|
| 10 |
from sqlalchemy.orm import Session
|
|
@@ -479,3 +482,205 @@ def get_auth_config():
|
|
| 479 |
return {
|
| 480 |
"google_client_id": settings.GOOGLE_CLIENT_ID
|
| 481 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
import re
|
| 5 |
import secrets
|
| 6 |
+
from typing import Optional
|
| 7 |
from datetime import datetime, timezone
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Cookie, Response, Body
|
| 9 |
+
from fastapi.responses import RedirectResponse
|
| 10 |
+
import httpx
|
| 11 |
from langsmith import expect
|
| 12 |
from sqlalchemy.exc import SQLAlchemyError
|
| 13 |
from sqlalchemy.orm import Session
|
|
|
|
| 482 |
return {
|
| 483 |
"google_client_id": settings.GOOGLE_CLIENT_ID
|
| 484 |
}
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def _unique_google_username(email: str, db: Session) -> str:
|
| 488 |
+
"""
|
| 489 |
+
Generate a unique username based on the email.
|
| 490 |
+
"""
|
| 491 |
+
base = email.split("@")[0]
|
| 492 |
+
base = re.sub(r"[^a-zA-Z0-9_-]", "", base)
|
| 493 |
+
base = base[:70]
|
| 494 |
+
candidate = base
|
| 495 |
+
suffix = 1
|
| 496 |
+
|
| 497 |
+
while db.query(User).filter(User.username == candidate).first():
|
| 498 |
+
suffix += 1
|
| 499 |
+
suffix_text = f"-{suffix}"
|
| 500 |
+
candidate = f"{base[:80 - len(suffix_text)]}{suffix_text}"
|
| 501 |
+
|
| 502 |
+
return candidate
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
@router.get("/login/huggingface")
|
| 506 |
+
def huggingface_login(response: Response):
|
| 507 |
+
"""
|
| 508 |
+
Generates a secure state, stores it in an HttpOnly cookie,
|
| 509 |
+
and returns the Hugging Face OAuth authorization URL.
|
| 510 |
+
"""
|
| 511 |
+
if not settings.HF_CLIENT_ID or not settings.HF_REDIRECT_URI:
|
| 512 |
+
raise HTTPException(
|
| 513 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 514 |
+
detail="Hugging Face OAuth is not configured",
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
# Generate CSRF state
|
| 518 |
+
state = secrets.token_urlsafe(32)
|
| 519 |
+
|
| 520 |
+
# Store state in cookie (valid for 10 minutes)
|
| 521 |
+
response.set_cookie(
|
| 522 |
+
key="oauth_state",
|
| 523 |
+
value=state,
|
| 524 |
+
httponly=True,
|
| 525 |
+
secure=settings.ENVIRONMENT == "production",
|
| 526 |
+
samesite="lax",
|
| 527 |
+
max_age=600, # 10 minutes
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
# Build Hugging Face authorize URL
|
| 531 |
+
scope = "openid profile email"
|
| 532 |
+
auth_url = (
|
| 533 |
+
f"https://huggingface.co/oauth/authorize?"
|
| 534 |
+
f"client_id={settings.HF_CLIENT_ID}&"
|
| 535 |
+
f"redirect_uri={settings.HF_REDIRECT_URI}&"
|
| 536 |
+
f"scope={scope}&"
|
| 537 |
+
f"state={state}&"
|
| 538 |
+
f"response_type=code"
|
| 539 |
+
)
|
| 540 |
+
|
| 541 |
+
return {"url": auth_url}
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
@router.get("/callback/huggingface")
|
| 545 |
+
async def huggingface_callback(
|
| 546 |
+
code: str,
|
| 547 |
+
state: str,
|
| 548 |
+
response: Response,
|
| 549 |
+
oauth_state: Optional[str] = Cookie(None),
|
| 550 |
+
db: Session = Depends(get_db),
|
| 551 |
+
):
|
| 552 |
+
"""
|
| 553 |
+
Verifies state, exchanges code for access token,
|
| 554 |
+
gets user info, upserts user, sets HttpOnly JWT cookies,
|
| 555 |
+
and redirects to the frontend dashboard.
|
| 556 |
+
"""
|
| 557 |
+
# 1. Verify CSRF State
|
| 558 |
+
if not oauth_state or state != oauth_state:
|
| 559 |
+
raise HTTPException(
|
| 560 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 561 |
+
detail="State verification failed. Possible CSRF attack.",
|
| 562 |
+
)
|
| 563 |
+
|
| 564 |
+
# 2. Exchange code for access_token via Hugging Face API
|
| 565 |
+
token_url = "https://huggingface.co/oauth/token"
|
| 566 |
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
| 567 |
+
data = {
|
| 568 |
+
"grant_type": "authorization_code",
|
| 569 |
+
"code": code,
|
| 570 |
+
"redirect_uri": settings.HF_REDIRECT_URI,
|
| 571 |
+
"client_id": settings.HF_CLIENT_ID,
|
| 572 |
+
"client_secret": settings.HF_CLIENT_SECRET,
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
async with httpx.AsyncClient() as client:
|
| 576 |
+
try:
|
| 577 |
+
token_response = await client.post(token_url, headers=headers, data=data)
|
| 578 |
+
token_response.raise_for_status()
|
| 579 |
+
token_data = token_response.json()
|
| 580 |
+
except httpx.HTTPStatusError as e:
|
| 581 |
+
raise HTTPException(
|
| 582 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 583 |
+
detail=f"Failed to exchange code: {e.response.text}",
|
| 584 |
+
)
|
| 585 |
+
except Exception as e:
|
| 586 |
+
raise HTTPException(
|
| 587 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 588 |
+
detail=f"Token exchange error: {str(e)}",
|
| 589 |
+
)
|
| 590 |
+
|
| 591 |
+
hf_access_token = token_data.get("access_token")
|
| 592 |
+
if not hf_access_token:
|
| 593 |
+
raise HTTPException(
|
| 594 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 595 |
+
detail="No access token returned from Hugging Face",
|
| 596 |
+
)
|
| 597 |
+
|
| 598 |
+
# 3. Fetch user profile data via /oauth/userinfo
|
| 599 |
+
userinfo_url = "https://huggingface.co/oauth/userinfo"
|
| 600 |
+
userinfo_headers = {"Authorization": f"Bearer {hf_access_token}"}
|
| 601 |
+
|
| 602 |
+
async with httpx.AsyncClient() as client:
|
| 603 |
+
try:
|
| 604 |
+
userinfo_response = await client.get(userinfo_url, headers=userinfo_headers)
|
| 605 |
+
userinfo_response.raise_for_status()
|
| 606 |
+
user_data = userinfo_response.json()
|
| 607 |
+
except Exception as e:
|
| 608 |
+
raise HTTPException(
|
| 609 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 610 |
+
detail=f"Failed to retrieve Hugging Face user info: {str(e)}",
|
| 611 |
+
)
|
| 612 |
+
|
| 613 |
+
email = user_data.get("email")
|
| 614 |
+
username = user_data.get("preferred_username") or user_data.get("username") or user_data.get("name")
|
| 615 |
+
|
| 616 |
+
if not email:
|
| 617 |
+
raise HTTPException(
|
| 618 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 619 |
+
detail="Hugging Face account email is required but not provided",
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
email = email.lower()
|
| 623 |
+
if not username:
|
| 624 |
+
username = email.split("@")[0]
|
| 625 |
+
|
| 626 |
+
# 4. Upsert user in the DB
|
| 627 |
+
user = db.query(User).filter(User.email == email).first()
|
| 628 |
+
if not user:
|
| 629 |
+
# Check if username is already taken
|
| 630 |
+
username = _unique_google_username(email, db)
|
| 631 |
+
user = User(
|
| 632 |
+
username=username,
|
| 633 |
+
email=email,
|
| 634 |
+
hashed_password=hash_password(secrets.token_urlsafe(32)),
|
| 635 |
+
)
|
| 636 |
+
db.add(user)
|
| 637 |
+
db.commit()
|
| 638 |
+
db.refresh(user)
|
| 639 |
+
|
| 640 |
+
user.last_login = datetime.now(timezone.utc)
|
| 641 |
+
db.commit()
|
| 642 |
+
db.refresh(user)
|
| 643 |
+
|
| 644 |
+
# 5. Generate secure session JWT tokens for our app
|
| 645 |
+
access_token = create_access_token(user.id)
|
| 646 |
+
refresh_token = create_refresh_token(user.id)
|
| 647 |
+
|
| 648 |
+
# 6. Set tokens as HttpOnly cookies and Redirect
|
| 649 |
+
redirect_dest = f"{settings.FRONTEND_URL}/dashboard" if settings.ENVIRONMENT == "development" else "/dashboard"
|
| 650 |
+
response = RedirectResponse(
|
| 651 |
+
url=redirect_dest,
|
| 652 |
+
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
| 653 |
+
)
|
| 654 |
+
|
| 655 |
+
response.set_cookie(
|
| 656 |
+
key="access_token",
|
| 657 |
+
value=access_token,
|
| 658 |
+
httponly=True,
|
| 659 |
+
secure=settings.ENVIRONMENT == "production",
|
| 660 |
+
samesite="lax",
|
| 661 |
+
max_age=settings.JWT_ACCESS_EXPIRY_MINUTES * 60,
|
| 662 |
+
)
|
| 663 |
+
|
| 664 |
+
response.set_cookie(
|
| 665 |
+
key="refresh_token",
|
| 666 |
+
value=refresh_token,
|
| 667 |
+
httponly=True,
|
| 668 |
+
secure=settings.ENVIRONMENT == "production",
|
| 669 |
+
samesite="lax",
|
| 670 |
+
max_age=settings.JWT_REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
|
| 671 |
+
)
|
| 672 |
+
|
| 673 |
+
# Delete the oauth_state cookie
|
| 674 |
+
response.delete_cookie(key="oauth_state")
|
| 675 |
+
|
| 676 |
+
return response
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
@router.post("/logout")
|
| 680 |
+
def logout(response: Response):
|
| 681 |
+
"""
|
| 682 |
+
Logs out the user by clearing the secure session cookies.
|
| 683 |
+
"""
|
| 684 |
+
response.delete_cookie(key="access_token")
|
| 685 |
+
response.delete_cookie(key="refresh_token")
|
| 686 |
+
return {"message": "Successfully logged out"}
|
backend/app/routes/documents.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
Document management routes — upload, list, delete, and serve PDF files.
|
| 3 |
-
Background ingestion via
|
| 4 |
"""
|
| 5 |
import os
|
| 6 |
import sys
|
|
@@ -14,7 +14,7 @@ from pathlib import Path
|
|
| 14 |
import shutil
|
| 15 |
import tempfile
|
| 16 |
from urllib.parse import urlparse
|
| 17 |
-
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File,
|
| 18 |
from fastapi.responses import FileResponse
|
| 19 |
from sqlalchemy.orm import Session
|
| 20 |
|
|
@@ -30,8 +30,7 @@ from app.schemas import (
|
|
| 30 |
)
|
| 31 |
from app.auth import get_current_user
|
| 32 |
from app.config import get_settings
|
| 33 |
-
from app.
|
| 34 |
-
from app.rag.vectorstore import store_chunks
|
| 35 |
|
| 36 |
try:
|
| 37 |
from crawl4ai import AsyncWebCrawler
|
|
@@ -137,133 +136,6 @@ async def validate_upload(file: UploadFile):
|
|
| 137 |
pass
|
| 138 |
|
| 139 |
|
| 140 |
-
def _ingest_document(document_id: str, filepath: str, original_name: str, user_id: str):
|
| 141 |
-
"""
|
| 142 |
-
Process a document in the background: chunk document, generate embeddings, and store in ChromaDB,
|
| 143 |
-
calls document summary function, and update the database record.
|
| 144 |
-
|
| 145 |
-
This function is intended to be run as a background task.
|
| 146 |
-
It creates its own database session, updates the
|
| 147 |
-
document status, extracts text, splits into chunks, generates embeddings,
|
| 148 |
-
stores everything in ChromaDB, calls summary function, updates the document record with page count,
|
| 149 |
-
chunk count, and summary, and marks the document as 'ready'.
|
| 150 |
-
On failure, it sets status to 'failed' and records the error message.
|
| 151 |
-
|
| 152 |
-
Args:
|
| 153 |
-
document_id: Unique identifier of the document in the database.
|
| 154 |
-
filepath: Absolute or relative path to the uploaded file on disk.
|
| 155 |
-
original_name: original filename provided by the user (for logging and metadata).
|
| 156 |
-
user_id: Identifier of the user who owns the document.
|
| 157 |
-
|
| 158 |
-
Returns:
|
| 159 |
-
None
|
| 160 |
-
|
| 161 |
-
Note:
|
| 162 |
-
This function does not raise exceptions to the caller;
|
| 163 |
-
all errors are logged and the database record is updated accordingly.
|
| 164 |
-
"""
|
| 165 |
-
from app.database import SessionLocal
|
| 166 |
-
|
| 167 |
-
db = SessionLocal()
|
| 168 |
-
try:
|
| 169 |
-
doc = (
|
| 170 |
-
db.query(Document)
|
| 171 |
-
.filter(Document.id == document_id, Document.is_deleted.is_(False))
|
| 172 |
-
.first()
|
| 173 |
-
)
|
| 174 |
-
if not doc:
|
| 175 |
-
logger.error(f"Document {document_id} not found for ingestion")
|
| 176 |
-
return
|
| 177 |
-
|
| 178 |
-
# Update status to processing
|
| 179 |
-
doc.status = "processing"
|
| 180 |
-
db.commit()
|
| 181 |
-
|
| 182 |
-
# Get page count
|
| 183 |
-
page_count = get_page_count(filepath)
|
| 184 |
-
doc.page_count = page_count
|
| 185 |
-
|
| 186 |
-
# Chunk document with optional chunk size and overlap parameters from the document record, falling back to global defaults if not set
|
| 187 |
-
chunk_size = doc.chunk_size
|
| 188 |
-
chunk_overlap = doc.chunk_overlap
|
| 189 |
-
try:
|
| 190 |
-
kwargs = {}
|
| 191 |
-
if chunk_size is not None:
|
| 192 |
-
kwargs["chunk_size"] = chunk_size
|
| 193 |
-
if chunk_overlap is not None:
|
| 194 |
-
kwargs["chunk_overlap"] = chunk_overlap
|
| 195 |
-
|
| 196 |
-
if kwargs:
|
| 197 |
-
chunks = chunk_document(filepath, **kwargs)
|
| 198 |
-
else:
|
| 199 |
-
chunks = chunk_document(filepath)
|
| 200 |
-
|
| 201 |
-
except TypeError:
|
| 202 |
-
# Backward-compatible fallback for chunk_document implementations/tests
|
| 203 |
-
# that only accept (filepath)
|
| 204 |
-
chunks = chunk_document(filepath)
|
| 205 |
-
|
| 206 |
-
if not chunks:
|
| 207 |
-
doc.status = "failed"
|
| 208 |
-
doc.error_message = "No text could be extracted from the document"
|
| 209 |
-
db.commit()
|
| 210 |
-
return
|
| 211 |
-
|
| 212 |
-
# Build and persist a lightweight entity co-occurrence graph for GraphRAG.
|
| 213 |
-
try:
|
| 214 |
-
from app.rag.graph_builder import build_graph, save_graph
|
| 215 |
-
|
| 216 |
-
graph = build_graph(chunks)
|
| 217 |
-
save_graph(graph, user_id=user_id, document_id=document_id)
|
| 218 |
-
except Exception as e:
|
| 219 |
-
logger.warning(f"Could not build knowledge graph for document {document_id}: {e}")
|
| 220 |
-
|
| 221 |
-
# Store embeddings in ChromaDB
|
| 222 |
-
chunk_count = store_chunks(
|
| 223 |
-
chunks=chunks,
|
| 224 |
-
document_id=document_id,
|
| 225 |
-
filename=original_name,
|
| 226 |
-
user_id=user_id,
|
| 227 |
-
)
|
| 228 |
-
|
| 229 |
-
# Generate summary and update document record
|
| 230 |
-
try:
|
| 231 |
-
from app.rag.summarizer import generate_document_summary
|
| 232 |
-
|
| 233 |
-
summary = generate_document_summary(filepath, max_sentences=2)
|
| 234 |
-
if summary:
|
| 235 |
-
doc.summary = summary
|
| 236 |
-
db.commit() # Update document record with summary
|
| 237 |
-
except Exception as e:
|
| 238 |
-
logger.warning(f"Could not import summarizer for document {document_id}: {e}")
|
| 239 |
-
doc.summary = None
|
| 240 |
-
|
| 241 |
-
# Update document record
|
| 242 |
-
doc.chunk_count = chunk_count
|
| 243 |
-
doc.status = "ready"
|
| 244 |
-
db.commit()
|
| 245 |
-
|
| 246 |
-
logger.info(f"Document {document_id} ingested: {page_count} pages, {chunk_count} chunks")
|
| 247 |
-
|
| 248 |
-
except Exception as e:
|
| 249 |
-
logger.error(f"Ingestion error for {document_id}: {e}")
|
| 250 |
-
try:
|
| 251 |
-
doc = (
|
| 252 |
-
db.query(Document)
|
| 253 |
-
.filter(Document.id == document_id, Document.is_deleted.is_(False))
|
| 254 |
-
.first()
|
| 255 |
-
)
|
| 256 |
-
if doc:
|
| 257 |
-
doc.status = "failed"
|
| 258 |
-
doc.error_message = str(e)[:500]
|
| 259 |
-
db.commit()
|
| 260 |
-
except Exception:
|
| 261 |
-
pass
|
| 262 |
-
finally:
|
| 263 |
-
db.close()
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
def _crawl_in_new_loop(url: str) -> str:
|
| 268 |
"""Run the async crawler in a fresh event loop on a worker thread.
|
| 269 |
On Windows this must be a ProactorEventLoop to support subprocesses.
|
|
@@ -295,7 +167,6 @@ def _crawl_in_new_loop(url: str) -> str:
|
|
| 295 |
|
| 296 |
@router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_202_ACCEPTED)
|
| 297 |
async def upload_document(
|
| 298 |
-
background_tasks: BackgroundTasks,
|
| 299 |
file: UploadFile = File(...),
|
| 300 |
user: User = Depends(get_current_user),
|
| 301 |
db: Session = Depends(get_db),
|
|
@@ -305,12 +176,11 @@ async def upload_document(
|
|
| 305 |
|
| 306 |
Validates the uploaded file (extension, size, MIME type, integrity),
|
| 307 |
saves it to the user's directory, creates a database record with status
|
| 308 |
-
'pending',
|
| 309 |
-
|
| 310 |
-
|
| 311 |
|
| 312 |
Args:
|
| 313 |
-
background_tasks: FastAPI BackgroundTasks instance to run the ingestion process asynchronously.
|
| 314 |
file: The uploaded file, provided as a multipart/form-data field in the request.
|
| 315 |
user: The currently authenticated user, injected by the `get_current_user` dependency.
|
| 316 |
db: Database session, injected by the `get_db` dependency.
|
|
@@ -364,21 +234,19 @@ async def upload_document(
|
|
| 364 |
db.commit()
|
| 365 |
db.refresh(document)
|
| 366 |
|
| 367 |
-
# ──
|
| 368 |
-
|
| 369 |
-
_ingest_document,
|
| 370 |
document_id=document.id,
|
| 371 |
filepath=filepath,
|
| 372 |
original_name=file.filename,
|
| 373 |
user_id=user.id,
|
| 374 |
)
|
| 375 |
|
| 376 |
-
return DocumentResponse.model_validate(document)
|
| 377 |
|
| 378 |
@router.post("/urlupload", status_code=status.HTTP_202_ACCEPTED)
|
| 379 |
async def upload_document_url(
|
| 380 |
payload: UploadUrl,
|
| 381 |
-
background_tasks: BackgroundTasks,
|
| 382 |
user: User = Depends(get_current_user),
|
| 383 |
db: Session = Depends(get_db),
|
| 384 |
):
|
|
@@ -450,16 +318,15 @@ async def upload_document_url(
|
|
| 450 |
db.commit()
|
| 451 |
db.refresh(document)
|
| 452 |
|
| 453 |
-
# ──
|
| 454 |
-
|
| 455 |
-
_ingest_document,
|
| 456 |
document_id=document.id,
|
| 457 |
filepath=filepath,
|
| 458 |
original_name=original_name,
|
| 459 |
user_id=user.id,
|
| 460 |
)
|
| 461 |
|
| 462 |
-
return DocumentResponse.model_validate(document)
|
| 463 |
|
| 464 |
except HTTPException:
|
| 465 |
raise
|
|
@@ -716,7 +583,6 @@ def delete_document(
|
|
| 716 |
def update_chunk_settings(
|
| 717 |
document_id: str,
|
| 718 |
settings_update: ChunkSettings,
|
| 719 |
-
background_tasks: BackgroundTasks,
|
| 720 |
user: User = Depends(get_current_user),
|
| 721 |
db: Session = Depends(get_db),
|
| 722 |
):
|
|
@@ -727,7 +593,6 @@ def update_chunk_settings(
|
|
| 727 |
Args:
|
| 728 |
document_id: The unique identifier of the document to update.
|
| 729 |
settings_update: A ChunkSettings object containing the chunk_size and chunk_overlap values.
|
| 730 |
-
background_tasks: FastAPI BackgroundTasks instance to run the ingestion process asynchronously.
|
| 731 |
user: The currently authenticated user, injected by the `get_current_user` dependency.
|
| 732 |
db: Database session, injected by the `get_db` dependency.
|
| 733 |
|
|
@@ -768,13 +633,13 @@ def update_chunk_settings(
|
|
| 768 |
doc.summary = None
|
| 769 |
db.commit()
|
| 770 |
|
| 771 |
-
#
|
| 772 |
-
|
| 773 |
-
|
| 774 |
document_id=doc.id,
|
| 775 |
filepath=os.path.join(settings.UPLOAD_DIR, user.id, doc.filename),
|
| 776 |
original_name=doc.original_name,
|
| 777 |
user_id=user.id,
|
| 778 |
)
|
| 779 |
# Return the updated document record with new chunk settings
|
| 780 |
-
return DocumentResponse.model_validate(doc)
|
|
|
|
| 1 |
"""
|
| 2 |
Document management routes — upload, list, delete, and serve PDF files.
|
| 3 |
+
Background ingestion via Celery workers.
|
| 4 |
"""
|
| 5 |
import os
|
| 6 |
import sys
|
|
|
|
| 14 |
import shutil
|
| 15 |
import tempfile
|
| 16 |
from urllib.parse import urlparse
|
| 17 |
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status, Query
|
| 18 |
from fastapi.responses import FileResponse
|
| 19 |
from sqlalchemy.orm import Session
|
| 20 |
|
|
|
|
| 30 |
)
|
| 31 |
from app.auth import get_current_user
|
| 32 |
from app.config import get_settings
|
| 33 |
+
from app.tasks import process_document
|
|
|
|
| 34 |
|
| 35 |
try:
|
| 36 |
from crawl4ai import AsyncWebCrawler
|
|
|
|
| 136 |
pass
|
| 137 |
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
def _crawl_in_new_loop(url: str) -> str:
|
| 140 |
"""Run the async crawler in a fresh event loop on a worker thread.
|
| 141 |
On Windows this must be a ProactorEventLoop to support subprocesses.
|
|
|
|
| 167 |
|
| 168 |
@router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_202_ACCEPTED)
|
| 169 |
async def upload_document(
|
|
|
|
| 170 |
file: UploadFile = File(...),
|
| 171 |
user: User = Depends(get_current_user),
|
| 172 |
db: Session = Depends(get_db),
|
|
|
|
| 176 |
|
| 177 |
Validates the uploaded file (extension, size, MIME type, integrity),
|
| 178 |
saves it to the user's directory, creates a database record with status
|
| 179 |
+
'pending', queues a Celery task for chunking and embedding, and returns
|
| 180 |
+
202 Accepted immediately so large documents do not block the API request
|
| 181 |
+
while embeddings are generated.
|
| 182 |
|
| 183 |
Args:
|
|
|
|
| 184 |
file: The uploaded file, provided as a multipart/form-data field in the request.
|
| 185 |
user: The currently authenticated user, injected by the `get_current_user` dependency.
|
| 186 |
db: Database session, injected by the `get_db` dependency.
|
|
|
|
| 234 |
db.commit()
|
| 235 |
db.refresh(document)
|
| 236 |
|
| 237 |
+
# ── Queue background ingestion ─────────────────
|
| 238 |
+
task = process_document.delay(
|
|
|
|
| 239 |
document_id=document.id,
|
| 240 |
filepath=filepath,
|
| 241 |
original_name=file.filename,
|
| 242 |
user_id=user.id,
|
| 243 |
)
|
| 244 |
|
| 245 |
+
return DocumentResponse.model_validate(document).model_copy(update={"task_id": task.id})
|
| 246 |
|
| 247 |
@router.post("/urlupload", status_code=status.HTTP_202_ACCEPTED)
|
| 248 |
async def upload_document_url(
|
| 249 |
payload: UploadUrl,
|
|
|
|
| 250 |
user: User = Depends(get_current_user),
|
| 251 |
db: Session = Depends(get_db),
|
| 252 |
):
|
|
|
|
| 318 |
db.commit()
|
| 319 |
db.refresh(document)
|
| 320 |
|
| 321 |
+
# ── Queue background ingestion ───────────────────────
|
| 322 |
+
task = process_document.delay(
|
|
|
|
| 323 |
document_id=document.id,
|
| 324 |
filepath=filepath,
|
| 325 |
original_name=original_name,
|
| 326 |
user_id=user.id,
|
| 327 |
)
|
| 328 |
|
| 329 |
+
return DocumentResponse.model_validate(document).model_copy(update={"task_id": task.id})
|
| 330 |
|
| 331 |
except HTTPException:
|
| 332 |
raise
|
|
|
|
| 583 |
def update_chunk_settings(
|
| 584 |
document_id: str,
|
| 585 |
settings_update: ChunkSettings,
|
|
|
|
| 586 |
user: User = Depends(get_current_user),
|
| 587 |
db: Session = Depends(get_db),
|
| 588 |
):
|
|
|
|
| 593 |
Args:
|
| 594 |
document_id: The unique identifier of the document to update.
|
| 595 |
settings_update: A ChunkSettings object containing the chunk_size and chunk_overlap values.
|
|
|
|
| 596 |
user: The currently authenticated user, injected by the `get_current_user` dependency.
|
| 597 |
db: Database session, injected by the `get_db` dependency.
|
| 598 |
|
|
|
|
| 633 |
doc.summary = None
|
| 634 |
db.commit()
|
| 635 |
|
| 636 |
+
# Queue ingestion with updated chunk settings. The worker reads the new
|
| 637 |
+
# settings from the document record before re-chunking.
|
| 638 |
+
task = process_document.delay(
|
| 639 |
document_id=doc.id,
|
| 640 |
filepath=os.path.join(settings.UPLOAD_DIR, user.id, doc.filename),
|
| 641 |
original_name=doc.original_name,
|
| 642 |
user_id=user.id,
|
| 643 |
)
|
| 644 |
# Return the updated document record with new chunk settings
|
| 645 |
+
return DocumentResponse.model_validate(doc).model_copy(update={"task_id": task.id})
|
backend/app/schemas.py
CHANGED
|
@@ -119,6 +119,7 @@ class DocumentResponse(BaseModel):
|
|
| 119 |
error_message: Optional[str] = None
|
| 120 |
uploaded_at: datetime
|
| 121 |
summary: Optional[str] = None # New field for document summary
|
|
|
|
| 122 |
|
| 123 |
class Config:
|
| 124 |
from_attributes = True
|
|
|
|
| 119 |
error_message: Optional[str] = None
|
| 120 |
uploaded_at: datetime
|
| 121 |
summary: Optional[str] = None # New field for document summary
|
| 122 |
+
task_id: Optional[str] = None
|
| 123 |
|
| 124 |
class Config:
|
| 125 |
from_attributes = True
|
backend/app/services/document_ingestion.py
CHANGED
|
@@ -17,18 +17,31 @@ def ingest_document(document_id: str, filepath: str, original_name: str, user_id
|
|
| 17 |
|
| 18 |
db = SessionLocal()
|
| 19 |
try:
|
| 20 |
-
doc = db.query(Document).filter(
|
|
|
|
|
|
|
|
|
|
| 21 |
if not doc:
|
| 22 |
logger.error("Document %s not found for ingestion", document_id)
|
| 23 |
return
|
| 24 |
|
| 25 |
doc.status = "processing"
|
|
|
|
| 26 |
db.commit()
|
| 27 |
|
| 28 |
page_count = get_page_count(filepath)
|
| 29 |
doc.page_count = page_count
|
| 30 |
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
if not chunks:
|
| 34 |
doc.status = "failed"
|
|
@@ -36,6 +49,14 @@ def ingest_document(document_id: str, filepath: str, original_name: str, user_id
|
|
| 36 |
db.commit()
|
| 37 |
return
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
chunk_count = store_chunks(
|
| 40 |
chunks=chunks,
|
| 41 |
document_id=document_id,
|
|
@@ -69,7 +90,10 @@ def ingest_document(document_id: str, filepath: str, original_name: str, user_id
|
|
| 69 |
except Exception as e:
|
| 70 |
logger.error("Ingestion error for %s: %s", document_id, e)
|
| 71 |
try:
|
| 72 |
-
doc = db.query(Document).filter(
|
|
|
|
|
|
|
|
|
|
| 73 |
if doc:
|
| 74 |
doc.status = "failed"
|
| 75 |
doc.error_message = str(e)[:500]
|
|
|
|
| 17 |
|
| 18 |
db = SessionLocal()
|
| 19 |
try:
|
| 20 |
+
doc = db.query(Document).filter(
|
| 21 |
+
Document.id == document_id,
|
| 22 |
+
Document.is_deleted.is_(False),
|
| 23 |
+
).first()
|
| 24 |
if not doc:
|
| 25 |
logger.error("Document %s not found for ingestion", document_id)
|
| 26 |
return
|
| 27 |
|
| 28 |
doc.status = "processing"
|
| 29 |
+
doc.error_message = None
|
| 30 |
db.commit()
|
| 31 |
|
| 32 |
page_count = get_page_count(filepath)
|
| 33 |
doc.page_count = page_count
|
| 34 |
|
| 35 |
+
try:
|
| 36 |
+
chunk_kwargs = {}
|
| 37 |
+
if doc.chunk_size is not None:
|
| 38 |
+
chunk_kwargs["chunk_size"] = doc.chunk_size
|
| 39 |
+
if doc.chunk_overlap is not None:
|
| 40 |
+
chunk_kwargs["chunk_overlap"] = doc.chunk_overlap
|
| 41 |
+
chunks = chunk_document(filepath, **chunk_kwargs)
|
| 42 |
+
except TypeError:
|
| 43 |
+
# Preserve compatibility with patched/test implementations.
|
| 44 |
+
chunks = chunk_document(filepath)
|
| 45 |
|
| 46 |
if not chunks:
|
| 47 |
doc.status = "failed"
|
|
|
|
| 49 |
db.commit()
|
| 50 |
return
|
| 51 |
|
| 52 |
+
try:
|
| 53 |
+
from app.rag.graph_builder import build_graph, save_graph
|
| 54 |
+
|
| 55 |
+
graph = build_graph(chunks)
|
| 56 |
+
save_graph(graph, user_id=user_id, document_id=document_id)
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.warning("Could not build knowledge graph for document %s: %s", document_id, e)
|
| 59 |
+
|
| 60 |
chunk_count = store_chunks(
|
| 61 |
chunks=chunks,
|
| 62 |
document_id=document_id,
|
|
|
|
| 90 |
except Exception as e:
|
| 91 |
logger.error("Ingestion error for %s: %s", document_id, e)
|
| 92 |
try:
|
| 93 |
+
doc = db.query(Document).filter(
|
| 94 |
+
Document.id == document_id,
|
| 95 |
+
Document.is_deleted.is_(False),
|
| 96 |
+
).first()
|
| 97 |
if doc:
|
| 98 |
doc.status = "failed"
|
| 99 |
doc.error_message = str(e)[:500]
|
backend/app/tasks.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Celery tasks for document processing."""
|
| 2 |
+
from app.celery_app import celery_app
|
| 3 |
+
from app.services.document_ingestion import ingest_document
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@celery_app.task(bind=True, name="app.tasks.process_document")
|
| 7 |
+
def process_document(
|
| 8 |
+
self,
|
| 9 |
+
document_id: str,
|
| 10 |
+
filepath: str,
|
| 11 |
+
original_name: str,
|
| 12 |
+
user_id: str,
|
| 13 |
+
) -> dict[str, str]:
|
| 14 |
+
"""Run the RAG ingestion pipeline for a stored document."""
|
| 15 |
+
ingest_document(
|
| 16 |
+
document_id=document_id,
|
| 17 |
+
filepath=filepath,
|
| 18 |
+
original_name=original_name,
|
| 19 |
+
user_id=user_id,
|
| 20 |
+
)
|
| 21 |
+
return {"document_id": document_id, "status": "completed"}
|
| 22 |
+
|
backend/evaluation/ragas_sample_questions.jsonl
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"id":"q001","question":"What is the main purpose of PDF-Assistant-RAG?","reference":"PDF-Assistant-RAG helps users upload documents, retrieve relevant document context, and ask questions answered through a retrieval-augmented generation workflow."}
|
| 2 |
+
{"id":"q002","question":"Which backend framework serves the API?","reference":"The backend API is served by FastAPI."}
|
| 3 |
+
{"id":"q003","question":"Which frontend framework is used for the application interface?","reference":"The frontend is a Next.js application."}
|
| 4 |
+
{"id":"q004","question":"What does the document upload route do before saving permanent state?","reference":"The upload route validates filename, extension, size, MIME type, and parser readability before moving a file into permanent storage."}
|
| 5 |
+
{"id":"q005","question":"Which vector database stores retrieved document chunks?","reference":"ChromaDB stores document chunks for vector retrieval."}
|
| 6 |
+
{"id":"q006","question":"Which embedding model is configured by default?","reference":"The default embedding model is sentence-transformers/all-MiniLM-L6-v2."}
|
| 7 |
+
{"id":"q007","question":"What is the default embedding dimension?","reference":"The default embedding dimension is 384."}
|
| 8 |
+
{"id":"q008","question":"What is the purpose of TOP_K_RETRIEVAL?","reference":"TOP_K_RETRIEVAL controls how many candidate chunks are retrieved before reranking."}
|
| 9 |
+
{"id":"q009","question":"What is the purpose of TOP_K_RERANK?","reference":"TOP_K_RERANK controls how many reranked chunks are finally passed to answer generation."}
|
| 10 |
+
{"id":"q010","question":"Which model family is used for reranking by default?","reference":"The default reranker is a cross-encoder model, cross-encoder/ms-marco-MiniLM-L-6-v2."}
|
| 11 |
+
{"id":"q011","question":"How does the backend identify authenticated users?","reference":"Authenticated routes use JWT identity through the current-user dependency."}
|
| 12 |
+
{"id":"q012","question":"What data must user-facing routes filter by?","reference":"User-facing routes must filter documents, files, vector chunks, and chat data by the authenticated user's id."}
|
| 13 |
+
{"id":"q013","question":"What does the health endpoint check?","reference":"The health endpoint checks service health such as API, SQL database, and Chroma availability."}
|
| 14 |
+
{"id":"q014","question":"What does the chat route provide besides normal JSON answers?","reference":"The chat route supports server-sent events so answers can stream tokens to the frontend."}
|
| 15 |
+
{"id":"q015","question":"What is GraphRAG used for in this project?","reference":"GraphRAG builds and retrieves lightweight entity co-occurrence relationships to add graph context to document answers."}
|
| 16 |
+
{"id":"q016","question":"Where are GraphRAG graph files persisted by default?","reference":"GraphRAG graph files are persisted under the configured GRAPH_PERSIST_DIR, which defaults to ./data/graphs."}
|
| 17 |
+
{"id":"q017","question":"Which graph library is used to store knowledge graph relationships?","reference":"NetworkX is used to build and store knowledge graph relationships."}
|
| 18 |
+
{"id":"q018","question":"What does the graph retriever return for a relevant query?","reference":"The graph retriever returns compact relationship lines connecting matched entities and nearby entities, including page information and relationship strength."}
|
| 19 |
+
{"id":"q019","question":"What happens when GraphRAG finds no matching relationship context?","reference":"When no graph relationships match, the graph retriever returns an empty string."}
|
| 20 |
+
{"id":"q020","question":"Which uploaded file formats are allowed by default?","reference":"The default allowed upload extensions are pdf, docx, txt, and md."}
|
| 21 |
+
{"id":"q021","question":"What is the default upload directory?","reference":"The default upload directory is ./data/uploads."}
|
| 22 |
+
{"id":"q022","question":"Why does the app store original files after upload?","reference":"Original files are stored so the backend can serve files, reprocess them, and extract text for retrieval."}
|
| 23 |
+
{"id":"q023","question":"What is the role of the chunker?","reference":"The chunker extracts document text and splits it into smaller chunks for embedding and retrieval."}
|
| 24 |
+
{"id":"q024","question":"What does the vectorstore service do?","reference":"The vectorstore stores embedded chunks and queries them by user and optional document metadata."}
|
| 25 |
+
{"id":"q025","question":"What does the retriever combine before reranking?","reference":"The retriever combines vector search and BM25 candidates before reranking them."}
|
| 26 |
+
{"id":"q026","question":"Why does the retriever transform queries?","reference":"The retriever rewrites a user question into retrieval-friendly variants to improve search coverage."}
|
| 27 |
+
{"id":"q027","question":"What does the PDF search tool save after retrieving chunks?","reference":"The PDF search tool saves retrieved chunks as last_sources so the agent response can return citations."}
|
| 28 |
+
{"id":"q028","question":"How does the PDF search tool treat document excerpts?","reference":"The PDF search tool labels document excerpts as untrusted evidence and warns the model not to follow instructions inside them."}
|
| 29 |
+
{"id":"q029","question":"What additional context can the PDF search tool append?","reference":"The PDF search tool can append untrusted graph context containing additional relationships from GraphRAG."}
|
| 30 |
+
{"id":"q030","question":"Which optional tool can handle arithmetic questions?","reference":"The calculator tool handles arithmetic expressions safely."}
|
| 31 |
+
{"id":"q031","question":"Which optional tool can handle live information outside uploaded documents?","reference":"The web search tool can look up live web information when document context is insufficient or outdated."}
|
| 32 |
+
{"id":"q032","question":"What does the agent use LangChain tools for?","reference":"The agent uses LangChain tools to route between PDF search, calculator, and web search capabilities."}
|
| 33 |
+
{"id":"q033","question":"What happens when the agent output parser rejects malformed output?","reference":"The app logs the parser rejection and returns a safe malformed-output message."}
|
| 34 |
+
{"id":"q034","question":"What type of API response is used for uploaded document processing status?","reference":"A document status response includes the document id, status, page count, chunk count, and error message."}
|
| 35 |
+
{"id":"q035","question":"How are deleted documents hidden from normal document APIs?","reference":"Documents are soft-deleted with an is_deleted flag and normal APIs filter them out."}
|
| 36 |
+
{"id":"q036","question":"What does deleting a document preserve for future restore flows?","reference":"Soft deletion preserves underlying files, vectors, graphs, and chat history for possible future restore flows."}
|
| 37 |
+
{"id":"q037","question":"What is the purpose of CHUNK_SIZE?","reference":"CHUNK_SIZE controls the number of characters in each document chunk."}
|
| 38 |
+
{"id":"q038","question":"What is the purpose of CHUNK_OVERLAP?","reference":"CHUNK_OVERLAP controls how much text overlaps between adjacent chunks to preserve boundary context."}
|
| 39 |
+
{"id":"q039","question":"Which HuggingFace setting controls answer length?","reference":"LLM_MAX_NEW_TOKENS controls the maximum number of generated tokens for answers."}
|
| 40 |
+
{"id":"q040","question":"Which HuggingFace setting controls answer randomness?","reference":"LLM_TEMPERATURE controls sampling randomness during answer generation."}
|
| 41 |
+
{"id":"q041","question":"What environment variable stores the HuggingFace token?","reference":"HF_TOKEN stores the HuggingFace API token used for inference."}
|
| 42 |
+
{"id":"q042","question":"Why should DEBUG not be enabled in production?","reference":"DEBUG enables detailed behavior intended for development and should not be enabled in production."}
|
| 43 |
+
{"id":"q043","question":"How are production CORS origins configured?","reference":"Production CORS origins are configured through ALLOWED_ORIGINS."}
|
| 44 |
+
{"id":"q044","question":"What database is used by default for local development?","reference":"The default database URL points to a local SQLite database at ./data/app.db."}
|
| 45 |
+
{"id":"q045","question":"What database does Docker Compose provide for the stack?","reference":"Docker Compose provides a PostgreSQL database service for the stack."}
|
| 46 |
+
{"id":"q046","question":"What is the contributor target branch for pull requests?","reference":"Contributor pull requests should target the dev branch."}
|
| 47 |
+
{"id":"q047","question":"Which branch is production protected for deployment?","reference":"The main branch is treated as the production branch for deployment."}
|
| 48 |
+
{"id":"q048","question":"Where can developers view Swagger locally?","reference":"Developers can view Swagger at /docs when the backend is running locally."}
|
| 49 |
+
{"id":"q049","question":"What does the architecture document focus on?","reference":"The architecture document focuses on how requests move through the system and how major runtime components interact."}
|
| 50 |
+
{"id":"q050","question":"Why is a RAGAS evaluation pipeline useful for this project?","reference":"A RAGAS evaluation pipeline provides quantitative scores to compare standard vector search with GraphRAG and track retrieval and answer quality over time."}
|
backend/requirements.txt
CHANGED
|
@@ -38,6 +38,7 @@ langchain-huggingface
|
|
| 38 |
langchain-text-splitters
|
| 39 |
langsmith
|
| 40 |
rank-bm25
|
|
|
|
| 41 |
|
| 42 |
# Embeddings & ML
|
| 43 |
sentence-transformers
|
|
@@ -56,6 +57,7 @@ huggingface-hub
|
|
| 56 |
gunicorn
|
| 57 |
slowapi
|
| 58 |
prometheus-fastapi-instrumentator
|
|
|
|
| 59 |
|
| 60 |
# File Validation
|
| 61 |
#sudo apt-get install libmagic1 // for Debian/Ubuntu
|
|
|
|
| 38 |
langchain-text-splitters
|
| 39 |
langsmith
|
| 40 |
rank-bm25
|
| 41 |
+
ragas>=0.3.0
|
| 42 |
|
| 43 |
# Embeddings & ML
|
| 44 |
sentence-transformers
|
|
|
|
| 57 |
gunicorn
|
| 58 |
slowapi
|
| 59 |
prometheus-fastapi-instrumentator
|
| 60 |
+
celery[redis]
|
| 61 |
|
| 62 |
# File Validation
|
| 63 |
#sudo apt-get install libmagic1 // for Debian/Ubuntu
|
backend/scripts/run_ragas_eval.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run a 50-question RAGAS comparison for vector search and GraphRAG."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import argparse
|
| 5 |
+
import json
|
| 6 |
+
import sys
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 11 |
+
BACKEND_DIR = ROOT / "backend"
|
| 12 |
+
if str(BACKEND_DIR) not in sys.path:
|
| 13 |
+
sys.path.insert(0, str(BACKEND_DIR))
|
| 14 |
+
|
| 15 |
+
DEFAULT_DATASET = BACKEND_DIR / "evaluation" / "ragas_sample_questions.jsonl"
|
| 16 |
+
DEFAULT_OUTPUT = BACKEND_DIR / "evaluation" / "ragas_results.json"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def parse_args() -> argparse.Namespace:
|
| 20 |
+
parser = argparse.ArgumentParser(
|
| 21 |
+
description="Evaluate vector search versus GraphRAG with RAGAS.",
|
| 22 |
+
)
|
| 23 |
+
parser.add_argument("--user-id", required=True, help="Owner user id for indexed documents.")
|
| 24 |
+
parser.add_argument("--document-id", help="Optional single document id to evaluate.")
|
| 25 |
+
parser.add_argument("--dataset", type=Path, default=DEFAULT_DATASET)
|
| 26 |
+
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
| 27 |
+
parser.add_argument("--limit", type=int, default=50)
|
| 28 |
+
return parser.parse_args()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def main() -> None:
|
| 32 |
+
args = parse_args()
|
| 33 |
+
|
| 34 |
+
from app.evaluation.ragas_pipeline import collect_records, compare_pipelines, load_questions
|
| 35 |
+
|
| 36 |
+
questions = load_questions(args.dataset, limit=args.limit)
|
| 37 |
+
grouped_records = collect_records(
|
| 38 |
+
questions=questions,
|
| 39 |
+
user_id=args.user_id,
|
| 40 |
+
document_id=args.document_id,
|
| 41 |
+
)
|
| 42 |
+
scores = compare_pipelines(grouped_records)
|
| 43 |
+
payload = {
|
| 44 |
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
| 45 |
+
"dataset": str(args.dataset),
|
| 46 |
+
"question_count": len(questions),
|
| 47 |
+
"user_id": args.user_id,
|
| 48 |
+
"document_id": args.document_id,
|
| 49 |
+
"scores": scores,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 53 |
+
args.output.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
| 54 |
+
print(json.dumps(payload["scores"], indent=2))
|
| 55 |
+
print(f"Wrote RAGAS evaluation results to {args.output}")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
if __name__ == "__main__":
|
| 59 |
+
main()
|
backend/tests/test_auth.py
CHANGED
|
@@ -122,3 +122,80 @@ def test_hf_token_appears_in_user_response(client, auth_headers, user, db_sessio
|
|
| 122 |
stored_token = row[0]
|
| 123 |
assert stored_token is not None
|
| 124 |
assert stored_token != "hf_persist_token"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
stored_token = row[0]
|
| 123 |
assert stored_token is not None
|
| 124 |
assert stored_token != "hf_persist_token"
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
from unittest.mock import patch, AsyncMock, MagicMock
|
| 128 |
+
import urllib.parse
|
| 129 |
+
|
| 130 |
+
def test_huggingface_login(client):
|
| 131 |
+
from app.config import get_settings
|
| 132 |
+
settings = get_settings()
|
| 133 |
+
settings.HF_CLIENT_ID = "test-client-id"
|
| 134 |
+
settings.HF_REDIRECT_URI = "http://localhost:8000/api/v1/auth/callback/huggingface"
|
| 135 |
+
|
| 136 |
+
response = client.get("/api/v1/auth/login/huggingface")
|
| 137 |
+
assert response.status_code == 200
|
| 138 |
+
data = response.json()
|
| 139 |
+
assert "url" in data
|
| 140 |
+
assert "test-client-id" in data["url"]
|
| 141 |
+
assert "oauth_state" in response.cookies
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@patch("httpx.AsyncClient.post")
|
| 145 |
+
@patch("httpx.AsyncClient.get")
|
| 146 |
+
def test_huggingface_callback_success(mock_get, mock_post, client):
|
| 147 |
+
from app.config import get_settings
|
| 148 |
+
settings = get_settings()
|
| 149 |
+
settings.HF_CLIENT_ID = "test-client-id"
|
| 150 |
+
settings.HF_CLIENT_SECRET = "test-client-secret"
|
| 151 |
+
settings.HF_REDIRECT_URI = "http://localhost:8000/api/v1/auth/callback/huggingface"
|
| 152 |
+
|
| 153 |
+
mock_post_resp = MagicMock()
|
| 154 |
+
mock_post_resp.status_code = 200
|
| 155 |
+
mock_post_resp.json.return_value = {"access_token": "hf-access-token"}
|
| 156 |
+
mock_post.return_value = mock_post_resp
|
| 157 |
+
|
| 158 |
+
mock_get_resp = MagicMock()
|
| 159 |
+
mock_get_resp.status_code = 200
|
| 160 |
+
mock_get_resp.json.return_value = {
|
| 161 |
+
"email": "hfuser@example.com",
|
| 162 |
+
"preferred_username": "hfuser"
|
| 163 |
+
}
|
| 164 |
+
mock_get.return_value = mock_get_resp
|
| 165 |
+
|
| 166 |
+
login_response = client.get("/api/v1/auth/login/huggingface")
|
| 167 |
+
state_cookie = login_response.cookies["oauth_state"]
|
| 168 |
+
url = login_response.json()["url"]
|
| 169 |
+
parsed = urllib.parse.urlparse(url)
|
| 170 |
+
queries = urllib.parse.parse_qs(parsed.query)
|
| 171 |
+
state_param = queries["state"][0]
|
| 172 |
+
|
| 173 |
+
client.cookies.set("oauth_state", state_cookie)
|
| 174 |
+
callback_response = client.get(
|
| 175 |
+
f"/api/v1/auth/callback/huggingface?code=hf-code&state={state_param}",
|
| 176 |
+
follow_redirects=False
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
assert callback_response.status_code == 307
|
| 180 |
+
assert "/dashboard" in callback_response.headers["location"]
|
| 181 |
+
assert "access_token" in callback_response.cookies
|
| 182 |
+
assert "refresh_token" in callback_response.cookies
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_huggingface_callback_invalid_state(client):
|
| 186 |
+
response = client.get(
|
| 187 |
+
"/api/v1/auth/callback/huggingface?code=hf-code&state=invalid-state",
|
| 188 |
+
cookies={"oauth_state": "actual-state"}
|
| 189 |
+
)
|
| 190 |
+
assert response.status_code == 400
|
| 191 |
+
assert "State verification failed" in response.json()["detail"]
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_huggingface_logout(client):
|
| 195 |
+
response = client.post(
|
| 196 |
+
"/api/v1/auth/logout",
|
| 197 |
+
cookies={"access_token": "token-value", "refresh_token": "refresh-value"}
|
| 198 |
+
)
|
| 199 |
+
assert response.status_code == 200
|
| 200 |
+
assert response.cookies.get("access_token") in (None, "")
|
| 201 |
+
assert response.cookies.get("refresh_token") in (None, "")
|
backend/tests/test_document_upload_validation.py
CHANGED
|
@@ -6,7 +6,7 @@ import uuid
|
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
import pytest
|
| 9 |
-
from fastapi import
|
| 10 |
from pypdf import PdfWriter
|
| 11 |
from sqlalchemy import create_engine
|
| 12 |
from sqlalchemy.orm import sessionmaker
|
|
@@ -141,10 +141,14 @@ def test_upload_document_handles_duplicate_original_names(
|
|
| 141 |
monkeypatch.setattr(documents, "validate_upload", fake_validate_upload)
|
| 142 |
monkeypatch.setattr(documents.settings, "UPLOAD_DIR", str(tmp_path / "uploads"))
|
| 143 |
monkeypatch.setattr(documents.uuid, "uuid4", lambda: next(uuid_values))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
first = _run(
|
| 146 |
documents.upload_document(
|
| 147 |
-
BackgroundTasks(),
|
| 148 |
file=_upload_file("same-name.pdf", b"first"),
|
| 149 |
user=user,
|
| 150 |
db=session,
|
|
@@ -152,7 +156,6 @@ def test_upload_document_handles_duplicate_original_names(
|
|
| 152 |
)
|
| 153 |
second = _run(
|
| 154 |
documents.upload_document(
|
| 155 |
-
BackgroundTasks(),
|
| 156 |
file=_upload_file("same-name.pdf", b"second"),
|
| 157 |
user=user,
|
| 158 |
db=session,
|
|
@@ -164,6 +167,7 @@ def test_upload_document_handles_duplicate_original_names(
|
|
| 164 |
assert [doc.original_name for doc in stored_docs] == ["same-name.pdf", "same-name.pdf"]
|
| 165 |
assert len({doc.filename for doc in stored_docs}) == 2
|
| 166 |
assert first.original_name == second.original_name == "same-name.pdf"
|
|
|
|
| 167 |
assert (tmp_path / "uploads" / user.id / f"{first_hex}.pdf").exists()
|
| 168 |
assert (tmp_path / "uploads" / user.id / f"{second_hex}.pdf").exists()
|
| 169 |
assert all(not path.exists() for path in temp_files)
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
import pytest
|
| 9 |
+
from fastapi import HTTPException, UploadFile
|
| 10 |
from pypdf import PdfWriter
|
| 11 |
from sqlalchemy import create_engine
|
| 12 |
from sqlalchemy.orm import sessionmaker
|
|
|
|
| 141 |
monkeypatch.setattr(documents, "validate_upload", fake_validate_upload)
|
| 142 |
monkeypatch.setattr(documents.settings, "UPLOAD_DIR", str(tmp_path / "uploads"))
|
| 143 |
monkeypatch.setattr(documents.uuid, "uuid4", lambda: next(uuid_values))
|
| 144 |
+
monkeypatch.setattr(
|
| 145 |
+
documents.process_document,
|
| 146 |
+
"delay",
|
| 147 |
+
lambda **_kwargs: types.SimpleNamespace(id="queued-task"),
|
| 148 |
+
)
|
| 149 |
|
| 150 |
first = _run(
|
| 151 |
documents.upload_document(
|
|
|
|
| 152 |
file=_upload_file("same-name.pdf", b"first"),
|
| 153 |
user=user,
|
| 154 |
db=session,
|
|
|
|
| 156 |
)
|
| 157 |
second = _run(
|
| 158 |
documents.upload_document(
|
|
|
|
| 159 |
file=_upload_file("same-name.pdf", b"second"),
|
| 160 |
user=user,
|
| 161 |
db=session,
|
|
|
|
| 167 |
assert [doc.original_name for doc in stored_docs] == ["same-name.pdf", "same-name.pdf"]
|
| 168 |
assert len({doc.filename for doc in stored_docs}) == 2
|
| 169 |
assert first.original_name == second.original_name == "same-name.pdf"
|
| 170 |
+
assert first.task_id == second.task_id == "queued-task"
|
| 171 |
assert (tmp_path / "uploads" / user.id / f"{first_hex}.pdf").exists()
|
| 172 |
assert (tmp_path / "uploads" / user.id / f"{second_hex}.pdf").exists()
|
| 173 |
assert all(not path.exists() for path in temp_files)
|
backend/tests/test_documents.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import types
|
| 2 |
|
| 3 |
from app.models import Document
|
| 4 |
-
from app.
|
| 5 |
|
| 6 |
|
| 7 |
def test_api_health(client):
|
|
@@ -116,9 +116,9 @@ def test_ingest_document_builds_and_saves_graph(db_session, monkeypatch, tmp_pat
|
|
| 116 |
chunks = [{"text": "OpenAI works with Microsoft.", "page": 1, "chunk_index": 0}]
|
| 117 |
saved = {}
|
| 118 |
|
| 119 |
-
monkeypatch.setattr("app.
|
| 120 |
-
monkeypatch.setattr("app.
|
| 121 |
-
monkeypatch.setattr("app.
|
| 122 |
monkeypatch.setattr("app.database.SessionLocal", lambda: db_session)
|
| 123 |
|
| 124 |
fake_summary = types.ModuleType("app.rag.summarizer")
|
|
@@ -136,7 +136,7 @@ def test_ingest_document_builds_and_saves_graph(db_session, monkeypatch, tmp_pat
|
|
| 136 |
),
|
| 137 |
)
|
| 138 |
|
| 139 |
-
|
| 140 |
document_id=document_id,
|
| 141 |
filepath=str(tmp_path / "graph.txt"),
|
| 142 |
original_name=document.original_name,
|
|
|
|
| 1 |
import types
|
| 2 |
|
| 3 |
from app.models import Document
|
| 4 |
+
from app.services.document_ingestion import ingest_document
|
| 5 |
|
| 6 |
|
| 7 |
def test_api_health(client):
|
|
|
|
| 116 |
chunks = [{"text": "OpenAI works with Microsoft.", "page": 1, "chunk_index": 0}]
|
| 117 |
saved = {}
|
| 118 |
|
| 119 |
+
monkeypatch.setattr("app.services.document_ingestion.get_page_count", lambda filepath: 1)
|
| 120 |
+
monkeypatch.setattr("app.services.document_ingestion.chunk_document", lambda filepath: chunks)
|
| 121 |
+
monkeypatch.setattr("app.services.document_ingestion.store_chunks", lambda **kwargs: len(chunks))
|
| 122 |
monkeypatch.setattr("app.database.SessionLocal", lambda: db_session)
|
| 123 |
|
| 124 |
fake_summary = types.ModuleType("app.rag.summarizer")
|
|
|
|
| 136 |
),
|
| 137 |
)
|
| 138 |
|
| 139 |
+
ingest_document(
|
| 140 |
document_id=document_id,
|
| 141 |
filepath=str(tmp_path / "graph.txt"),
|
| 142 |
original_name=document.original_name,
|
backend/tests/test_ragas_pipeline.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from types import SimpleNamespace
|
| 3 |
+
|
| 4 |
+
from app.evaluation import ragas_pipeline
|
| 5 |
+
from app.evaluation.ragas_pipeline import (
|
| 6 |
+
EvaluationQuestion,
|
| 7 |
+
append_graph_context,
|
| 8 |
+
collect_records,
|
| 9 |
+
load_questions,
|
| 10 |
+
summarize_ragas_result,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_load_questions_requires_exact_limit(tmp_path):
|
| 15 |
+
dataset = tmp_path / "questions.jsonl"
|
| 16 |
+
rows = [
|
| 17 |
+
{"id": "q1", "question": "Question 1?", "reference": "Reference 1."},
|
| 18 |
+
{"id": "q2", "question": "Question 2?", "reference": "Reference 2."},
|
| 19 |
+
]
|
| 20 |
+
dataset.write_text("\n".join(json.dumps(row) for row in rows), encoding="utf-8")
|
| 21 |
+
|
| 22 |
+
questions = load_questions(dataset, limit=2)
|
| 23 |
+
|
| 24 |
+
assert [question.id for question in questions] == ["q1", "q2"]
|
| 25 |
+
assert questions[0].question == "Question 1?"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_append_graph_context_skips_empty_context():
|
| 29 |
+
assert append_graph_context(["vector context"], " ") == ["vector context"]
|
| 30 |
+
assert append_graph_context(["vector context"], "graph context") == [
|
| 31 |
+
"vector context",
|
| 32 |
+
"graph context",
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_collect_records_builds_vector_and_graphrag_samples(monkeypatch):
|
| 37 |
+
questions = [
|
| 38 |
+
EvaluationQuestion(id="q1", question="What is Alpha?", reference="Alpha is a product."),
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
monkeypatch.setattr(
|
| 42 |
+
ragas_pipeline,
|
| 43 |
+
"retrieve_vector_contexts",
|
| 44 |
+
lambda **_kwargs: ["Alpha vector context."],
|
| 45 |
+
)
|
| 46 |
+
monkeypatch.setattr(
|
| 47 |
+
ragas_pipeline,
|
| 48 |
+
"retrieve_graphrag_contexts",
|
| 49 |
+
lambda **_kwargs: ["Alpha vector context.", "Alpha is related to Beta."],
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
records = collect_records(
|
| 53 |
+
questions=questions,
|
| 54 |
+
user_id="user-1",
|
| 55 |
+
answer_generator=lambda question, contexts: f"{question} -> {len(contexts)} contexts",
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
assert records["vector"][0].mode == "vector"
|
| 59 |
+
assert records["vector"][0].response.endswith("1 contexts")
|
| 60 |
+
assert records["graphrag"][0].mode == "graphrag"
|
| 61 |
+
assert records["graphrag"][0].response.endswith("2 contexts")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_summarize_ragas_result_averages_score_rows():
|
| 65 |
+
result = SimpleNamespace(
|
| 66 |
+
scores=[
|
| 67 |
+
{"faithfulness": 1.0, "context_recall": 0.5},
|
| 68 |
+
{"faithfulness": 0.5, "context_recall": 1.0},
|
| 69 |
+
]
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
assert summarize_ragas_result(result) == {
|
| 73 |
+
"faithfulness": 0.75,
|
| 74 |
+
"context_recall": 0.75,
|
| 75 |
+
}
|
| 76 |
+
|
docker-compose.yml
CHANGED
|
@@ -1,6 +1,20 @@
|
|
| 1 |
version: '3.8'
|
| 2 |
|
| 3 |
services:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
# ── PostgreSQL Database ──────────────────────────────────
|
| 5 |
postgres:
|
| 6 |
image: postgres:16-alpine
|
|
@@ -34,11 +48,16 @@ services:
|
|
| 34 |
- SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-me}
|
| 35 |
- HF_TOKEN=${HF_TOKEN}
|
| 36 |
- DATABASE_URL=postgresql://${POSTGRES_USER:-pdf_rag_user}:${POSTGRES_PASSWORD:-pdf_rag_pass}@postgres:5432/${POSTGRES_DB:-pdf_rag}
|
| 37 |
-
- UPLOAD_DIR=
|
| 38 |
-
- CHROMA_PERSIST_DIR=
|
|
|
|
|
|
|
|
|
|
| 39 |
depends_on:
|
| 40 |
postgres:
|
| 41 |
condition: service_healthy
|
|
|
|
|
|
|
| 42 |
restart: unless-stopped
|
| 43 |
healthcheck:
|
| 44 |
test: ["CMD", "curl", "-f", "http://localhost:7860/api/health"]
|
|
@@ -47,6 +66,31 @@ services:
|
|
| 47 |
retries: 3
|
| 48 |
start_period: 60s
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
# ── pgAdmin (optional — for local DB inspection) ─────────
|
| 51 |
pgadmin:
|
| 52 |
image: dpage/pgadmin4:latest
|
|
|
|
| 1 |
version: '3.8'
|
| 2 |
|
| 3 |
services:
|
| 4 |
+
# Redis broker/result backend for Celery document processing
|
| 5 |
+
redis:
|
| 6 |
+
image: redis:7-alpine
|
| 7 |
+
container_name: pdf_rag_redis
|
| 8 |
+
restart: unless-stopped
|
| 9 |
+
ports:
|
| 10 |
+
- "6379:6379"
|
| 11 |
+
healthcheck:
|
| 12 |
+
test: ["CMD", "redis-cli", "ping"]
|
| 13 |
+
interval: 10s
|
| 14 |
+
timeout: 5s
|
| 15 |
+
retries: 5
|
| 16 |
+
start_period: 5s
|
| 17 |
+
|
| 18 |
# ── PostgreSQL Database ──────────────────────────────────
|
| 19 |
postgres:
|
| 20 |
image: postgres:16-alpine
|
|
|
|
| 48 |
- SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-me}
|
| 49 |
- HF_TOKEN=${HF_TOKEN}
|
| 50 |
- DATABASE_URL=postgresql://${POSTGRES_USER:-pdf_rag_user}:${POSTGRES_PASSWORD:-pdf_rag_pass}@postgres:5432/${POSTGRES_DB:-pdf_rag}
|
| 51 |
+
- UPLOAD_DIR=/app/data/uploads
|
| 52 |
+
- CHROMA_PERSIST_DIR=/app/data/chroma_db
|
| 53 |
+
- GRAPH_PERSIST_DIR=/app/data/graphs
|
| 54 |
+
- CELERY_BROKER_URL=redis://redis:6379/0
|
| 55 |
+
- CELERY_RESULT_BACKEND=redis://redis:6379/1
|
| 56 |
depends_on:
|
| 57 |
postgres:
|
| 58 |
condition: service_healthy
|
| 59 |
+
redis:
|
| 60 |
+
condition: service_healthy
|
| 61 |
restart: unless-stopped
|
| 62 |
healthcheck:
|
| 63 |
test: ["CMD", "curl", "-f", "http://localhost:7860/api/health"]
|
|
|
|
| 66 |
retries: 3
|
| 67 |
start_period: 60s
|
| 68 |
|
| 69 |
+
# Celery worker for document extraction, chunking, embeddings, and vector storage
|
| 70 |
+
worker:
|
| 71 |
+
build: .
|
| 72 |
+
container_name: pdf_rag_worker
|
| 73 |
+
command: >
|
| 74 |
+
sh -c "cd /app/backend &&
|
| 75 |
+
celery -A app.celery_app.celery_app worker --loglevel=info"
|
| 76 |
+
volumes:
|
| 77 |
+
- app_data:/app/data
|
| 78 |
+
environment:
|
| 79 |
+
- SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-me}
|
| 80 |
+
- HF_TOKEN=${HF_TOKEN}
|
| 81 |
+
- DATABASE_URL=postgresql://${POSTGRES_USER:-pdf_rag_user}:${POSTGRES_PASSWORD:-pdf_rag_pass}@postgres:5432/${POSTGRES_DB:-pdf_rag}
|
| 82 |
+
- UPLOAD_DIR=/app/data/uploads
|
| 83 |
+
- CHROMA_PERSIST_DIR=/app/data/chroma_db
|
| 84 |
+
- GRAPH_PERSIST_DIR=/app/data/graphs
|
| 85 |
+
- CELERY_BROKER_URL=redis://redis:6379/0
|
| 86 |
+
- CELERY_RESULT_BACKEND=redis://redis:6379/1
|
| 87 |
+
depends_on:
|
| 88 |
+
postgres:
|
| 89 |
+
condition: service_healthy
|
| 90 |
+
redis:
|
| 91 |
+
condition: service_healthy
|
| 92 |
+
restart: unless-stopped
|
| 93 |
+
|
| 94 |
# ── pgAdmin (optional — for local DB inspection) ─────────
|
| 95 |
pgadmin:
|
| 96 |
image: dpage/pgadmin4:latest
|
docs/ARCHITECTURE.md
CHANGED
|
@@ -52,7 +52,8 @@ sequenceDiagram
|
|
| 52 |
participant UI as Frontend
|
| 53 |
participant API as FastAPI documents route
|
| 54 |
participant DB as SQL metadata
|
| 55 |
-
participant
|
|
|
|
| 56 |
participant Files as Upload storage
|
| 57 |
participant Vector as ChromaDB
|
| 58 |
|
|
@@ -60,8 +61,9 @@ sequenceDiagram
|
|
| 60 |
API->>API: Validate filename, extension, size, MIME, and parser readability
|
| 61 |
API->>Files: Persist original file under the user's upload directory
|
| 62 |
API->>DB: Create document row with processing status
|
| 63 |
-
API-
|
| 64 |
-
API->>
|
|
|
|
| 65 |
Worker->>Files: Read saved document
|
| 66 |
Worker->>Worker: Extract pages, chunk text, build graph summary data
|
| 67 |
Worker->>Vector: Store chunks with document and user metadata
|
|
@@ -70,9 +72,9 @@ sequenceDiagram
|
|
| 70 |
|
| 71 |
The upload route is intentionally strict before it writes long-lived state:
|
| 72 |
extension checks, size checks, MIME checks, and parser checks happen before the
|
| 73 |
-
file is moved into permanent storage.
|
| 74 |
-
such as text extraction, chunking,
|
| 75 |
-
generation.
|
| 76 |
|
| 77 |
## Chat And Retrieval Flow
|
| 78 |
|
|
|
|
| 52 |
participant UI as Frontend
|
| 53 |
participant API as FastAPI documents route
|
| 54 |
participant DB as SQL metadata
|
| 55 |
+
participant Redis as Redis broker
|
| 56 |
+
participant Worker as Celery worker
|
| 57 |
participant Files as Upload storage
|
| 58 |
participant Vector as ChromaDB
|
| 59 |
|
|
|
|
| 61 |
API->>API: Validate filename, extension, size, MIME, and parser readability
|
| 62 |
API->>Files: Persist original file under the user's upload directory
|
| 63 |
API->>DB: Create document row with processing status
|
| 64 |
+
API->>Redis: Queue Celery ingestion task
|
| 65 |
+
API-->>UI: 202 Accepted with document metadata and task_id
|
| 66 |
+
Redis->>Worker: Deliver ingestion task
|
| 67 |
Worker->>Files: Read saved document
|
| 68 |
Worker->>Worker: Extract pages, chunk text, build graph summary data
|
| 69 |
Worker->>Vector: Store chunks with document and user metadata
|
|
|
|
| 72 |
|
| 73 |
The upload route is intentionally strict before it writes long-lived state:
|
| 74 |
extension checks, size checks, MIME checks, and parser checks happen before the
|
| 75 |
+
file is moved into permanent storage. Celery uses Redis as the broker/result
|
| 76 |
+
backend, and the worker owns expensive work such as text extraction, chunking,
|
| 77 |
+
embedding, graph building, and summary generation.
|
| 78 |
|
| 79 |
## Chat And Retrieval Flow
|
| 80 |
|
frontend/e2e/auth-and-chat.spec.ts
CHANGED
|
@@ -28,7 +28,13 @@ const uploadedDocument = {
|
|
| 28 |
|
| 29 |
async function mockDashboardApis(page: Page, documents: typeof uploadedDocument[] = []) {
|
| 30 |
await page.route("**/api/v1/auth/me", async (route) => {
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
});
|
| 33 |
|
| 34 |
await page.route("**/api/v1/documents/", async (route) => {
|
|
@@ -54,7 +60,7 @@ test("logs in with email and password", async ({ page }) => {
|
|
| 54 |
await page.goto("/login");
|
| 55 |
await page.locator("#login-email").fill(user.email);
|
| 56 |
await page.locator("#login-password").fill("password123");
|
| 57 |
-
await page.
|
| 58 |
|
| 59 |
await expect(page).toHaveURL(/\/dashboard$/);
|
| 60 |
await expect(page.getByText("No documents yet")).toBeVisible();
|
|
|
|
| 28 |
|
| 29 |
async function mockDashboardApis(page: Page, documents: typeof uploadedDocument[] = []) {
|
| 30 |
await page.route("**/api/v1/auth/me", async (route) => {
|
| 31 |
+
const headers = route.request().headers();
|
| 32 |
+
const hasAuth = headers["authorization"] || headers["cookie"];
|
| 33 |
+
if (hasAuth) {
|
| 34 |
+
await route.fulfill({ json: user });
|
| 35 |
+
} else {
|
| 36 |
+
await route.fulfill({ status: 401, json: { detail: "Not authenticated" } });
|
| 37 |
+
}
|
| 38 |
});
|
| 39 |
|
| 40 |
await page.route("**/api/v1/documents/", async (route) => {
|
|
|
|
| 60 |
await page.goto("/login");
|
| 61 |
await page.locator("#login-email").fill(user.email);
|
| 62 |
await page.locator("#login-password").fill("password123");
|
| 63 |
+
await page.locator("#sign-in-btn").click();
|
| 64 |
|
| 65 |
await expect(page).toHaveURL(/\/dashboard$/);
|
| 66 |
await expect(page.getByText("No documents yet")).toBeVisible();
|
frontend/package-lock.json
CHANGED
|
@@ -5699,6 +5699,7 @@
|
|
| 5699 |
"version": "2.3.2",
|
| 5700 |
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
| 5701 |
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
|
|
| 5702 |
"hasInstallScript": true,
|
| 5703 |
"license": "MIT",
|
| 5704 |
"optional": true,
|
|
|
|
| 5699 |
"version": "2.3.2",
|
| 5700 |
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
| 5701 |
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
| 5702 |
+
"dev": true,
|
| 5703 |
"hasInstallScript": true,
|
| 5704 |
"license": "MIT",
|
| 5705 |
"optional": true,
|
frontend/src/app/dashboard/page.tsx
CHANGED
|
@@ -56,7 +56,7 @@ export interface DocInfo {
|
|
| 56 |
}
|
| 57 |
|
| 58 |
export default function DashboardPage() {
|
| 59 |
-
const { user, loading } = useAuth();
|
| 60 |
const router = useRouter();
|
| 61 |
|
| 62 |
const [documents, setDocuments] = useState<DocInfo[]>([]);
|
|
@@ -85,18 +85,21 @@ export default function DashboardPage() {
|
|
| 85 |
setActiveDoc((current) => (current?.id === renamedDocument.id ? renamedDocument : current));
|
| 86 |
}, []);
|
| 87 |
|
| 88 |
-
|
|
|
|
| 89 |
useEffect(() => {
|
| 90 |
-
if (
|
| 91 |
-
}, [user,
|
| 92 |
|
| 93 |
-
//
|
| 94 |
useEffect(() => {
|
| 95 |
if (user) {
|
| 96 |
-
const
|
| 97 |
|
| 98 |
-
if (!
|
| 99 |
-
console.
|
|
|
|
|
|
|
| 100 |
}
|
| 101 |
}
|
| 102 |
}, [user]);
|
|
@@ -160,7 +163,7 @@ export default function DashboardPage() {
|
|
| 160 |
return () => clearInterval(interval);
|
| 161 |
}, [documents, loadDocuments]);
|
| 162 |
|
| 163 |
-
if (
|
| 164 |
return (
|
| 165 |
<div className="min-h-screen flex items-center justify-center">
|
| 166 |
<div className="animate-pulse-glow w-12 h-12 rounded-full bg-primary/20" />
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
export default function DashboardPage() {
|
| 59 |
+
const { user, loading, initialized } = useAuth();
|
| 60 |
const router = useRouter();
|
| 61 |
|
| 62 |
const [documents, setDocuments] = useState<DocInfo[]>([]);
|
|
|
|
| 85 |
setActiveDoc((current) => (current?.id === renamedDocument.id ? renamedDocument : current));
|
| 86 |
}, []);
|
| 87 |
|
| 88 |
+
// Auth guard
|
| 89 |
+
|
| 90 |
useEffect(() => {
|
| 91 |
+
if (initialized && !user) router.replace("/login");
|
| 92 |
+
}, [user, initialized, router]);
|
| 93 |
|
| 94 |
+
// Check if Hugging Face token configuration is present
|
| 95 |
useEffect(() => {
|
| 96 |
if (user) {
|
| 97 |
+
const hasHfToken = !!(user.hf_token || localStorage.getItem("hf_token"));
|
| 98 |
|
| 99 |
+
if (!hasHfToken) {
|
| 100 |
+
console.info(
|
| 101 |
+
"Hugging Face API token is not configured. Personal model access will fall back to the system default unless set in the user profile menu."
|
| 102 |
+
);
|
| 103 |
}
|
| 104 |
}
|
| 105 |
}, [user]);
|
|
|
|
| 163 |
return () => clearInterval(interval);
|
| 164 |
}, [documents, loadDocuments]);
|
| 165 |
|
| 166 |
+
if (!initialized || !user) {
|
| 167 |
return (
|
| 168 |
<div className="min-h-screen flex items-center justify-center">
|
| 169 |
<div className="animate-pulse-glow w-12 h-12 rounded-full bg-primary/20" />
|
frontend/src/app/login/page.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useCallback, useState } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
| 6 |
import { useTranslation } from "react-i18next";
|
|
@@ -10,9 +10,10 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
|
| 10 |
import { Brain, Eye, EyeOff } from "lucide-react";
|
| 11 |
import Link from "next/link";
|
| 12 |
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
|
|
|
|
| 13 |
|
| 14 |
export default function LoginPage() {
|
| 15 |
-
const { login } = useAuth();
|
| 16 |
const { t } = useTranslation();
|
| 17 |
const router = useRouter();
|
| 18 |
const [email, setEmail] = useState("");
|
|
@@ -21,6 +22,13 @@ export default function LoginPage() {
|
|
| 21 |
const [error, setError] = useState("");
|
| 22 |
const [loading, setLoading] = useState(false);
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
const handleGoogleSuccess = useCallback(() => {
|
| 25 |
router.replace("/dashboard");
|
| 26 |
}, [router]);
|
|
@@ -58,13 +66,25 @@ export default function LoginPage() {
|
|
| 58 |
</CardHeader>
|
| 59 |
|
| 60 |
<CardContent>
|
| 61 |
-
<div className="mb-4">
|
|
|
|
| 62 |
<GoogleSignInButton
|
| 63 |
onError={setError}
|
| 64 |
onSuccess={handleGoogleSuccess}
|
| 65 |
/>
|
| 66 |
</div>
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 69 |
{error && (
|
| 70 |
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
|
|
@@ -107,7 +127,7 @@ export default function LoginPage() {
|
|
| 107 |
</div>
|
| 108 |
</div>
|
| 109 |
|
| 110 |
-
<Button type="submit" className="w-full h-11 text-base" disabled={loading}>
|
| 111 |
{loading ? (
|
| 112 |
<span className="flex items-center gap-2">
|
| 113 |
<span className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useCallback, useState, useEffect } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
| 6 |
import { useTranslation } from "react-i18next";
|
|
|
|
| 10 |
import { Brain, Eye, EyeOff } from "lucide-react";
|
| 11 |
import Link from "next/link";
|
| 12 |
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
|
| 13 |
+
import HuggingFaceSignInButton from "@/components/auth/HuggingFaceSignInButton";
|
| 14 |
|
| 15 |
export default function LoginPage() {
|
| 16 |
+
const { login, user, initialized } = useAuth();
|
| 17 |
const { t } = useTranslation();
|
| 18 |
const router = useRouter();
|
| 19 |
const [email, setEmail] = useState("");
|
|
|
|
| 22 |
const [error, setError] = useState("");
|
| 23 |
const [loading, setLoading] = useState(false);
|
| 24 |
|
| 25 |
+
// Redirect if already logged in
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
if (initialized && user) {
|
| 28 |
+
router.replace("/dashboard");
|
| 29 |
+
}
|
| 30 |
+
}, [user, initialized, router]);
|
| 31 |
+
|
| 32 |
const handleGoogleSuccess = useCallback(() => {
|
| 33 |
router.replace("/dashboard");
|
| 34 |
}, [router]);
|
|
|
|
| 66 |
</CardHeader>
|
| 67 |
|
| 68 |
<CardContent>
|
| 69 |
+
<div className="flex flex-col gap-2.5 mb-4">
|
| 70 |
+
<HuggingFaceSignInButton onError={setError} />
|
| 71 |
<GoogleSignInButton
|
| 72 |
onError={setError}
|
| 73 |
onSuccess={handleGoogleSuccess}
|
| 74 |
/>
|
| 75 |
</div>
|
| 76 |
|
| 77 |
+
<div className="relative my-5">
|
| 78 |
+
<div className="absolute inset-0 flex items-center">
|
| 79 |
+
<span className="w-full border-t border-border/40" />
|
| 80 |
+
</div>
|
| 81 |
+
<div className="relative flex justify-center text-xs uppercase">
|
| 82 |
+
<span className="bg-card px-2.5 text-muted-foreground text-[10px] tracking-wider font-semibold">
|
| 83 |
+
Or continue with
|
| 84 |
+
</span>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 89 |
{error && (
|
| 90 |
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
|
|
|
|
| 127 |
</div>
|
| 128 |
</div>
|
| 129 |
|
| 130 |
+
<Button id="sign-in-btn" type="submit" className="w-full h-11 text-base" disabled={loading}>
|
| 131 |
{loading ? (
|
| 132 |
<span className="flex items-center gap-2">
|
| 133 |
<span className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
|
frontend/src/app/register/page.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useCallback, useState } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
| 6 |
import { useTranslation } from "react-i18next";
|
|
@@ -10,9 +10,10 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
|
| 10 |
import { Brain, Eye, EyeOff } from "lucide-react";
|
| 11 |
import Link from "next/link";
|
| 12 |
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
|
|
|
|
| 13 |
|
| 14 |
export default function RegisterPage() {
|
| 15 |
-
const { register } = useAuth();
|
| 16 |
const { t } = useTranslation();
|
| 17 |
const router = useRouter();
|
| 18 |
const [username, setUsername] = useState("");
|
|
@@ -22,6 +23,13 @@ export default function RegisterPage() {
|
|
| 22 |
const [error, setError] = useState("");
|
| 23 |
const [loading, setLoading] = useState(false);
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
const handleGoogleSuccess = useCallback(() => {
|
| 26 |
router.replace("/dashboard");
|
| 27 |
}, [router]);
|
|
@@ -58,7 +66,8 @@ export default function RegisterPage() {
|
|
| 58 |
</CardHeader>
|
| 59 |
|
| 60 |
<CardContent>
|
| 61 |
-
<div className="mb-4">
|
|
|
|
| 62 |
<GoogleSignInButton
|
| 63 |
onError={setError}
|
| 64 |
onSuccess={handleGoogleSuccess}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useCallback, useState, useEffect } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
| 6 |
import { useTranslation } from "react-i18next";
|
|
|
|
| 10 |
import { Brain, Eye, EyeOff } from "lucide-react";
|
| 11 |
import Link from "next/link";
|
| 12 |
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
|
| 13 |
+
import HuggingFaceSignInButton from "@/components/auth/HuggingFaceSignInButton";
|
| 14 |
|
| 15 |
export default function RegisterPage() {
|
| 16 |
+
const { register, user, initialized } = useAuth();
|
| 17 |
const { t } = useTranslation();
|
| 18 |
const router = useRouter();
|
| 19 |
const [username, setUsername] = useState("");
|
|
|
|
| 23 |
const [error, setError] = useState("");
|
| 24 |
const [loading, setLoading] = useState(false);
|
| 25 |
|
| 26 |
+
// Redirect if already logged in
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
if (initialized && user) {
|
| 29 |
+
router.replace("/dashboard");
|
| 30 |
+
}
|
| 31 |
+
}, [user, initialized, router]);
|
| 32 |
+
|
| 33 |
const handleGoogleSuccess = useCallback(() => {
|
| 34 |
router.replace("/dashboard");
|
| 35 |
}, [router]);
|
|
|
|
| 66 |
</CardHeader>
|
| 67 |
|
| 68 |
<CardContent>
|
| 69 |
+
<div className="flex flex-col gap-2.5 mb-4">
|
| 70 |
+
<HuggingFaceSignInButton onError={setError} />
|
| 71 |
<GoogleSignInButton
|
| 72 |
onError={setError}
|
| 73 |
onSuccess={handleGoogleSuccess}
|
frontend/src/components/auth/HuggingFaceSignInButton.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { api } from "@/lib/api";
|
| 6 |
+
|
| 7 |
+
type HuggingFaceSignInButtonProps = {
|
| 8 |
+
onError: (message: string) => void;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export default function HuggingFaceSignInButton({ onError }: HuggingFaceSignInButtonProps) {
|
| 12 |
+
const [loading, setLoading] = useState(false);
|
| 13 |
+
|
| 14 |
+
const handleLogin = async () => {
|
| 15 |
+
setLoading(true);
|
| 16 |
+
try {
|
| 17 |
+
// 1. Fetch the Hugging Face OAuth authorization URL from backend
|
| 18 |
+
const data = await api.get<{ url: string }>("/api/v1/auth/login/huggingface");
|
| 19 |
+
if (data.url) {
|
| 20 |
+
// 2. Redirect the user's browser to Hugging Face
|
| 21 |
+
window.location.href = data.url;
|
| 22 |
+
} else {
|
| 23 |
+
onError("Could not retrieve authorization URL from backend.");
|
| 24 |
+
setLoading(false);
|
| 25 |
+
}
|
| 26 |
+
} catch (error) {
|
| 27 |
+
onError(
|
| 28 |
+
error instanceof Error
|
| 29 |
+
? error.message
|
| 30 |
+
: "An error occurred while connecting to Hugging Face OAuth."
|
| 31 |
+
);
|
| 32 |
+
setLoading(false);
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<Button
|
| 38 |
+
onClick={handleLogin}
|
| 39 |
+
disabled={loading}
|
| 40 |
+
variant="outline"
|
| 41 |
+
className="w-full h-11 bg-card/45 backdrop-blur-md border border-border/60 hover:border-[#FFD21E]/60 hover:bg-[#FFD21E]/5 hover:shadow-[0_0_15px_-3px_rgba(255,210,30,0.18)] text-foreground hover:text-[#FFD21E] transition-all duration-300 shadow-sm relative group flex items-center justify-center gap-2.5 font-semibold rounded-xl overflow-hidden active:scale-[0.98] cursor-pointer"
|
| 42 |
+
>
|
| 43 |
+
{loading ? (
|
| 44 |
+
<span className="w-5 h-5 border-2 border-[#FFD21E]/30 border-t-[#FFD21E] rounded-full animate-spin mr-1" />
|
| 45 |
+
) : (
|
| 46 |
+
<svg
|
| 47 |
+
className="w-5 h-5 transition-transform duration-300 group-hover:scale-110 fill-current text-[#FFD21E]"
|
| 48 |
+
viewBox="0 0 24 24"
|
| 49 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 50 |
+
>
|
| 51 |
+
<title>Hugging Face</title>
|
| 52 |
+
<path d="M12.025 1.13c-5.77 0-10.449 4.647-10.449 10.378 0 1.112.178 2.181.503 3.185.064-.222.203-.444.416-.577a.96.96 0 0 1 .524-.15c.293 0 .584.124.84.284.278.173.48.408.71.694.226.282.458.611.684.951v-.014c.017-.324.106-.622.264-.874s.403-.487.762-.543c.3-.047.596.06.787.203s.31.313.4.467c.15.257.212.468.233.542.01.026.653 1.552 1.657 2.54.616.605 1.01 1.223 1.082 1.912.055.537-.096 1.059-.38 1.572.637.121 1.294.187 1.967.187.657 0 1.298-.063 1.921-.178-.287-.517-.44-1.041-.384-1.581.07-.69.465-1.307 1.081-1.913 1.004-.987 1.647-2.513 1.657-2.539.021-.074.083-.285.233-.542.09-.154.208-.323.4-.467a1.08 1.08 0 0 1 .787-.203c.359.056.604.29.762.543s.247.55.265.874v.015c.225-.34.457-.67.683-.952.23-.286.432-.52.71-.694.257-.16.547-.284.84-.285a.97.97 0 0 1 .524.151c.228.143.373.388.43.625l.006.04a10.3 10.3 0 0 0 .534-3.273c0-5.731-4.678-10.378-10.449-10.378M8.327 6.583a1.5 1.5 0 0 1 .713.174 1.487 1.487 0 0 1 .617 2.013c-.183.343-.762-.214-1.102-.094-.38.134-.532.914-.917.71a1.487 1.487 0 0 1 .69-2.803m7.486 0a1.487 1.487 0 0 1 .689 2.803c-.385.204-.536-.576-.916-.71-.34-.12-.92.437-1.103.094a1.487 1.487 0 0 1 .617-2.013 1.5 1.5 0 0 1 .713-.174m-10.68 1.55a.96.96 0 1 1 0 1.921.96.96 0 0 1 0-1.92m13.838 0a.96.96 0 1 1 0 1.92.96.96 0 0 1 0-1.92M8.489 11.458c.588.01 1.965 1.157 3.572 1.164 1.607-.007 2.984-1.155 3.572-1.164.196-.003.305.12.305.454 0 .886-.424 2.328-1.563 3.202-.22-.756-1.396-1.366-1.63-1.32q-.011.001-.02.006l-.044.026-.01.008-.03.024q-.018.017-.035.036l-.032.04a1 1 0 0 0-.058.09l-.014.025q-.049.088-.11.19a1 1 0 0 1-.083.116 1.2 1.2 0 0 1-.173.18q-.035.029-.075.058a1.3 1.3 0 0 1-.251-.243 1 1 0 0 1-.076-.107c-.124-.193-.177-.363-.337-.444-.034-.016-.104-.008-.2.022q-.094.03-.216.087-.06.028-.125.063l-.13.074q-.067.04-.136.086a3 3 0 0 0-.135.096 3 3 0 0 0-.26.219 2 2 0 0 0-.12.121 2 2 0 0 0-.106.128l-.002.002a2 2 0 0 0-.09.132l-.001.001a1.2 1.2 0 0 0-.105.212q-.013.036-.024.073c-1.139-.875-1.563-2.317-1.563-3.203 0-.334.109-.457.305-.454m.836 10.354c.824-1.19.766-2.082-.365-3.194-1.13-1.112-1.789-2.738-1.789-2.738s-.246-.945-.806-.858-.97 1.499.202 2.362c1.173.864-.233 1.45-.685.64-.45-.812-1.683-2.896-2.322-3.295s-1.089-.175-.938.647 2.822 2.813 2.562 3.244-1.176-.506-1.176-.506-2.866-2.567-3.49-1.898.473 1.23 2.037 2.16c1.564.932 1.686 1.178 1.464 1.53s-3.675-2.511-4-1.297c-.323 1.214 3.524 1.567 3.287 2.405-.238.839-2.71-1.587-3.216-.642-.506.946 3.49 2.056 3.522 2.064 1.29.33 4.568 1.028 5.713-.624m5.349 0c-.824-1.19-.766-2.082.365-3.194 1.13-1.112 1.789-2.738 1.789-2.738s.246-.945.806-.858.97 1.499-.202 2.362c-1.173.864.233 1.45.685.64.451-.812 1.683-2.896 2.322-3.295s1.089-.175.938.647-2.822 2.813-2.562 3.244 1.176-.506 1.176-.506 2.866-2.567 3.49-1.898-.473 1.23-2.037 2.16c-1.564.932-1.686 1.178-1.464 1.53s3.675-2.511 4-1.297c.323 1.214-3.524 1.567-3.287 2.405.238.839 2.71-1.587 3.216-.642.506.946-3.49 2.056-3.522 2.064-1.29.33-4.568 1.028-5.713-.624" />
|
| 53 |
+
</svg>
|
| 54 |
+
)}
|
| 55 |
+
<span className="truncate">Sign in with Hugging Face</span>
|
| 56 |
+
</Button>
|
| 57 |
+
);
|
| 58 |
+
}
|
frontend/src/components/layout/Header.tsx
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
| 37 |
import { useWorkspaceStore, WORKSPACES, type WorkspaceId } from "@/store/workspace-store";
|
| 38 |
import { api } from "@/lib/api";
|
| 39 |
import { useTheme } from "next-themes";
|
|
|
|
| 40 |
|
| 41 |
import { useSyncExternalStore } from "react";
|
| 42 |
|
|
@@ -223,7 +224,14 @@ export default function Header({
|
|
| 223 |
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
|
| 224 |
</div>
|
| 225 |
<DropdownMenuSeparator />
|
| 226 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
<LogOut className="w-4 h-4 mr-2" />
|
| 228 |
Sign out
|
| 229 |
</DropdownMenuItem>
|
|
|
|
| 37 |
import { useWorkspaceStore, WORKSPACES, type WorkspaceId } from "@/store/workspace-store";
|
| 38 |
import { api } from "@/lib/api";
|
| 39 |
import { useTheme } from "next-themes";
|
| 40 |
+
import HuggingFaceTokenModal from "@/components/auth/HuggingFaceTokenModal";
|
| 41 |
|
| 42 |
import { useSyncExternalStore } from "react";
|
| 43 |
|
|
|
|
| 224 |
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
|
| 225 |
</div>
|
| 226 |
<DropdownMenuSeparator />
|
| 227 |
+
<div className="px-1 py-0.5">
|
| 228 |
+
<HuggingFaceTokenModal />
|
| 229 |
+
</div>
|
| 230 |
+
<DropdownMenuSeparator />
|
| 231 |
+
<DropdownMenuItem
|
| 232 |
+
className="text-destructive cursor-pointer"
|
| 233 |
+
onClick={handleLogout}
|
| 234 |
+
>
|
| 235 |
<LogOut className="w-4 h-4 mr-2" />
|
| 236 |
Sign out
|
| 237 |
</DropdownMenuItem>
|
frontend/src/lib/api.ts
CHANGED
|
@@ -39,7 +39,7 @@ class ApiClient {
|
|
| 39 |
};
|
| 40 |
|
| 41 |
const authToken = token || this.getToken();
|
| 42 |
-
if (authToken) {
|
| 43 |
headers["Authorization"] = `Bearer ${authToken}`;
|
| 44 |
}
|
| 45 |
|
|
@@ -48,7 +48,11 @@ class ApiClient {
|
|
| 48 |
|
| 49 |
private async fetchWithConnectionError(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
| 50 |
try {
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
} catch (error) {
|
| 53 |
if (error instanceof TypeError) {
|
| 54 |
throw new Error(CONNECTION_ERROR_MESSAGE);
|
|
|
|
| 39 |
};
|
| 40 |
|
| 41 |
const authToken = token || this.getToken();
|
| 42 |
+
if (authToken && authToken !== "cookie") {
|
| 43 |
headers["Authorization"] = `Bearer ${authToken}`;
|
| 44 |
}
|
| 45 |
|
|
|
|
| 48 |
|
| 49 |
private async fetchWithConnectionError(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
| 50 |
try {
|
| 51 |
+
const mergedInit = {
|
| 52 |
+
credentials: "include" as const,
|
| 53 |
+
...init,
|
| 54 |
+
};
|
| 55 |
+
return await fetch(input, mergedInit);
|
| 56 |
} catch (error) {
|
| 57 |
if (error instanceof TypeError) {
|
| 58 |
throw new Error(CONNECTION_ERROR_MESSAGE);
|
frontend/src/store/auth-store.ts
CHANGED
|
@@ -90,7 +90,12 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
|
|
| 90 |
});
|
| 91 |
},
|
| 92 |
|
| 93 |
-
logout() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
clearStoredTokens();
|
| 95 |
set({
|
| 96 |
token: null,
|
|
@@ -105,16 +110,19 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
|
|
| 105 |
if (initialized) return;
|
| 106 |
|
| 107 |
const storedToken = token ?? getStoredToken();
|
| 108 |
-
|
| 109 |
-
set({ token: null, user: null, loading: false, initialized: true });
|
| 110 |
-
return;
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
set({ token: storedToken, loading: true });
|
| 114 |
|
| 115 |
try {
|
| 116 |
-
const user = await api.get<AuthUser>(
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
} catch {
|
| 119 |
clearStoredTokens();
|
| 120 |
set({ user: null, token: null, loading: false, initialized: true });
|
|
|
|
| 90 |
});
|
| 91 |
},
|
| 92 |
|
| 93 |
+
async logout() {
|
| 94 |
+
try {
|
| 95 |
+
await api.post("/api/v1/auth/logout");
|
| 96 |
+
} catch {
|
| 97 |
+
// Ignore network errors on logout
|
| 98 |
+
}
|
| 99 |
clearStoredTokens();
|
| 100 |
set({
|
| 101 |
token: null,
|
|
|
|
| 110 |
if (initialized) return;
|
| 111 |
|
| 112 |
const storedToken = token ?? getStoredToken();
|
| 113 |
+
set({ loading: true });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
try {
|
| 116 |
+
const user = await api.get<AuthUser>(
|
| 117 |
+
"/api/v1/auth/me",
|
| 118 |
+
storedToken ? { token: storedToken } : undefined
|
| 119 |
+
);
|
| 120 |
+
set({
|
| 121 |
+
user,
|
| 122 |
+
token: storedToken || "cookie",
|
| 123 |
+
loading: false,
|
| 124 |
+
initialized: true,
|
| 125 |
+
});
|
| 126 |
} catch {
|
| 127 |
clearStoredTokens();
|
| 128 |
set({ user: null, token: null, loading: false, initialized: true });
|
grafana_dashboard.json
ADDED
|
@@ -0,0 +1,1025 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"annotations": {
|
| 3 |
+
"list": [
|
| 4 |
+
{
|
| 5 |
+
"builtIn": 1,
|
| 6 |
+
"datasource": {
|
| 7 |
+
"type": "grafana",
|
| 8 |
+
"uid": "-- Grafana --"
|
| 9 |
+
},
|
| 10 |
+
"enable": true,
|
| 11 |
+
"hide": true,
|
| 12 |
+
"iconColor": "rgba(0, 211, 255, 1)",
|
| 13 |
+
"name": "Annotations & Alerts",
|
| 14 |
+
"type": "dashboard"
|
| 15 |
+
}
|
| 16 |
+
]
|
| 17 |
+
},
|
| 18 |
+
"description": "System monitoring dashboard for PDF-Assistant-RAG covering API latency, LLM token usage, active users, request volume, error rate, and backend memory.",
|
| 19 |
+
"editable": true,
|
| 20 |
+
"fiscalYearStartMonth": 0,
|
| 21 |
+
"graphTooltip": 1,
|
| 22 |
+
"id": null,
|
| 23 |
+
"links": [],
|
| 24 |
+
"liveNow": false,
|
| 25 |
+
"panels": [
|
| 26 |
+
{
|
| 27 |
+
"collapsed": false,
|
| 28 |
+
"gridPos": {
|
| 29 |
+
"h": 1,
|
| 30 |
+
"w": 24,
|
| 31 |
+
"x": 0,
|
| 32 |
+
"y": 0
|
| 33 |
+
},
|
| 34 |
+
"id": 1,
|
| 35 |
+
"panels": [],
|
| 36 |
+
"title": "Service Overview",
|
| 37 |
+
"type": "row"
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"datasource": {
|
| 41 |
+
"type": "prometheus",
|
| 42 |
+
"uid": "${DS_PROMETHEUS}"
|
| 43 |
+
},
|
| 44 |
+
"fieldConfig": {
|
| 45 |
+
"defaults": {
|
| 46 |
+
"color": {
|
| 47 |
+
"mode": "thresholds"
|
| 48 |
+
},
|
| 49 |
+
"decimals": 0,
|
| 50 |
+
"mappings": [],
|
| 51 |
+
"thresholds": {
|
| 52 |
+
"mode": "absolute",
|
| 53 |
+
"steps": [
|
| 54 |
+
{
|
| 55 |
+
"color": "green",
|
| 56 |
+
"value": null
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"color": "yellow",
|
| 60 |
+
"value": 500
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"color": "red",
|
| 64 |
+
"value": 1500
|
| 65 |
+
}
|
| 66 |
+
]
|
| 67 |
+
},
|
| 68 |
+
"unit": "ms"
|
| 69 |
+
},
|
| 70 |
+
"overrides": []
|
| 71 |
+
},
|
| 72 |
+
"gridPos": {
|
| 73 |
+
"h": 4,
|
| 74 |
+
"w": 6,
|
| 75 |
+
"x": 0,
|
| 76 |
+
"y": 1
|
| 77 |
+
},
|
| 78 |
+
"id": 2,
|
| 79 |
+
"options": {
|
| 80 |
+
"colorMode": "background",
|
| 81 |
+
"graphMode": "area",
|
| 82 |
+
"justifyMode": "auto",
|
| 83 |
+
"orientation": "auto",
|
| 84 |
+
"reduceOptions": {
|
| 85 |
+
"calcs": [
|
| 86 |
+
"lastNotNull"
|
| 87 |
+
],
|
| 88 |
+
"fields": "",
|
| 89 |
+
"values": false
|
| 90 |
+
},
|
| 91 |
+
"textMode": "auto"
|
| 92 |
+
},
|
| 93 |
+
"pluginVersion": "10.4.0",
|
| 94 |
+
"targets": [
|
| 95 |
+
{
|
| 96 |
+
"datasource": {
|
| 97 |
+
"type": "prometheus",
|
| 98 |
+
"uid": "${DS_PROMETHEUS}"
|
| 99 |
+
},
|
| 100 |
+
"editorMode": "code",
|
| 101 |
+
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{handler=~\"$handler\"}[$__rate_interval])) by (le)) * 1000",
|
| 102 |
+
"legendFormat": "p95 latency",
|
| 103 |
+
"range": true,
|
| 104 |
+
"refId": "A"
|
| 105 |
+
}
|
| 106 |
+
],
|
| 107 |
+
"title": "API p95 Latency",
|
| 108 |
+
"type": "stat"
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"datasource": {
|
| 112 |
+
"type": "prometheus",
|
| 113 |
+
"uid": "${DS_PROMETHEUS}"
|
| 114 |
+
},
|
| 115 |
+
"fieldConfig": {
|
| 116 |
+
"defaults": {
|
| 117 |
+
"color": {
|
| 118 |
+
"mode": "palette-classic"
|
| 119 |
+
},
|
| 120 |
+
"decimals": 2,
|
| 121 |
+
"mappings": [],
|
| 122 |
+
"thresholds": {
|
| 123 |
+
"mode": "absolute",
|
| 124 |
+
"steps": [
|
| 125 |
+
{
|
| 126 |
+
"color": "blue",
|
| 127 |
+
"value": null
|
| 128 |
+
}
|
| 129 |
+
]
|
| 130 |
+
},
|
| 131 |
+
"unit": "reqps"
|
| 132 |
+
},
|
| 133 |
+
"overrides": []
|
| 134 |
+
},
|
| 135 |
+
"gridPos": {
|
| 136 |
+
"h": 4,
|
| 137 |
+
"w": 6,
|
| 138 |
+
"x": 6,
|
| 139 |
+
"y": 1
|
| 140 |
+
},
|
| 141 |
+
"id": 3,
|
| 142 |
+
"options": {
|
| 143 |
+
"colorMode": "background",
|
| 144 |
+
"graphMode": "area",
|
| 145 |
+
"justifyMode": "auto",
|
| 146 |
+
"orientation": "auto",
|
| 147 |
+
"reduceOptions": {
|
| 148 |
+
"calcs": [
|
| 149 |
+
"lastNotNull"
|
| 150 |
+
],
|
| 151 |
+
"fields": "",
|
| 152 |
+
"values": false
|
| 153 |
+
},
|
| 154 |
+
"textMode": "auto"
|
| 155 |
+
},
|
| 156 |
+
"pluginVersion": "10.4.0",
|
| 157 |
+
"targets": [
|
| 158 |
+
{
|
| 159 |
+
"datasource": {
|
| 160 |
+
"type": "prometheus",
|
| 161 |
+
"uid": "${DS_PROMETHEUS}"
|
| 162 |
+
},
|
| 163 |
+
"editorMode": "code",
|
| 164 |
+
"expr": "sum(rate(http_requests_total{handler=~\"$handler\"}[$__rate_interval]))",
|
| 165 |
+
"legendFormat": "requests/sec",
|
| 166 |
+
"range": true,
|
| 167 |
+
"refId": "A"
|
| 168 |
+
}
|
| 169 |
+
],
|
| 170 |
+
"title": "API Throughput",
|
| 171 |
+
"type": "stat"
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
"datasource": {
|
| 175 |
+
"type": "prometheus",
|
| 176 |
+
"uid": "${DS_PROMETHEUS}"
|
| 177 |
+
},
|
| 178 |
+
"description": "Requires an application counter named llm_tokens_total with labels such as direction=\"input|output\".",
|
| 179 |
+
"fieldConfig": {
|
| 180 |
+
"defaults": {
|
| 181 |
+
"color": {
|
| 182 |
+
"mode": "continuous-BlPu"
|
| 183 |
+
},
|
| 184 |
+
"decimals": 0,
|
| 185 |
+
"mappings": [],
|
| 186 |
+
"thresholds": {
|
| 187 |
+
"mode": "absolute",
|
| 188 |
+
"steps": [
|
| 189 |
+
{
|
| 190 |
+
"color": "purple",
|
| 191 |
+
"value": null
|
| 192 |
+
}
|
| 193 |
+
]
|
| 194 |
+
},
|
| 195 |
+
"unit": "short"
|
| 196 |
+
},
|
| 197 |
+
"overrides": []
|
| 198 |
+
},
|
| 199 |
+
"gridPos": {
|
| 200 |
+
"h": 4,
|
| 201 |
+
"w": 6,
|
| 202 |
+
"x": 12,
|
| 203 |
+
"y": 1
|
| 204 |
+
},
|
| 205 |
+
"id": 4,
|
| 206 |
+
"options": {
|
| 207 |
+
"colorMode": "background",
|
| 208 |
+
"graphMode": "area",
|
| 209 |
+
"justifyMode": "auto",
|
| 210 |
+
"orientation": "auto",
|
| 211 |
+
"reduceOptions": {
|
| 212 |
+
"calcs": [
|
| 213 |
+
"lastNotNull"
|
| 214 |
+
],
|
| 215 |
+
"fields": "",
|
| 216 |
+
"values": false
|
| 217 |
+
},
|
| 218 |
+
"textMode": "auto"
|
| 219 |
+
},
|
| 220 |
+
"pluginVersion": "10.4.0",
|
| 221 |
+
"targets": [
|
| 222 |
+
{
|
| 223 |
+
"datasource": {
|
| 224 |
+
"type": "prometheus",
|
| 225 |
+
"uid": "${DS_PROMETHEUS}"
|
| 226 |
+
},
|
| 227 |
+
"editorMode": "code",
|
| 228 |
+
"expr": "sum(rate(llm_tokens_total[$__rate_interval])) * 60",
|
| 229 |
+
"legendFormat": "tokens/min",
|
| 230 |
+
"range": true,
|
| 231 |
+
"refId": "A"
|
| 232 |
+
}
|
| 233 |
+
],
|
| 234 |
+
"title": "LLM Tokens / min",
|
| 235 |
+
"type": "stat"
|
| 236 |
+
},
|
| 237 |
+
{
|
| 238 |
+
"datasource": {
|
| 239 |
+
"type": "prometheus",
|
| 240 |
+
"uid": "${DS_PROMETHEUS}"
|
| 241 |
+
},
|
| 242 |
+
"description": "Requires an application gauge named active_users.",
|
| 243 |
+
"fieldConfig": {
|
| 244 |
+
"defaults": {
|
| 245 |
+
"color": {
|
| 246 |
+
"mode": "continuous-GrYlRd"
|
| 247 |
+
},
|
| 248 |
+
"decimals": 0,
|
| 249 |
+
"mappings": [],
|
| 250 |
+
"thresholds": {
|
| 251 |
+
"mode": "absolute",
|
| 252 |
+
"steps": [
|
| 253 |
+
{
|
| 254 |
+
"color": "green",
|
| 255 |
+
"value": null
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
"color": "yellow",
|
| 259 |
+
"value": 100
|
| 260 |
+
},
|
| 261 |
+
{
|
| 262 |
+
"color": "red",
|
| 263 |
+
"value": 500
|
| 264 |
+
}
|
| 265 |
+
]
|
| 266 |
+
},
|
| 267 |
+
"unit": "short"
|
| 268 |
+
},
|
| 269 |
+
"overrides": []
|
| 270 |
+
},
|
| 271 |
+
"gridPos": {
|
| 272 |
+
"h": 4,
|
| 273 |
+
"w": 6,
|
| 274 |
+
"x": 18,
|
| 275 |
+
"y": 1
|
| 276 |
+
},
|
| 277 |
+
"id": 5,
|
| 278 |
+
"options": {
|
| 279 |
+
"colorMode": "background",
|
| 280 |
+
"graphMode": "area",
|
| 281 |
+
"justifyMode": "auto",
|
| 282 |
+
"orientation": "auto",
|
| 283 |
+
"reduceOptions": {
|
| 284 |
+
"calcs": [
|
| 285 |
+
"lastNotNull"
|
| 286 |
+
],
|
| 287 |
+
"fields": "",
|
| 288 |
+
"values": false
|
| 289 |
+
},
|
| 290 |
+
"textMode": "auto"
|
| 291 |
+
},
|
| 292 |
+
"pluginVersion": "10.4.0",
|
| 293 |
+
"targets": [
|
| 294 |
+
{
|
| 295 |
+
"datasource": {
|
| 296 |
+
"type": "prometheus",
|
| 297 |
+
"uid": "${DS_PROMETHEUS}"
|
| 298 |
+
},
|
| 299 |
+
"editorMode": "code",
|
| 300 |
+
"expr": "sum(active_users)",
|
| 301 |
+
"legendFormat": "active users",
|
| 302 |
+
"range": true,
|
| 303 |
+
"refId": "A"
|
| 304 |
+
}
|
| 305 |
+
],
|
| 306 |
+
"title": "Active Users",
|
| 307 |
+
"type": "stat"
|
| 308 |
+
},
|
| 309 |
+
{
|
| 310 |
+
"collapsed": false,
|
| 311 |
+
"gridPos": {
|
| 312 |
+
"h": 1,
|
| 313 |
+
"w": 24,
|
| 314 |
+
"x": 0,
|
| 315 |
+
"y": 5
|
| 316 |
+
},
|
| 317 |
+
"id": 6,
|
| 318 |
+
"panels": [],
|
| 319 |
+
"title": "API Health",
|
| 320 |
+
"type": "row"
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
"datasource": {
|
| 324 |
+
"type": "prometheus",
|
| 325 |
+
"uid": "${DS_PROMETHEUS}"
|
| 326 |
+
},
|
| 327 |
+
"fieldConfig": {
|
| 328 |
+
"defaults": {
|
| 329 |
+
"custom": {
|
| 330 |
+
"drawStyle": "line",
|
| 331 |
+
"fillOpacity": 18,
|
| 332 |
+
"gradientMode": "opacity",
|
| 333 |
+
"lineInterpolation": "smooth",
|
| 334 |
+
"lineWidth": 2,
|
| 335 |
+
"pointSize": 5,
|
| 336 |
+
"showPoints": "never",
|
| 337 |
+
"spanNulls": false
|
| 338 |
+
},
|
| 339 |
+
"mappings": [],
|
| 340 |
+
"thresholds": {
|
| 341 |
+
"mode": "absolute",
|
| 342 |
+
"steps": [
|
| 343 |
+
{
|
| 344 |
+
"color": "green",
|
| 345 |
+
"value": null
|
| 346 |
+
}
|
| 347 |
+
]
|
| 348 |
+
},
|
| 349 |
+
"unit": "ms"
|
| 350 |
+
},
|
| 351 |
+
"overrides": []
|
| 352 |
+
},
|
| 353 |
+
"gridPos": {
|
| 354 |
+
"h": 8,
|
| 355 |
+
"w": 12,
|
| 356 |
+
"x": 0,
|
| 357 |
+
"y": 6
|
| 358 |
+
},
|
| 359 |
+
"id": 7,
|
| 360 |
+
"options": {
|
| 361 |
+
"legend": {
|
| 362 |
+
"calcs": [
|
| 363 |
+
"lastNotNull",
|
| 364 |
+
"max"
|
| 365 |
+
],
|
| 366 |
+
"displayMode": "table",
|
| 367 |
+
"placement": "bottom",
|
| 368 |
+
"showLegend": true
|
| 369 |
+
},
|
| 370 |
+
"tooltip": {
|
| 371 |
+
"mode": "multi",
|
| 372 |
+
"sort": "desc"
|
| 373 |
+
}
|
| 374 |
+
},
|
| 375 |
+
"targets": [
|
| 376 |
+
{
|
| 377 |
+
"datasource": {
|
| 378 |
+
"type": "prometheus",
|
| 379 |
+
"uid": "${DS_PROMETHEUS}"
|
| 380 |
+
},
|
| 381 |
+
"editorMode": "code",
|
| 382 |
+
"expr": "histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket{handler=~\"$handler\"}[$__rate_interval])) by (le)) * 1000",
|
| 383 |
+
"legendFormat": "p50",
|
| 384 |
+
"range": true,
|
| 385 |
+
"refId": "A"
|
| 386 |
+
},
|
| 387 |
+
{
|
| 388 |
+
"datasource": {
|
| 389 |
+
"type": "prometheus",
|
| 390 |
+
"uid": "${DS_PROMETHEUS}"
|
| 391 |
+
},
|
| 392 |
+
"editorMode": "code",
|
| 393 |
+
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{handler=~\"$handler\"}[$__rate_interval])) by (le)) * 1000",
|
| 394 |
+
"legendFormat": "p95",
|
| 395 |
+
"range": true,
|
| 396 |
+
"refId": "B"
|
| 397 |
+
},
|
| 398 |
+
{
|
| 399 |
+
"datasource": {
|
| 400 |
+
"type": "prometheus",
|
| 401 |
+
"uid": "${DS_PROMETHEUS}"
|
| 402 |
+
},
|
| 403 |
+
"editorMode": "code",
|
| 404 |
+
"expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{handler=~\"$handler\"}[$__rate_interval])) by (le)) * 1000",
|
| 405 |
+
"legendFormat": "p99",
|
| 406 |
+
"range": true,
|
| 407 |
+
"refId": "C"
|
| 408 |
+
}
|
| 409 |
+
],
|
| 410 |
+
"title": "API Latency Percentiles",
|
| 411 |
+
"type": "timeseries"
|
| 412 |
+
},
|
| 413 |
+
{
|
| 414 |
+
"datasource": {
|
| 415 |
+
"type": "prometheus",
|
| 416 |
+
"uid": "${DS_PROMETHEUS}"
|
| 417 |
+
},
|
| 418 |
+
"fieldConfig": {
|
| 419 |
+
"defaults": {
|
| 420 |
+
"color": {
|
| 421 |
+
"mode": "palette-classic"
|
| 422 |
+
},
|
| 423 |
+
"custom": {
|
| 424 |
+
"drawStyle": "line",
|
| 425 |
+
"fillOpacity": 20,
|
| 426 |
+
"gradientMode": "opacity",
|
| 427 |
+
"lineInterpolation": "smooth",
|
| 428 |
+
"lineWidth": 2,
|
| 429 |
+
"pointSize": 4,
|
| 430 |
+
"showPoints": "never",
|
| 431 |
+
"spanNulls": false
|
| 432 |
+
},
|
| 433 |
+
"mappings": [],
|
| 434 |
+
"thresholds": {
|
| 435 |
+
"mode": "absolute",
|
| 436 |
+
"steps": [
|
| 437 |
+
{
|
| 438 |
+
"color": "green",
|
| 439 |
+
"value": null
|
| 440 |
+
}
|
| 441 |
+
]
|
| 442 |
+
},
|
| 443 |
+
"unit": "reqps"
|
| 444 |
+
},
|
| 445 |
+
"overrides": []
|
| 446 |
+
},
|
| 447 |
+
"gridPos": {
|
| 448 |
+
"h": 8,
|
| 449 |
+
"w": 12,
|
| 450 |
+
"x": 12,
|
| 451 |
+
"y": 6
|
| 452 |
+
},
|
| 453 |
+
"id": 8,
|
| 454 |
+
"options": {
|
| 455 |
+
"legend": {
|
| 456 |
+
"calcs": [
|
| 457 |
+
"lastNotNull"
|
| 458 |
+
],
|
| 459 |
+
"displayMode": "table",
|
| 460 |
+
"placement": "bottom",
|
| 461 |
+
"showLegend": true
|
| 462 |
+
},
|
| 463 |
+
"tooltip": {
|
| 464 |
+
"mode": "multi",
|
| 465 |
+
"sort": "desc"
|
| 466 |
+
}
|
| 467 |
+
},
|
| 468 |
+
"targets": [
|
| 469 |
+
{
|
| 470 |
+
"datasource": {
|
| 471 |
+
"type": "prometheus",
|
| 472 |
+
"uid": "${DS_PROMETHEUS}"
|
| 473 |
+
},
|
| 474 |
+
"editorMode": "code",
|
| 475 |
+
"expr": "sum(rate(http_requests_total{handler=~\"$handler\"}[$__rate_interval])) by (handler)",
|
| 476 |
+
"legendFormat": "{{handler}}",
|
| 477 |
+
"range": true,
|
| 478 |
+
"refId": "A"
|
| 479 |
+
}
|
| 480 |
+
],
|
| 481 |
+
"title": "Request Rate by Route",
|
| 482 |
+
"type": "timeseries"
|
| 483 |
+
},
|
| 484 |
+
{
|
| 485 |
+
"datasource": {
|
| 486 |
+
"type": "prometheus",
|
| 487 |
+
"uid": "${DS_PROMETHEUS}"
|
| 488 |
+
},
|
| 489 |
+
"fieldConfig": {
|
| 490 |
+
"defaults": {
|
| 491 |
+
"color": {
|
| 492 |
+
"mode": "thresholds"
|
| 493 |
+
},
|
| 494 |
+
"custom": {
|
| 495 |
+
"drawStyle": "line",
|
| 496 |
+
"fillOpacity": 25,
|
| 497 |
+
"gradientMode": "opacity",
|
| 498 |
+
"lineInterpolation": "smooth",
|
| 499 |
+
"lineWidth": 2,
|
| 500 |
+
"pointSize": 4,
|
| 501 |
+
"showPoints": "never",
|
| 502 |
+
"spanNulls": false
|
| 503 |
+
},
|
| 504 |
+
"mappings": [],
|
| 505 |
+
"thresholds": {
|
| 506 |
+
"mode": "absolute",
|
| 507 |
+
"steps": [
|
| 508 |
+
{
|
| 509 |
+
"color": "green",
|
| 510 |
+
"value": null
|
| 511 |
+
},
|
| 512 |
+
{
|
| 513 |
+
"color": "yellow",
|
| 514 |
+
"value": 1
|
| 515 |
+
},
|
| 516 |
+
{
|
| 517 |
+
"color": "red",
|
| 518 |
+
"value": 5
|
| 519 |
+
}
|
| 520 |
+
]
|
| 521 |
+
},
|
| 522 |
+
"unit": "percent"
|
| 523 |
+
},
|
| 524 |
+
"overrides": []
|
| 525 |
+
},
|
| 526 |
+
"gridPos": {
|
| 527 |
+
"h": 7,
|
| 528 |
+
"w": 12,
|
| 529 |
+
"x": 0,
|
| 530 |
+
"y": 14
|
| 531 |
+
},
|
| 532 |
+
"id": 9,
|
| 533 |
+
"options": {
|
| 534 |
+
"legend": {
|
| 535 |
+
"calcs": [
|
| 536 |
+
"lastNotNull",
|
| 537 |
+
"max"
|
| 538 |
+
],
|
| 539 |
+
"displayMode": "table",
|
| 540 |
+
"placement": "bottom",
|
| 541 |
+
"showLegend": true
|
| 542 |
+
},
|
| 543 |
+
"tooltip": {
|
| 544 |
+
"mode": "multi",
|
| 545 |
+
"sort": "desc"
|
| 546 |
+
}
|
| 547 |
+
},
|
| 548 |
+
"targets": [
|
| 549 |
+
{
|
| 550 |
+
"datasource": {
|
| 551 |
+
"type": "prometheus",
|
| 552 |
+
"uid": "${DS_PROMETHEUS}"
|
| 553 |
+
},
|
| 554 |
+
"editorMode": "code",
|
| 555 |
+
"expr": "100 * sum(rate(http_requests_total{handler=~\"$handler\", status=~\"5..\"}[$__rate_interval])) / clamp_min(sum(rate(http_requests_total{handler=~\"$handler\"}[$__rate_interval])), 0.001)",
|
| 556 |
+
"legendFormat": "5xx error rate",
|
| 557 |
+
"range": true,
|
| 558 |
+
"refId": "A"
|
| 559 |
+
}
|
| 560 |
+
],
|
| 561 |
+
"title": "API Error Rate",
|
| 562 |
+
"type": "timeseries"
|
| 563 |
+
},
|
| 564 |
+
{
|
| 565 |
+
"datasource": {
|
| 566 |
+
"type": "prometheus",
|
| 567 |
+
"uid": "${DS_PROMETHEUS}"
|
| 568 |
+
},
|
| 569 |
+
"fieldConfig": {
|
| 570 |
+
"defaults": {
|
| 571 |
+
"custom": {
|
| 572 |
+
"align": "auto",
|
| 573 |
+
"cellOptions": {
|
| 574 |
+
"type": "color-background"
|
| 575 |
+
},
|
| 576 |
+
"inspect": false
|
| 577 |
+
},
|
| 578 |
+
"mappings": [],
|
| 579 |
+
"thresholds": {
|
| 580 |
+
"mode": "absolute",
|
| 581 |
+
"steps": [
|
| 582 |
+
{
|
| 583 |
+
"color": "green",
|
| 584 |
+
"value": null
|
| 585 |
+
},
|
| 586 |
+
{
|
| 587 |
+
"color": "yellow",
|
| 588 |
+
"value": 500
|
| 589 |
+
},
|
| 590 |
+
{
|
| 591 |
+
"color": "red",
|
| 592 |
+
"value": 1500
|
| 593 |
+
}
|
| 594 |
+
]
|
| 595 |
+
},
|
| 596 |
+
"unit": "ms"
|
| 597 |
+
},
|
| 598 |
+
"overrides": []
|
| 599 |
+
},
|
| 600 |
+
"gridPos": {
|
| 601 |
+
"h": 7,
|
| 602 |
+
"w": 12,
|
| 603 |
+
"x": 12,
|
| 604 |
+
"y": 14
|
| 605 |
+
},
|
| 606 |
+
"id": 10,
|
| 607 |
+
"options": {
|
| 608 |
+
"cellHeight": "sm",
|
| 609 |
+
"footer": {
|
| 610 |
+
"countRows": false,
|
| 611 |
+
"fields": "",
|
| 612 |
+
"reducer": [
|
| 613 |
+
"sum"
|
| 614 |
+
],
|
| 615 |
+
"show": false
|
| 616 |
+
},
|
| 617 |
+
"showHeader": true
|
| 618 |
+
},
|
| 619 |
+
"pluginVersion": "10.4.0",
|
| 620 |
+
"targets": [
|
| 621 |
+
{
|
| 622 |
+
"datasource": {
|
| 623 |
+
"type": "prometheus",
|
| 624 |
+
"uid": "${DS_PROMETHEUS}"
|
| 625 |
+
},
|
| 626 |
+
"editorMode": "code",
|
| 627 |
+
"expr": "topk(10, sum(rate(http_request_duration_seconds_sum{handler=~\"$handler\"}[$__rate_interval])) by (handler) / clamp_min(sum(rate(http_request_duration_seconds_count{handler=~\"$handler\"}[$__rate_interval])) by (handler), 0.001) * 1000)",
|
| 628 |
+
"format": "table",
|
| 629 |
+
"instant": true,
|
| 630 |
+
"legendFormat": "{{handler}}",
|
| 631 |
+
"refId": "A"
|
| 632 |
+
}
|
| 633 |
+
],
|
| 634 |
+
"title": "Slowest Routes - Average Latency",
|
| 635 |
+
"type": "table"
|
| 636 |
+
},
|
| 637 |
+
{
|
| 638 |
+
"collapsed": false,
|
| 639 |
+
"gridPos": {
|
| 640 |
+
"h": 1,
|
| 641 |
+
"w": 24,
|
| 642 |
+
"x": 0,
|
| 643 |
+
"y": 21
|
| 644 |
+
},
|
| 645 |
+
"id": 11,
|
| 646 |
+
"panels": [],
|
| 647 |
+
"title": "LLM and User Activity",
|
| 648 |
+
"type": "row"
|
| 649 |
+
},
|
| 650 |
+
{
|
| 651 |
+
"datasource": {
|
| 652 |
+
"type": "prometheus",
|
| 653 |
+
"uid": "${DS_PROMETHEUS}"
|
| 654 |
+
},
|
| 655 |
+
"description": "Requires llm_tokens_total. Suggested labels: direction, model, route.",
|
| 656 |
+
"fieldConfig": {
|
| 657 |
+
"defaults": {
|
| 658 |
+
"color": {
|
| 659 |
+
"mode": "palette-classic"
|
| 660 |
+
},
|
| 661 |
+
"custom": {
|
| 662 |
+
"drawStyle": "line",
|
| 663 |
+
"fillOpacity": 25,
|
| 664 |
+
"gradientMode": "opacity",
|
| 665 |
+
"lineInterpolation": "smooth",
|
| 666 |
+
"lineWidth": 2,
|
| 667 |
+
"pointSize": 4,
|
| 668 |
+
"showPoints": "never",
|
| 669 |
+
"spanNulls": false
|
| 670 |
+
},
|
| 671 |
+
"mappings": [],
|
| 672 |
+
"thresholds": {
|
| 673 |
+
"mode": "absolute",
|
| 674 |
+
"steps": [
|
| 675 |
+
{
|
| 676 |
+
"color": "green",
|
| 677 |
+
"value": null
|
| 678 |
+
}
|
| 679 |
+
]
|
| 680 |
+
},
|
| 681 |
+
"unit": "short"
|
| 682 |
+
},
|
| 683 |
+
"overrides": []
|
| 684 |
+
},
|
| 685 |
+
"gridPos": {
|
| 686 |
+
"h": 8,
|
| 687 |
+
"w": 12,
|
| 688 |
+
"x": 0,
|
| 689 |
+
"y": 22
|
| 690 |
+
},
|
| 691 |
+
"id": 12,
|
| 692 |
+
"options": {
|
| 693 |
+
"legend": {
|
| 694 |
+
"calcs": [
|
| 695 |
+
"lastNotNull",
|
| 696 |
+
"sum"
|
| 697 |
+
],
|
| 698 |
+
"displayMode": "table",
|
| 699 |
+
"placement": "bottom",
|
| 700 |
+
"showLegend": true
|
| 701 |
+
},
|
| 702 |
+
"tooltip": {
|
| 703 |
+
"mode": "multi",
|
| 704 |
+
"sort": "desc"
|
| 705 |
+
}
|
| 706 |
+
},
|
| 707 |
+
"targets": [
|
| 708 |
+
{
|
| 709 |
+
"datasource": {
|
| 710 |
+
"type": "prometheus",
|
| 711 |
+
"uid": "${DS_PROMETHEUS}"
|
| 712 |
+
},
|
| 713 |
+
"editorMode": "code",
|
| 714 |
+
"expr": "sum(rate(llm_tokens_total[$__rate_interval])) by (direction) * 60",
|
| 715 |
+
"legendFormat": "{{direction}} tokens/min",
|
| 716 |
+
"range": true,
|
| 717 |
+
"refId": "A"
|
| 718 |
+
}
|
| 719 |
+
],
|
| 720 |
+
"title": "LLM Token Usage by Direction",
|
| 721 |
+
"type": "timeseries"
|
| 722 |
+
},
|
| 723 |
+
{
|
| 724 |
+
"datasource": {
|
| 725 |
+
"type": "prometheus",
|
| 726 |
+
"uid": "${DS_PROMETHEUS}"
|
| 727 |
+
},
|
| 728 |
+
"description": "Requires active_users gauge. Optional labels such as auth_provider or plan are supported.",
|
| 729 |
+
"fieldConfig": {
|
| 730 |
+
"defaults": {
|
| 731 |
+
"color": {
|
| 732 |
+
"mode": "continuous-GrYlRd"
|
| 733 |
+
},
|
| 734 |
+
"custom": {
|
| 735 |
+
"drawStyle": "line",
|
| 736 |
+
"fillOpacity": 30,
|
| 737 |
+
"gradientMode": "opacity",
|
| 738 |
+
"lineInterpolation": "smooth",
|
| 739 |
+
"lineWidth": 2,
|
| 740 |
+
"pointSize": 4,
|
| 741 |
+
"showPoints": "never",
|
| 742 |
+
"spanNulls": false
|
| 743 |
+
},
|
| 744 |
+
"mappings": [],
|
| 745 |
+
"thresholds": {
|
| 746 |
+
"mode": "absolute",
|
| 747 |
+
"steps": [
|
| 748 |
+
{
|
| 749 |
+
"color": "green",
|
| 750 |
+
"value": null
|
| 751 |
+
}
|
| 752 |
+
]
|
| 753 |
+
},
|
| 754 |
+
"unit": "short"
|
| 755 |
+
},
|
| 756 |
+
"overrides": []
|
| 757 |
+
},
|
| 758 |
+
"gridPos": {
|
| 759 |
+
"h": 8,
|
| 760 |
+
"w": 12,
|
| 761 |
+
"x": 12,
|
| 762 |
+
"y": 22
|
| 763 |
+
},
|
| 764 |
+
"id": 13,
|
| 765 |
+
"options": {
|
| 766 |
+
"legend": {
|
| 767 |
+
"calcs": [
|
| 768 |
+
"lastNotNull",
|
| 769 |
+
"max"
|
| 770 |
+
],
|
| 771 |
+
"displayMode": "table",
|
| 772 |
+
"placement": "bottom",
|
| 773 |
+
"showLegend": true
|
| 774 |
+
},
|
| 775 |
+
"tooltip": {
|
| 776 |
+
"mode": "multi",
|
| 777 |
+
"sort": "desc"
|
| 778 |
+
}
|
| 779 |
+
},
|
| 780 |
+
"targets": [
|
| 781 |
+
{
|
| 782 |
+
"datasource": {
|
| 783 |
+
"type": "prometheus",
|
| 784 |
+
"uid": "${DS_PROMETHEUS}"
|
| 785 |
+
},
|
| 786 |
+
"editorMode": "code",
|
| 787 |
+
"expr": "sum(active_users)",
|
| 788 |
+
"legendFormat": "active users",
|
| 789 |
+
"range": true,
|
| 790 |
+
"refId": "A"
|
| 791 |
+
}
|
| 792 |
+
],
|
| 793 |
+
"title": "Active Users Over Time",
|
| 794 |
+
"type": "timeseries"
|
| 795 |
+
},
|
| 796 |
+
{
|
| 797 |
+
"collapsed": false,
|
| 798 |
+
"gridPos": {
|
| 799 |
+
"h": 1,
|
| 800 |
+
"w": 24,
|
| 801 |
+
"x": 0,
|
| 802 |
+
"y": 30
|
| 803 |
+
},
|
| 804 |
+
"id": 14,
|
| 805 |
+
"panels": [],
|
| 806 |
+
"title": "Runtime",
|
| 807 |
+
"type": "row"
|
| 808 |
+
},
|
| 809 |
+
{
|
| 810 |
+
"datasource": {
|
| 811 |
+
"type": "prometheus",
|
| 812 |
+
"uid": "${DS_PROMETHEUS}"
|
| 813 |
+
},
|
| 814 |
+
"fieldConfig": {
|
| 815 |
+
"defaults": {
|
| 816 |
+
"color": {
|
| 817 |
+
"mode": "palette-classic"
|
| 818 |
+
},
|
| 819 |
+
"custom": {
|
| 820 |
+
"drawStyle": "line",
|
| 821 |
+
"fillOpacity": 20,
|
| 822 |
+
"gradientMode": "opacity",
|
| 823 |
+
"lineInterpolation": "smooth",
|
| 824 |
+
"lineWidth": 2,
|
| 825 |
+
"pointSize": 4,
|
| 826 |
+
"showPoints": "never",
|
| 827 |
+
"spanNulls": false
|
| 828 |
+
},
|
| 829 |
+
"mappings": [],
|
| 830 |
+
"thresholds": {
|
| 831 |
+
"mode": "absolute",
|
| 832 |
+
"steps": [
|
| 833 |
+
{
|
| 834 |
+
"color": "green",
|
| 835 |
+
"value": null
|
| 836 |
+
}
|
| 837 |
+
]
|
| 838 |
+
},
|
| 839 |
+
"unit": "decbytes"
|
| 840 |
+
},
|
| 841 |
+
"overrides": []
|
| 842 |
+
},
|
| 843 |
+
"gridPos": {
|
| 844 |
+
"h": 7,
|
| 845 |
+
"w": 12,
|
| 846 |
+
"x": 0,
|
| 847 |
+
"y": 31
|
| 848 |
+
},
|
| 849 |
+
"id": 15,
|
| 850 |
+
"options": {
|
| 851 |
+
"legend": {
|
| 852 |
+
"calcs": [
|
| 853 |
+
"lastNotNull",
|
| 854 |
+
"max"
|
| 855 |
+
],
|
| 856 |
+
"displayMode": "table",
|
| 857 |
+
"placement": "bottom",
|
| 858 |
+
"showLegend": true
|
| 859 |
+
},
|
| 860 |
+
"tooltip": {
|
| 861 |
+
"mode": "multi",
|
| 862 |
+
"sort": "desc"
|
| 863 |
+
}
|
| 864 |
+
},
|
| 865 |
+
"targets": [
|
| 866 |
+
{
|
| 867 |
+
"datasource": {
|
| 868 |
+
"type": "prometheus",
|
| 869 |
+
"uid": "${DS_PROMETHEUS}"
|
| 870 |
+
},
|
| 871 |
+
"editorMode": "code",
|
| 872 |
+
"expr": "app_process_resident_memory_bytes",
|
| 873 |
+
"legendFormat": "backend RSS",
|
| 874 |
+
"range": true,
|
| 875 |
+
"refId": "A"
|
| 876 |
+
}
|
| 877 |
+
],
|
| 878 |
+
"title": "Backend Memory",
|
| 879 |
+
"type": "timeseries"
|
| 880 |
+
},
|
| 881 |
+
{
|
| 882 |
+
"datasource": {
|
| 883 |
+
"type": "prometheus",
|
| 884 |
+
"uid": "${DS_PROMETHEUS}"
|
| 885 |
+
},
|
| 886 |
+
"fieldConfig": {
|
| 887 |
+
"defaults": {
|
| 888 |
+
"mappings": [],
|
| 889 |
+
"thresholds": {
|
| 890 |
+
"mode": "absolute",
|
| 891 |
+
"steps": [
|
| 892 |
+
{
|
| 893 |
+
"color": "red",
|
| 894 |
+
"value": null
|
| 895 |
+
},
|
| 896 |
+
{
|
| 897 |
+
"color": "green",
|
| 898 |
+
"value": 1
|
| 899 |
+
}
|
| 900 |
+
]
|
| 901 |
+
},
|
| 902 |
+
"unit": "none"
|
| 903 |
+
},
|
| 904 |
+
"overrides": []
|
| 905 |
+
},
|
| 906 |
+
"gridPos": {
|
| 907 |
+
"h": 7,
|
| 908 |
+
"w": 12,
|
| 909 |
+
"x": 12,
|
| 910 |
+
"y": 31
|
| 911 |
+
},
|
| 912 |
+
"id": 16,
|
| 913 |
+
"options": {
|
| 914 |
+
"displayMode": "lcd",
|
| 915 |
+
"maxVizHeight": 300,
|
| 916 |
+
"minVizHeight": 16,
|
| 917 |
+
"minVizWidth": 8,
|
| 918 |
+
"namePlacement": "auto",
|
| 919 |
+
"orientation": "horizontal",
|
| 920 |
+
"reduceOptions": {
|
| 921 |
+
"calcs": [
|
| 922 |
+
"lastNotNull"
|
| 923 |
+
],
|
| 924 |
+
"fields": "",
|
| 925 |
+
"values": false
|
| 926 |
+
},
|
| 927 |
+
"showUnfilled": true,
|
| 928 |
+
"sizing": "auto",
|
| 929 |
+
"valueMode": "color"
|
| 930 |
+
},
|
| 931 |
+
"pluginVersion": "10.4.0",
|
| 932 |
+
"targets": [
|
| 933 |
+
{
|
| 934 |
+
"datasource": {
|
| 935 |
+
"type": "prometheus",
|
| 936 |
+
"uid": "${DS_PROMETHEUS}"
|
| 937 |
+
},
|
| 938 |
+
"editorMode": "code",
|
| 939 |
+
"expr": "up",
|
| 940 |
+
"legendFormat": "{{job}}",
|
| 941 |
+
"range": true,
|
| 942 |
+
"refId": "A"
|
| 943 |
+
}
|
| 944 |
+
],
|
| 945 |
+
"title": "Prometheus Target Health",
|
| 946 |
+
"type": "bargauge"
|
| 947 |
+
}
|
| 948 |
+
],
|
| 949 |
+
"refresh": "30s",
|
| 950 |
+
"schemaVersion": 39,
|
| 951 |
+
"style": "dark",
|
| 952 |
+
"tags": [
|
| 953 |
+
"pdf-assistant-rag",
|
| 954 |
+
"fastapi",
|
| 955 |
+
"prometheus",
|
| 956 |
+
"llm",
|
| 957 |
+
"rag"
|
| 958 |
+
],
|
| 959 |
+
"templating": {
|
| 960 |
+
"list": [
|
| 961 |
+
{
|
| 962 |
+
"current": {
|
| 963 |
+
"selected": false,
|
| 964 |
+
"text": "Prometheus",
|
| 965 |
+
"value": "prometheus"
|
| 966 |
+
},
|
| 967 |
+
"hide": 0,
|
| 968 |
+
"includeAll": false,
|
| 969 |
+
"label": "Prometheus",
|
| 970 |
+
"multi": false,
|
| 971 |
+
"name": "DS_PROMETHEUS",
|
| 972 |
+
"options": [],
|
| 973 |
+
"query": "prometheus",
|
| 974 |
+
"refresh": 1,
|
| 975 |
+
"regex": "",
|
| 976 |
+
"type": "datasource"
|
| 977 |
+
},
|
| 978 |
+
{
|
| 979 |
+
"allValue": ".*",
|
| 980 |
+
"current": {
|
| 981 |
+
"selected": true,
|
| 982 |
+
"text": "All",
|
| 983 |
+
"value": "$__all"
|
| 984 |
+
},
|
| 985 |
+
"datasource": {
|
| 986 |
+
"type": "prometheus",
|
| 987 |
+
"uid": "${DS_PROMETHEUS}"
|
| 988 |
+
},
|
| 989 |
+
"definition": "label_values(http_requests_total, handler)",
|
| 990 |
+
"hide": 0,
|
| 991 |
+
"includeAll": true,
|
| 992 |
+
"label": "Route",
|
| 993 |
+
"multi": true,
|
| 994 |
+
"name": "handler",
|
| 995 |
+
"options": [],
|
| 996 |
+
"query": {
|
| 997 |
+
"query": "label_values(http_requests_total, handler)",
|
| 998 |
+
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
| 999 |
+
},
|
| 1000 |
+
"refresh": 2,
|
| 1001 |
+
"regex": "",
|
| 1002 |
+
"sort": 1,
|
| 1003 |
+
"type": "query"
|
| 1004 |
+
}
|
| 1005 |
+
]
|
| 1006 |
+
},
|
| 1007 |
+
"time": {
|
| 1008 |
+
"from": "now-6h",
|
| 1009 |
+
"to": "now"
|
| 1010 |
+
},
|
| 1011 |
+
"timepicker": {
|
| 1012 |
+
"refresh_intervals": [
|
| 1013 |
+
"10s",
|
| 1014 |
+
"30s",
|
| 1015 |
+
"1m",
|
| 1016 |
+
"5m",
|
| 1017 |
+
"15m"
|
| 1018 |
+
]
|
| 1019 |
+
},
|
| 1020 |
+
"timezone": "browser",
|
| 1021 |
+
"title": "PDF-Assistant-RAG System Monitoring",
|
| 1022 |
+
"uid": "pdf-assistant-rag-system-monitoring",
|
| 1023 |
+
"version": 1,
|
| 1024 |
+
"weekStart": ""
|
| 1025 |
+
}
|
package-lock.json
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "PDF-Assistant-RAG",
|
| 3 |
-
"lockfileVersion": 3,
|
| 4 |
-
"requires": true,
|
| 5 |
-
"packages": {}
|
| 6 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|