kishalll commited on
Commit
d22e308
·
2 Parent(s): 0418a93b1a5e35

Resolve merge conflict in dashboard/page.tsx wrt document rename and updated auth guard

Browse files
.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
- # → Full stack at http://localhost:7860
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 or API key."""
103
- token = credentials.credentials
 
 
 
 
 
 
 
 
 
 
 
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, Body, Depends, HTTPException, status
 
 
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 FastAPI BackgroundTasks.
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, BackgroundTasks, status, Query
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.rag.chunker import chunk_document, get_page_count
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', schedules a background task for chunking and embedding, and
309
- returns 202 Accepted immediately so large documents do not block the API
310
- request while embeddings are generated.
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
- # ── Trigger background ingestion ─────────────────
368
- background_tasks.add_task(
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
- # ── Trigger background ingestion ───────────────────────
454
- background_tasks.add_task(
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
- # Trigger background ingestion with updated chunk settings. The _ingest_document function will read the new chunk settings from the document record and re-chunk the document accordingly.
772
- background_tasks.add_task(
773
- _ingest_document,
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(Document.id == document_id).first()
 
 
 
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
- chunks = chunk_document(filepath)
 
 
 
 
 
 
 
 
 
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(Document.id == document_id).first()
 
 
 
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 BackgroundTasks, HTTPException, UploadFile
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.routes.documents import _ingest_document
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.routes.documents.get_page_count", lambda filepath: 1)
120
- monkeypatch.setattr("app.routes.documents.chunk_document", lambda filepath: chunks)
121
- monkeypatch.setattr("app.routes.documents.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,7 +136,7 @@ def test_ingest_document_builds_and_saves_graph(db_session, monkeypatch, tmp_pat
136
  ),
137
  )
138
 
139
- _ingest_document(
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=./data/uploads
38
- - CHROMA_PERSIST_DIR=./data/chroma_db
 
 
 
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 Worker as Background task
 
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-->>UI: 202 Accepted with document metadata
64
- API->>Worker: Queue ingestion task
 
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. The background task owns expensive work
74
- such as text extraction, chunking, embedding, graph building, and summary
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
- await route.fulfill({ json: user });
 
 
 
 
 
 
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.getByRole("button", { name: "Sign In" }).click();
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
- // Auth guard
 
89
  useEffect(() => {
90
- if (!loading && !user) router.replace("/login");
91
- }, [user, loading, router]);
92
 
93
- // Intercept dashboard if Hugging Face token configuration is missing
94
  useEffect(() => {
95
  if (user) {
96
- const existingHfToken = localStorage.getItem("hf_token");
97
 
98
- if (!existingHfToken) {
99
- console.warn("Hugging Face API configuration key missing.");
 
 
100
  }
101
  }
102
  }, [user]);
@@ -160,7 +163,7 @@ export default function DashboardPage() {
160
  return () => clearInterval(interval);
161
  }, [documents, loadDocuments]);
162
 
163
- if (loading || !user) {
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
- <DropdownMenuItem className="text-destructive cursor-pointer" onClick={handleLogout}>
 
 
 
 
 
 
 
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
- return await fetch(input, init);
 
 
 
 
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
- if (!storedToken) {
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>("/api/v1/auth/me", { token: storedToken });
117
- set({ user, token: storedToken, loading: false, initialized: true });
 
 
 
 
 
 
 
 
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
- }