mohhhhhit commited on
Commit
c2d2111
·
verified ·
1 Parent(s): e9fd5a6

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +2097 -0
app.py ADDED
@@ -0,0 +1,2097 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI Backend for NotebookPRO
3
+ Handles RAG, LLM, file processing, and chat management
4
+ """
5
+ from fastapi import FastAPI, File, UploadFile, HTTPException, Response
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import StreamingResponse
8
+ from pydantic import BaseModel
9
+ from typing import List, Optional, Dict, Any
10
+ from pathlib import Path
11
+ import json
12
+ from datetime import datetime
13
+ import uuid
14
+ import sys
15
+ import warnings
16
+ import logging
17
+ import os
18
+ import shutil
19
+ import tempfile
20
+ import zipfile
21
+ import re
22
+ import html
23
+ import time
24
+ from io import BytesIO
25
+ from fastapi.responses import FileResponse
26
+ from starlette.background import BackgroundTask
27
+ import weasyprint
28
+ import markdown
29
+ import latex2mathml.converter
30
+ import ziamath
31
+
32
+ # Suppress warnings
33
+ warnings.filterwarnings('ignore')
34
+ os.environ['PYTHONWARNINGS'] = 'ignore'
35
+ os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
36
+ os.environ['TOKENIZERS_PARALLELISM'] = 'false'
37
+ os.environ.setdefault('OMP_NUM_THREADS', '2')
38
+ os.environ.setdefault('MKL_NUM_THREADS', '2')
39
+ os.environ.setdefault('OPENBLAS_NUM_THREADS', '2')
40
+ os.environ.setdefault('NUMEXPR_NUM_THREADS', '2')
41
+ logging.getLogger().setLevel(logging.ERROR)
42
+
43
+ # Add project root to path
44
+ sys.path.append(str(Path(__file__).parent.parent))
45
+
46
+ import config
47
+ from utils.document_processor import DocumentProcessor
48
+ from utils.vector_db import VectorDatabase
49
+ from utils.hybrid_retriever import HybridRetriever
50
+ from utils.llm_generator import LLMGenerator
51
+ from utils.config_manager import ConfigManager
52
+ from utils.spaces_manager import SpacesManager
53
+ from utils.studio_manager import StudioManager
54
+ from utils.studio_generator import StudioGenerator
55
+ from models.studio_models import (
56
+ NotebookEntry,
57
+ NotebookEntryCreate,
58
+ NotebookEntryUpdate,
59
+ Flashcard,
60
+ FlashcardCreate,
61
+ FlashcardUpdate,
62
+ FlashcardReview,
63
+ FlashcardGenerateRequest,
64
+ MasteryLevel,
65
+ Quiz,
66
+ QuizCreate,
67
+ QuizGenerateRequest,
68
+ QuizSubmission,
69
+ QuizResult,
70
+ QuizHistory,
71
+ )
72
+
73
+ # Initialize FastAPI
74
+ app = FastAPI(title="NotebookPRO API", version="2.0.0")
75
+
76
+ # CORS - Allow Flutter web to connect
77
+ app.add_middleware(
78
+ CORSMiddleware,
79
+ allow_origins=["*"], # In production, specify your Flutter web URL
80
+ allow_credentials=True,
81
+ allow_methods=["*"],
82
+ allow_headers=["*"],
83
+ )
84
+
85
+ # Global instances
86
+ config_manager = ConfigManager()
87
+ spaces_manager = SpacesManager()
88
+ studio_manager = StudioManager()
89
+ studio_generator = None # Will be initialized after LLM
90
+ vector_db = None
91
+ hybrid_retriever = None
92
+ llm_generator = None
93
+ current_space = None
94
+
95
+ # ==================== Pydantic Models ====================
96
+
97
+ class ChatMessage(BaseModel):
98
+ role: str
99
+ content: str
100
+ timestamp: str
101
+ sources: Optional[List[Dict[str, Any]]] = None
102
+ suggested_follow_ups: Optional[List[str]] = None
103
+
104
+ class ChatRequest(BaseModel):
105
+ query: str
106
+ space_id: str
107
+ chat_id: Optional[str] = None
108
+ workflow: str = "chat"
109
+
110
+ class ChatResponse(BaseModel):
111
+ response: str
112
+ sources: List[Dict[str, Any]]
113
+ chat_id: str
114
+ timestamp: str
115
+ ui_notice: Optional[str] = None
116
+ suggested_follow_ups: Optional[List[str]] = None
117
+
118
+ class DeleteMessageRequest(BaseModel):
119
+ timestamp: str
120
+ role: str = "assistant"
121
+ content: Optional[str] = None
122
+ delete_related_user: bool = False
123
+
124
+ class SpaceCreate(BaseModel):
125
+ name: str
126
+
127
+ class SpaceResponse(BaseModel):
128
+ id: str
129
+ name: str
130
+ created_at: str
131
+ file_count: int
132
+
133
+ class ChatInfo(BaseModel):
134
+ id: str
135
+ title: str
136
+ preview: str
137
+ created_at: str
138
+ updated_at: str
139
+ message_count: int
140
+
141
+ class ConfigResponse(BaseModel):
142
+ gemini_api_key: Optional[str]
143
+ nvidia_api_key: Optional[str] = None
144
+ groq_api_key: Optional[str] = None
145
+
146
+ class ConfigUpdate(BaseModel):
147
+ gemini_api_key: Optional[str] = None
148
+ nvidia_api_key: Optional[str] = None
149
+ groq_api_key: Optional[str] = None
150
+
151
+ class ChatToNotebookRequest(BaseModel):
152
+ space_id: str
153
+ question: str
154
+ answer: str
155
+ chat_id: Optional[str] = None
156
+ assistant_timestamp: Optional[str] = None
157
+ tags: List[str] = []
158
+ space_name: Optional[str] = None
159
+
160
+ class ExportAnswerPdfRequest(BaseModel):
161
+ space_id: str
162
+ question: str
163
+ answer: str
164
+ title: Optional[str] = None
165
+
166
+ class QAItem(BaseModel):
167
+ question: str
168
+ answer: str
169
+
170
+ class ExportCombinedAnswersPdfRequest(BaseModel):
171
+ space_id: str
172
+ chat_title: Optional[str] = None
173
+ items: List[QAItem]
174
+
175
+
176
+ class ExportSelectedNotebookPdfRequest(BaseModel):
177
+ space_id: str
178
+ entry_ids: List[str]
179
+ title: Optional[str] = None
180
+
181
+ # ==================== Helper Functions ====================
182
+
183
+ def get_data_dir():
184
+ """Get data directory path"""
185
+ return Path(config.DATA_DIR)
186
+
187
+ def get_space_dir(space_id: str):
188
+ """Get space-specific directory"""
189
+ return get_data_dir() / "spaces" / space_id
190
+
191
+ def load_chats_for_space(space_id: str) -> List[Dict]:
192
+ """Load all chats for a space"""
193
+ chats_file = get_space_dir(space_id) / "chats.json"
194
+ if chats_file.exists():
195
+ with open(chats_file, 'r', encoding='utf-8') as f:
196
+ return json.load(f)
197
+ return []
198
+
199
+ def save_chats_for_space(space_id: str, chats: List[Dict]):
200
+ """Save chats for a space"""
201
+ chats_file = get_space_dir(space_id) / "chats.json"
202
+ chats_file.parent.mkdir(parents=True, exist_ok=True)
203
+ with open(chats_file, 'w', encoding='utf-8') as f:
204
+ json.dump(chats, f, indent=2, ensure_ascii=False)
205
+
206
+ def get_chat_title(messages: List[Dict]) -> str:
207
+ """Generate chat title from first user message"""
208
+ for msg in messages:
209
+ if msg['role'] == 'user':
210
+ content = msg['content'][:50]
211
+ return content + "..." if len(msg['content']) > 50 else content
212
+ return "New Chat"
213
+
214
+
215
+ def _is_nlp_metrics_query(query: str) -> bool:
216
+ """Detect metric-heavy NLP evaluation questions that need broader retrieval."""
217
+ q = (query or "").lower()
218
+ triggers = (
219
+ "bleu",
220
+ "rouge",
221
+ "meteor",
222
+ "bertscore",
223
+ "sequence generation",
224
+ "machine translation",
225
+ "summarization",
226
+ "n-gram",
227
+ "brevity penalty",
228
+ "evaluation metric",
229
+ "nlp metric",
230
+ "transformer",
231
+ )
232
+ return any(t in q for t in triggers)
233
+
234
+
235
+ def _extract_year_from_filename(filename: str) -> Optional[int]:
236
+ """Infer publication year from filename (best-effort)."""
237
+ if not filename:
238
+ return None
239
+
240
+ matches = re.findall(r'(?:19|20)\d{2}', filename)
241
+ if not matches:
242
+ return None
243
+
244
+ try:
245
+ years = [int(y) for y in matches]
246
+ valid_years = [y for y in years if 1900 <= y <= 2100]
247
+ if not valid_years:
248
+ return None
249
+ return max(valid_years)
250
+ except Exception:
251
+ return None
252
+
253
+
254
+ def _resolve_publication_year(meta: Optional[Dict[str, Any]]) -> Optional[int]:
255
+ """Resolve publication year from metadata; fallback to filename inference."""
256
+ if not meta:
257
+ return None
258
+
259
+ raw_year = meta.get('publication_year')
260
+ if raw_year is not None:
261
+ try:
262
+ year = int(raw_year)
263
+ if 1900 <= year <= 2100:
264
+ return year
265
+ except Exception:
266
+ pass
267
+
268
+ filename = str(meta.get('filename', ''))
269
+ return _extract_year_from_filename(filename)
270
+
271
+
272
+ def _build_retrieval_queries(query: str) -> List[str]:
273
+ """Build retrieval query set; add targeted expansions for NLP metric topics."""
274
+ queries = [query]
275
+ if not _is_nlp_metrics_query(query):
276
+ return queries
277
+
278
+ queries.extend([
279
+ f"{query} BLEU brevity penalty BP n-gram precision",
280
+ f"{query} ROUGE-N ROUGE-L longest common subsequence LCS F1 recall precision",
281
+ f"{query} METEOR stemming synonym WordNet exact-match limitation",
282
+ f"{query} BERTScore contextual embeddings semantic similarity deep learning transformer",
283
+ ])
284
+ return queries
285
+
286
+
287
+ def _retrieve_documents_for_query(
288
+ retriever: HybridRetriever,
289
+ query: str,
290
+ n_results: int,
291
+ score_threshold: float,
292
+ ):
293
+ """Retrieve with optional query expansions and merge by max score."""
294
+ retrieval_queries = _build_retrieval_queries(query)
295
+ per_query_n = max(12, n_results // 2) if len(retrieval_queries) > 1 else n_results
296
+ per_query_threshold = max(0.0, score_threshold - 0.01) if len(retrieval_queries) > 1 else score_threshold
297
+
298
+ merged = {}
299
+ for q in retrieval_queries:
300
+ docs, metas, scores = retriever.retrieve(
301
+ query=q,
302
+ n_results=per_query_n,
303
+ score_threshold=per_query_threshold,
304
+ )
305
+ for doc, meta, score in zip(docs, metas, scores):
306
+ current = merged.get(doc)
307
+ score_value = float(score)
308
+ if current is None or score_value > current["score"]:
309
+ merged[doc] = {
310
+ "doc": doc,
311
+ "meta": meta,
312
+ "score": score_value,
313
+ }
314
+
315
+ ranked = sorted(merged.values(), key=lambda item: item["score"], reverse=True)
316
+ selected = ranked[:n_results]
317
+ return (
318
+ [item["doc"] for item in selected],
319
+ [item["meta"] for item in selected],
320
+ [item["score"] for item in selected],
321
+ )
322
+
323
+
324
+ def _sanitize_retrieved_chunk(text: str) -> str:
325
+ """Clean retrieval chunks to remove OCR/table-of-contents numeric noise."""
326
+ if not text:
327
+ return ""
328
+
329
+ cleaned_lines: List[str] = []
330
+ for raw_line in text.splitlines():
331
+ line = raw_line.strip()
332
+ if not line:
333
+ cleaned_lines.append("")
334
+ continue
335
+
336
+ # Drop pathological section-number runs like: 1.1.1.1.1.1...
337
+ if re.search(r'(?:\d+\.){8,}\d*', line):
338
+ continue
339
+
340
+ compact = re.sub(r'\s+', '', line)
341
+ # Drop long punctuation/digit-only lines that come from OCR artifacts.
342
+ if len(compact) >= 40 and re.fullmatch(r'[\d\W_]+', compact):
343
+ continue
344
+
345
+ line = re.sub(r'(?:\d+\.){8,}\d*', ' ', line)
346
+ line = re.sub(r'\s{2,}', ' ', line).strip()
347
+ if line:
348
+ cleaned_lines.append(line)
349
+
350
+ cleaned = "\n".join(cleaned_lines)
351
+ cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
352
+ return cleaned
353
+
354
+
355
+ def _is_usable_context_chunk(text: str) -> bool:
356
+ """Reject chunks that are mostly numeric garbage after cleanup."""
357
+ if not text or len(text) < 80:
358
+ return False
359
+
360
+ letters = len(re.findall(r'[A-Za-z]', text))
361
+ digits = len(re.findall(r'\d', text))
362
+
363
+ if letters < 35:
364
+ return False
365
+
366
+ # If a chunk is heavily numeric and has little natural language, skip it.
367
+ if digits > 0 and letters < max(80, int(digits * 0.35)):
368
+ return False
369
+
370
+ return True
371
+
372
+
373
+ def _format_sse_event(event: str, payload: Dict[str, Any]) -> str:
374
+ """Format one Server-Sent Event frame."""
375
+ return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
376
+
377
+ def ensure_notebooks_for_existing_spaces() -> int:
378
+ """Ensure every existing space has an associated notebook metadata record."""
379
+ created_count = 0
380
+ spaces = spaces_manager.get_all_spaces()
381
+
382
+ for space in spaces:
383
+ space_id = space.get('id')
384
+ if not space_id:
385
+ continue
386
+
387
+ existing_notebook = studio_manager.get_space_notebook(space_id)
388
+ if existing_notebook:
389
+ continue
390
+
391
+ studio_manager.ensure_space_notebook(space_id, space.get('name', space_id))
392
+ created_count += 1
393
+
394
+ return created_count
395
+
396
+ def rebuild_space_index_if_missing(space_id: str, force: bool = False) -> int:
397
+ """Rebuild a space index from uploaded files if the current index is empty."""
398
+ if not vector_db:
399
+ return 0
400
+
401
+ if not force:
402
+ try:
403
+ existing_count = vector_db.get_collection_count()
404
+ if existing_count > 0:
405
+ print(f"[INDEX_REBUILD {space_id}] skip existing_vectors={existing_count}")
406
+ return 0
407
+ except Exception:
408
+ # If count check fails, continue with a best-effort rebuild.
409
+ pass
410
+
411
+ uploads_dir = get_space_dir(space_id) / "uploads"
412
+ if not uploads_dir.exists():
413
+ return 0
414
+
415
+ files = [
416
+ p for p in uploads_dir.iterdir()
417
+ if p.is_file() and p.suffix.lower() in {".pdf", ".docx", ".txt"}
418
+ ]
419
+ if not files:
420
+ print(f"[INDEX_REBUILD {space_id}] skip no_supported_uploads")
421
+ return 0
422
+
423
+ print(f"[INDEX_REBUILD {space_id}] start files={len(files)}")
424
+
425
+ processor = DocumentProcessor()
426
+ texts: List[str] = []
427
+ metadatas: List[Dict[str, Any]] = []
428
+ ids: List[str] = []
429
+
430
+ for file_path in files:
431
+ try:
432
+ print(f"[INDEX_REBUILD {space_id}] processing_file={file_path.name}")
433
+ file_data = processor.process_file(file_path)
434
+ chunk_filename = file_data.get('virtual_filename', file_path.name)
435
+ chunks = processor.chunk_text(
436
+ file_data['content'],
437
+ chunk_size=config.CHUNK_SIZE,
438
+ overlap=config.CHUNK_OVERLAP,
439
+ semantic=True,
440
+ source_filename=chunk_filename,
441
+ )
442
+ total_chunks = len(chunks)
443
+ print(f"[INDEX_REBUILD {space_id}] file_chunks={total_chunks} file={chunk_filename}")
444
+ publication_year = file_data.get('publication_year', '0000')
445
+ for idx, chunk in enumerate(chunks):
446
+ texts.append(chunk)
447
+ metadatas.append({
448
+ 'filename': chunk_filename,
449
+ 'chunk_index': idx,
450
+ 'total_chunks': total_chunks,
451
+ 'source_type': file_data['format'],
452
+ 'publication_year': publication_year,
453
+ })
454
+ ids.append(f"{space_id}_rebuild_{len(ids)}_{uuid.uuid4().hex[:8]}")
455
+ except Exception as e:
456
+ print(f"Index rebuild skipped {file_path.name}: {e}")
457
+
458
+ if not texts:
459
+ print(f"[INDEX_REBUILD {space_id}] skip no_chunks_generated")
460
+ return 0
461
+
462
+ batch_size = 5000
463
+ for i in range(0, len(texts), batch_size):
464
+ end = min(i + batch_size, len(texts))
465
+ print(f"[INDEX_REBUILD {space_id}] batch_add start={i} end={end}")
466
+ vector_db.add_documents(
467
+ texts[i:i + batch_size],
468
+ metadatas[i:i + batch_size],
469
+ ids[i:i + batch_size],
470
+ )
471
+
472
+ print(f"[INDEX_REBUILD {space_id}] done chunks={len(texts)}")
473
+ return len(texts)
474
+
475
+ def initialize_space(space_id: str):
476
+ """Initialize vector DB and components for a space"""
477
+ global vector_db, hybrid_retriever, llm_generator, studio_generator, current_space
478
+
479
+ # Fast path: reuse already initialized components for the active space.
480
+ if current_space == space_id and vector_db is not None and llm_generator is not None:
481
+ return
482
+
483
+ # On space switch, release references and clear caches to reduce stale VRAM pressure.
484
+ switching_space = current_space is not None and current_space != space_id
485
+ if switching_space:
486
+ vector_db = None
487
+ hybrid_retriever = None
488
+ llm_generator = None
489
+ studio_generator = None
490
+ if config.SPACE_SWITCH_CLEAR_CUDA_CACHE:
491
+ VectorDatabase.clear_runtime_caches(
492
+ unload_embedding_model=config.SPACE_SWITCH_UNLOAD_EMBEDDING_MODEL,
493
+ )
494
+
495
+ # Get API keys for Gemini answers and NVIDIA follow-up questions.
496
+ gemini_key = config_manager.get_api_key('gemini')
497
+ nvidia_key = config_manager.get_api_key('nvidia')
498
+ groq_key = config_manager.get_api_key('groq')
499
+
500
+ if not gemini_key and not nvidia_key and not groq_key:
501
+ raise HTTPException(
502
+ status_code=400,
503
+ detail="No API key configured. Please add a Gemini or NVIDIA API key.",
504
+ )
505
+
506
+ # Initialize vector database for this space (space-local persistence path).
507
+ space_dir = get_space_dir(space_id)
508
+ vector_db = VectorDatabase(
509
+ collection_name=f"space_{space_id}",
510
+ persist_directory=str(space_dir / "vector_db"),
511
+ )
512
+
513
+ collection_reset = vector_db.ensure_collection_embedding_compatibility()
514
+ if collection_reset:
515
+ print(f"[INDEX_REBUILD {space_id}] reset_due_to_embedding_dimension_change=1")
516
+
517
+ hybrid_retriever = HybridRetriever(vector_db, alpha=0.6)
518
+
519
+ # Backward-compatibility: rebuild embeddings from uploaded files if index is empty.
520
+ rebuild_space_index_if_missing(space_id)
521
+
522
+ # Initialize LLM generator (Gemini for answers, NVIDIA for follow-ups).
523
+ llm_generator = LLMGenerator(
524
+ provider="gemini",
525
+ gemini_api_key=gemini_key,
526
+ nvidia_api_key=nvidia_key,
527
+ groq_api_key=groq_key,
528
+ )
529
+
530
+ # Initialize studio generator with LLM
531
+ studio_generator = StudioGenerator(llm_generator, studio_manager)
532
+ current_space = space_id
533
+
534
+ @app.on_event("startup")
535
+ async def startup_sync_notebooks():
536
+ """Auto-create missing notebooks for pre-existing spaces when backend starts and preload GPU models."""
537
+ try:
538
+ from utils.vector_db import VectorDatabase
539
+ print("Preloading embedding and reranker models to GPU...")
540
+ VectorDatabase._get_or_create_embedding_model()
541
+ VectorDatabase._get_or_create_reranker()
542
+ print("Models successfully preloaded to GPU.")
543
+ except Exception as e:
544
+ print(f"Failed to preload models: {e}")
545
+
546
+ try:
547
+ created = ensure_notebooks_for_existing_spaces()
548
+ if created > 0:
549
+ print(f"Created {created} missing notebook(s) for existing spaces")
550
+ except Exception as e:
551
+ # Keep server startup resilient even if sync fails.
552
+ print(f"Notebook startup sync failed: {e}")
553
+
554
+ # ==================== API Endpoints ====================
555
+
556
+ @app.get("/")
557
+ async def root():
558
+ """Health check"""
559
+ return {"status": "NotebookPRO API is running", "version": "2.0.0"}
560
+
561
+ @app.get("/api/health")
562
+ async def health_check():
563
+ """Fast health endpoint used by the Flutter app connection checks."""
564
+ return {"status": "ok"}
565
+
566
+ @app.get("/api/config", response_model=ConfigResponse)
567
+ async def get_config():
568
+ """Get current API keys (masked)"""
569
+ gemini_key = config_manager.get_api_key('gemini')
570
+ nvidia_key = config_manager.get_api_key('nvidia')
571
+ groq_key = config_manager.get_api_key('groq')
572
+ masked_gemini_key = "***" + gemini_key[-4:] if gemini_key else None
573
+ masked_nvidia_key = "***" + nvidia_key[-4:] if nvidia_key else None
574
+ masked_groq_key = "***" + groq_key[-4:] if groq_key else None
575
+
576
+ return ConfigResponse(
577
+ gemini_api_key=masked_gemini_key,
578
+ nvidia_api_key=masked_nvidia_key,
579
+ groq_api_key=masked_groq_key,
580
+ )
581
+
582
+ @app.get("/api/system/export")
583
+ async def export_data():
584
+ """Export the entire data directory as a ZIP file."""
585
+ try:
586
+ tmp_dir = Path(tempfile.gettempdir())
587
+ zip_base_path = tmp_dir / f"notebookpro_backup_{int(time.time())}"
588
+
589
+ # This creates notebookpro_backup_xxx.zip
590
+ shutil.make_archive(
591
+ base_name=str(zip_base_path),
592
+ format="zip",
593
+ root_dir=str(get_data_dir())
594
+ )
595
+
596
+ zip_path = str(zip_base_path) + ".zip"
597
+
598
+ return FileResponse(
599
+ path=zip_path,
600
+ filename="notebookpro_backup.zip",
601
+ media_type="application/zip",
602
+ background=BackgroundTask(lambda: os.remove(zip_path))
603
+ )
604
+ except Exception as e:
605
+ raise HTTPException(status_code=500, detail=f"Export failed: {e}")
606
+
607
+ @app.post("/api/system/import")
608
+ async def import_data(file: UploadFile = File(...)):
609
+ """Import a ZIP file and overwrite the data directory."""
610
+ if not file.filename.endswith('.zip'):
611
+ raise HTTPException(status_code=400, detail="Must be a ZIP file")
612
+
613
+ try:
614
+ tmp_dir = Path(tempfile.gettempdir())
615
+ zip_path = tmp_dir / f"notebookpro_import_{int(time.time())}.zip"
616
+
617
+ with open(zip_path, "wb") as buffer:
618
+ shutil.copyfileobj(file.file, buffer)
619
+
620
+ # Verify zip structure minimally
621
+ with zipfile.ZipFile(zip_path, 'r') as zip_ref:
622
+ # For a basic check, we don't strict-enforce specific files in case of empty states
623
+ pass
624
+
625
+ extract_dir = tmp_dir / f"notebookpro_import_extracted_{int(time.time())}"
626
+ if extract_dir.exists():
627
+ shutil.rmtree(extract_dir)
628
+ extract_dir.mkdir(parents=True)
629
+
630
+ with zipfile.ZipFile(zip_path, 'r') as zip_ref:
631
+ zip_ref.extractall(extract_dir)
632
+
633
+ # Re-initialize managers to close DB connections if any?
634
+ # Best approach: replace data and ask user to restart.
635
+
636
+ # Replace data directory
637
+ data_dir = get_data_dir()
638
+ for item in extract_dir.iterdir():
639
+ target = data_dir / item.name
640
+ if target.exists():
641
+ if target.is_dir():
642
+ shutil.rmtree(target)
643
+ else:
644
+ target.unlink()
645
+ shutil.move(str(item), str(target))
646
+
647
+ # Clean up
648
+ os.remove(zip_path)
649
+ shutil.rmtree(extract_dir)
650
+
651
+ return {"status": "success", "message": "Data imported successfully. Please restart the backend."}
652
+
653
+ except Exception as e:
654
+ raise HTTPException(status_code=500, detail=f"Import failed: {e}")
655
+
656
+
657
+ @app.post("/api/config")
658
+ async def update_config(config_update: ConfigUpdate):
659
+ """Update API keys"""
660
+ if config_update.gemini_api_key:
661
+ config_manager.set_api_key('gemini', config_update.gemini_api_key)
662
+
663
+ if config_update.nvidia_api_key:
664
+ config_manager.set_api_key('nvidia', config_update.nvidia_api_key)
665
+
666
+ if config_update.groq_api_key:
667
+ config_manager.set_api_key('groq', config_update.groq_api_key)
668
+
669
+ return {"status": "success", "message": "Configuration updated"}
670
+
671
+ @app.get("/api/spaces", response_model=List[SpaceResponse])
672
+ async def get_spaces():
673
+ """Get all spaces"""
674
+ # Self-healing check in case spaces were created externally while server is running.
675
+ ensure_notebooks_for_existing_spaces()
676
+ spaces = spaces_manager.get_all_spaces()
677
+
678
+ result = []
679
+ for space in spaces:
680
+ space_id = space['id']
681
+ space_dir = get_space_dir(space_id)
682
+ processed_file = space_dir / "processed_files.json"
683
+
684
+ file_count = 0
685
+ if processed_file.exists():
686
+ with open(processed_file, 'r', encoding='utf-8-sig') as f:
687
+ file_count = len(json.load(f))
688
+
689
+ result.append(SpaceResponse(
690
+ id=space_id,
691
+ name=space['name'],
692
+ created_at=space['created_at'],
693
+ file_count=file_count
694
+ ))
695
+
696
+ return result
697
+
698
+ @app.post("/api/spaces", response_model=SpaceResponse)
699
+ async def create_space(space_data: SpaceCreate):
700
+ """Create a new space"""
701
+ try:
702
+ space = spaces_manager.create_space(space_data.name)
703
+
704
+ # Create associated notebook metadata with the same name as the space.
705
+ studio_manager.ensure_space_notebook(space['id'], space['name'])
706
+
707
+ return SpaceResponse(
708
+ id=space['id'],
709
+ name=space['name'],
710
+ created_at=space['created_at'],
711
+ file_count=0
712
+ )
713
+ except ValueError as e:
714
+ raise HTTPException(status_code=400, detail=str(e))
715
+
716
+ @app.delete("/api/spaces/{space_id}")
717
+ async def delete_space(space_id: str):
718
+ """Delete a space"""
719
+ try:
720
+ spaces_manager.delete_space(space_id)
721
+
722
+ # Delete space directory
723
+ space_dir = get_space_dir(space_id)
724
+ if space_dir.exists():
725
+ shutil.rmtree(space_dir)
726
+
727
+ return {"status": "success", "message": f"Space {space_id} deleted"}
728
+ except ValueError as e:
729
+ raise HTTPException(status_code=400, detail=str(e))
730
+ except Exception as e:
731
+ raise HTTPException(status_code=500, detail=f"Error deleting space: {str(e)}")
732
+
733
+ @app.get("/api/spaces/{space_id}/chats", response_model=List[ChatInfo])
734
+ async def get_chats(space_id: str):
735
+ """Get all chats for a space"""
736
+ chats = load_chats_for_space(space_id)
737
+
738
+ result = []
739
+ for chat in chats:
740
+ messages = chat.get('messages', [])
741
+ result.append(ChatInfo(
742
+ id=chat['id'],
743
+ title=get_chat_title(messages),
744
+ preview=messages[0]['content'][:100] if messages else "",
745
+ created_at=chat.get('created_at', ''),
746
+ updated_at=chat.get('updated_at', ''),
747
+ message_count=len(messages)
748
+ ))
749
+
750
+ return result
751
+
752
+ @app.get("/api/spaces/{space_id}/chats/{chat_id}")
753
+ async def get_chat(space_id: str, chat_id: str):
754
+ """Get specific chat by ID"""
755
+ chats = load_chats_for_space(space_id)
756
+
757
+ for chat in chats:
758
+ if chat['id'] == chat_id:
759
+ return chat
760
+
761
+ raise HTTPException(status_code=404, detail="Chat not found")
762
+
763
+ @app.delete("/api/spaces/{space_id}/chats/{chat_id}")
764
+ async def delete_chat(space_id: str, chat_id: str):
765
+ """Delete a chat"""
766
+ chats = load_chats_for_space(space_id)
767
+ chats = [c for c in chats if c['id'] != chat_id]
768
+ save_chats_for_space(space_id, chats)
769
+
770
+ return {"status": "success", "message": f"Chat {chat_id} deleted"}
771
+
772
+ @app.post("/api/spaces/{space_id}/chats/{chat_id}/messages/delete")
773
+ async def delete_chat_message(space_id: str, chat_id: str, request: DeleteMessageRequest):
774
+ """Delete a specific message from a chat.
775
+
776
+ Optionally deletes the nearest preceding user message as a Q/A pair.
777
+ """
778
+ chats = load_chats_for_space(space_id)
779
+
780
+ chat = next((c for c in chats if c.get('id') == chat_id), None)
781
+ if chat is None:
782
+ raise HTTPException(status_code=404, detail="Chat not found")
783
+
784
+ messages = chat.get('messages', [])
785
+ removed = False
786
+ target_index = -1
787
+
788
+ # Primary match: role + timestamp (+ optional exact content)
789
+ for idx, msg in enumerate(messages):
790
+ if msg.get('role') != request.role:
791
+ continue
792
+ if msg.get('timestamp') != request.timestamp:
793
+ continue
794
+ if request.content is not None and msg.get('content') != request.content:
795
+ continue
796
+
797
+ target_index = idx
798
+ removed = True
799
+ break
800
+
801
+ # Fallback: role + exact content (handles rare timestamp drift)
802
+ if not removed and request.content is not None:
803
+ for idx in range(len(messages) - 1, -1, -1):
804
+ msg = messages[idx]
805
+ if msg.get('role') == request.role and msg.get('content') == request.content:
806
+ target_index = idx
807
+ removed = True
808
+ break
809
+
810
+ if not removed:
811
+ raise HTTPException(status_code=404, detail="Message not found")
812
+
813
+ indexes_to_remove = {target_index}
814
+
815
+ if request.delete_related_user and target_index > 0:
816
+ for idx in range(target_index - 1, -1, -1):
817
+ if messages[idx].get('role') == 'user':
818
+ indexes_to_remove.add(idx)
819
+ break
820
+
821
+ for idx in sorted(indexes_to_remove, reverse=True):
822
+ if 0 <= idx < len(messages):
823
+ del messages[idx]
824
+
825
+ chat['messages'] = messages
826
+ chat['updated_at'] = datetime.now().isoformat()
827
+ save_chats_for_space(space_id, chats)
828
+
829
+ return {
830
+ "status": "success",
831
+ "message": "Message deleted",
832
+ "remaining_messages": len(messages),
833
+ }
834
+
835
+ @app.post("/api/chat", response_model=ChatResponse)
836
+ async def chat(request: ChatRequest):
837
+ """Process a chat message with RAG"""
838
+ try:
839
+ trace_id = uuid.uuid4().hex[:8]
840
+ t_start = time.perf_counter()
841
+ print(
842
+ f"[CHAT_TIMING {trace_id}] start space={request.space_id} workflow={request.workflow} "
843
+ f"query_len={len((request.query or '').strip())}"
844
+ )
845
+
846
+ # Initialize space if needed
847
+ t_init = time.perf_counter()
848
+ initialize_space(request.space_id)
849
+ print(f"[CHAT_TIMING {trace_id}] init_space_ms={(time.perf_counter() - t_init) * 1000:.1f}")
850
+
851
+ # Reuse per-space retriever to avoid rebuilding BM25 index every request.
852
+ global hybrid_retriever
853
+ if hybrid_retriever is None:
854
+ hybrid_retriever = HybridRetriever(vector_db, alpha=0.6)
855
+ print(f"[CHAT_TIMING {trace_id}] retriever_cache=MISS")
856
+ else:
857
+ print(f"[CHAT_TIMING {trace_id}] retriever_cache=HIT")
858
+
859
+ retrieval_n = int(os.getenv("CHAT_RETRIEVAL_RESULTS", "16"))
860
+ retrieval_n = max(8, min(retrieval_n, 30))
861
+ retrieval_threshold = float(os.getenv("CHAT_RETRIEVAL_THRESHOLD", "0.01"))
862
+ chat_max_tokens = int(os.getenv("CHAT_MAX_TOKENS", "1800"))
863
+ chat_max_tokens = max(700, min(chat_max_tokens, 2600))
864
+
865
+ # Retrieve relevant documents (with targeted query expansion for metric-heavy NLP prompts).
866
+ t_retrieve = time.perf_counter()
867
+ documents, metadatas, scores = _retrieve_documents_for_query(
868
+ retriever=hybrid_retriever,
869
+ query=request.query,
870
+ n_results=retrieval_n,
871
+ score_threshold=retrieval_threshold,
872
+ )
873
+ print(
874
+ f"[CHAT_TIMING {trace_id}] retrieval_ms={(time.perf_counter() - t_retrieve) * 1000:.1f} "
875
+ f"docs={len(documents)} query_expansions={len(_build_retrieval_queries(request.query))}"
876
+ )
877
+
878
+ # Build context from retrieved documents
879
+ t_context = time.perf_counter()
880
+ context_parts = []
881
+ sources = []
882
+ filtered_metadatas = []
883
+ dropped_chunks = 0
884
+ seen_context_fingerprints = set()
885
+
886
+ for doc, meta, score in zip(documents, metadatas, scores):
887
+ cleaned_doc = _sanitize_retrieved_chunk(doc)
888
+ if not _is_usable_context_chunk(cleaned_doc):
889
+ dropped_chunks += 1
890
+ continue
891
+
892
+ fingerprint = re.sub(r'\s+', ' ', cleaned_doc[:320]).lower()
893
+ if fingerprint in seen_context_fingerprints:
894
+ dropped_chunks += 1
895
+ continue
896
+ seen_context_fingerprints.add(fingerprint)
897
+
898
+ # Extract clean filename for source citation
899
+ filename = meta.get('filename', 'Unknown')
900
+ clean_name = filename.replace('.pdf', '').replace('.docx', '').replace('.txt', '')
901
+ publication_year = _resolve_publication_year(meta)
902
+ year_label = str(publication_year) if publication_year else "UNKNOWN"
903
+ source_idx = len(context_parts) + 1
904
+ context_parts.append(f"Source [{source_idx}] [YEAR: {year_label}] ({clean_name}):\n{cleaned_doc}\n")
905
+ filtered_metadatas.append(meta)
906
+ sources.append({
907
+ "content": cleaned_doc[:200] + "..." if len(cleaned_doc) > 200 else cleaned_doc,
908
+ "metadata": meta,
909
+ "score": float(score)
910
+ })
911
+
912
+ print(
913
+ f"[CHAT_TIMING {trace_id}] context_filter kept={len(context_parts)} dropped={dropped_chunks}"
914
+ )
915
+
916
+ if not context_parts:
917
+ return ChatResponse(
918
+ response=(
919
+ "I found related retrieval hits, but they were too noisy to trust for a grounded answer. "
920
+ "Please re-upload a cleaner source file (or a text-based PDF) and try again."
921
+ ),
922
+ sources=[],
923
+ chat_id=request.chat_id or str(uuid.uuid4()),
924
+ timestamp=datetime.now().isoformat(),
925
+ ui_notice="Context chunks were filtered due to low text quality.",
926
+ suggested_follow_ups=[],
927
+ )
928
+
929
+ context = "\n".join(context_parts)
930
+ print(
931
+ f"[CHAT_TIMING {trace_id}] context_ms={(time.perf_counter() - t_context) * 1000:.1f} "
932
+ f"context_chars={len(context)}"
933
+ )
934
+
935
+ # Use the advanced generate_response method which has the new NotebookLM-style prompt
936
+ t_llm = time.perf_counter()
937
+ response = llm_generator.generate_response(
938
+ prompt=request.query,
939
+ context=context,
940
+ use_case=request.workflow if request.workflow in ["summary", "explanation", "qa", "notes"] else "qa",
941
+ metadatas=filtered_metadatas,
942
+ temperature=0.35,
943
+ max_tokens=chat_max_tokens,
944
+ )
945
+ print(
946
+ f"[CHAT_TIMING {trace_id}] llm_ms={(time.perf_counter() - t_llm) * 1000:.1f} "
947
+ f"response_chars={len(response or '')}"
948
+ )
949
+
950
+ t_follow = time.perf_counter()
951
+ suggested_follow_ups = llm_generator.generate_follow_up_questions(
952
+ prompt=request.query,
953
+ context=context,
954
+ answer=response,
955
+ max_questions=3,
956
+ )
957
+ print(
958
+ f"[CHAT_TIMING {trace_id}] followups_ms={(time.perf_counter() - t_follow) * 1000:.1f} "
959
+ f"count={len(suggested_follow_ups)}"
960
+ )
961
+ ui_notice = getattr(llm_generator, 'last_notice', None)
962
+
963
+ # Create or update chat
964
+ chat_id = request.chat_id or str(uuid.uuid4())
965
+ chats = load_chats_for_space(request.space_id)
966
+
967
+ # Find existing chat or create new
968
+ chat = None
969
+ for c in chats:
970
+ if c['id'] == chat_id:
971
+ chat = c
972
+ break
973
+
974
+ if not chat:
975
+ chat = {
976
+ 'id': chat_id,
977
+ 'messages': [],
978
+ 'created_at': datetime.now().isoformat(),
979
+ 'updated_at': datetime.now().isoformat()
980
+ }
981
+ chats.append(chat)
982
+
983
+ # Add messages
984
+ timestamp = datetime.now().isoformat()
985
+ chat['messages'].extend([
986
+ {'role': 'user', 'content': request.query, 'timestamp': timestamp},
987
+ {
988
+ 'role': 'assistant',
989
+ 'content': response,
990
+ 'timestamp': timestamp,
991
+ 'sources': sources,
992
+ 'suggested_follow_ups': suggested_follow_ups,
993
+ }
994
+ ])
995
+ chat['updated_at'] = timestamp
996
+
997
+ # Save chats
998
+ t_save = time.perf_counter()
999
+ save_chats_for_space(request.space_id, chats)
1000
+ print(f"[CHAT_TIMING {trace_id}] save_ms={(time.perf_counter() - t_save) * 1000:.1f}")
1001
+ print(f"[CHAT_TIMING {trace_id}] total_ms={(time.perf_counter() - t_start) * 1000:.1f}")
1002
+
1003
+ return ChatResponse(
1004
+ response=response,
1005
+ sources=sources,
1006
+ chat_id=chat_id,
1007
+ timestamp=timestamp,
1008
+ ui_notice=ui_notice,
1009
+ suggested_follow_ups=suggested_follow_ups,
1010
+ )
1011
+
1012
+ except HTTPException:
1013
+ raise
1014
+ except Exception as e:
1015
+ raise HTTPException(status_code=500, detail=str(e))
1016
+
1017
+
1018
+ @app.post("/api/chat/stream")
1019
+ async def chat_stream(request: ChatRequest):
1020
+ """SSE streaming compatibility endpoint for the Flutter client."""
1021
+ trace_id = "unknown"
1022
+ t_stream = time.perf_counter()
1023
+ try:
1024
+ trace_id = uuid.uuid4().hex[:8]
1025
+ t_stream = time.perf_counter()
1026
+ print(f"[CHAT_STREAM {trace_id}] start space={request.space_id}")
1027
+
1028
+ def event_stream():
1029
+ try:
1030
+ # 1. Initialization & Retrieval
1031
+ initialize_space(request.space_id)
1032
+ global hybrid_retriever
1033
+ if hybrid_retriever is None:
1034
+ hybrid_retriever = HybridRetriever(vector_db, alpha=0.6)
1035
+
1036
+ retrieval_n = int(os.getenv("CHAT_RETRIEVAL_RESULTS", "16"))
1037
+ retrieval_n = max(8, min(retrieval_n, 30))
1038
+ retrieval_threshold = float(os.getenv("CHAT_RETRIEVAL_THRESHOLD", "0.01"))
1039
+ chat_max_tokens = int(os.getenv("CHAT_MAX_TOKENS", "1800"))
1040
+ chat_max_tokens = max(700, min(chat_max_tokens, 2600))
1041
+
1042
+ documents, metadatas, scores = _retrieve_documents_for_query(
1043
+ retriever=hybrid_retriever,
1044
+ query=request.query,
1045
+ n_results=retrieval_n,
1046
+ score_threshold=retrieval_threshold,
1047
+ )
1048
+
1049
+ # 2. Context Building
1050
+ context_parts = []
1051
+ sources = []
1052
+ filtered_metadatas = []
1053
+ seen_context_fingerprints = set()
1054
+
1055
+ for doc, meta, score in zip(documents, metadatas, scores):
1056
+ cleaned_doc = _sanitize_retrieved_chunk(doc)
1057
+ if not _is_usable_context_chunk(cleaned_doc):
1058
+ continue
1059
+
1060
+ fingerprint = re.sub(r'\s+', ' ', cleaned_doc[:320]).lower()
1061
+ if fingerprint in seen_context_fingerprints:
1062
+ continue
1063
+ seen_context_fingerprints.add(fingerprint)
1064
+
1065
+ filename = meta.get('filename', 'Unknown')
1066
+ clean_name = filename.replace('.pdf', '').replace('.docx', '').replace('.txt', '')
1067
+ publication_year = _resolve_publication_year(meta)
1068
+ year_label = str(publication_year) if publication_year else "UNKNOWN"
1069
+ source_idx = len(context_parts) + 1
1070
+
1071
+ context_parts.append(f"Source [{source_idx}] [YEAR: {year_label}] ({clean_name}):\n{cleaned_doc}\n")
1072
+ filtered_metadatas.append(meta)
1073
+ sources.append({
1074
+ "content": cleaned_doc[:200] + "..." if len(cleaned_doc) > 200 else cleaned_doc,
1075
+ "metadata": meta,
1076
+ "score": float(score)
1077
+ })
1078
+
1079
+ chat_id = request.chat_id or str(uuid.uuid4())
1080
+ timestamp = datetime.now().isoformat()
1081
+
1082
+ if not context_parts:
1083
+ yield _format_sse_event("meta", {
1084
+ "chat_id": chat_id,
1085
+ "timestamp": timestamp,
1086
+ "sources": [],
1087
+ "ui_notice": "Context chunks were filtered due to low text quality.",
1088
+ })
1089
+ msg = "I found related retrieval hits, but they were too noisy to trust for a grounded answer. Please re-upload a cleaner source file (or a text-based PDF) and try again."
1090
+ yield _format_sse_event("token", {"delta": msg})
1091
+ yield _format_sse_event("done", {
1092
+ "chat_id": chat_id,
1093
+ "timestamp": timestamp,
1094
+ "response": msg,
1095
+ "sources": [],
1096
+ "ui_notice": "Context chunks were filtered due to low text quality.",
1097
+ "suggested_follow_ups": [],
1098
+ })
1099
+ return
1100
+
1101
+ context = "\n".join(context_parts)
1102
+
1103
+ # 3. Yield Meta (unblocks UI, shows citations)
1104
+ yield _format_sse_event("meta", {
1105
+ "chat_id": chat_id,
1106
+ "timestamp": timestamp,
1107
+ "sources": sources,
1108
+ "ui_notice": getattr(llm_generator, 'last_notice', None),
1109
+ })
1110
+
1111
+ # 4. Stream LLM tokens live
1112
+ full_response = ""
1113
+ generator = llm_generator.generate_stream(
1114
+ prompt=request.query,
1115
+ context=context,
1116
+ use_case=request.workflow if request.workflow in ["summary", "explanation", "qa", "notes"] else "qa",
1117
+ metadatas=filtered_metadatas,
1118
+ temperature=0.35,
1119
+ max_tokens=chat_max_tokens,
1120
+ )
1121
+
1122
+ for chunk in generator:
1123
+ if chunk:
1124
+ full_response += chunk
1125
+ yield _format_sse_event("token", {"delta": chunk})
1126
+
1127
+ # 5. Generate follow-ups and save history
1128
+ suggested_follow_ups = llm_generator.generate_follow_up_questions(
1129
+ prompt=request.query,
1130
+ context=context,
1131
+ answer=full_response,
1132
+ max_questions=3,
1133
+ )
1134
+
1135
+ if suggested_follow_ups:
1136
+ yield _format_sse_event("followups", {
1137
+ "suggested_follow_ups": suggested_follow_ups,
1138
+ })
1139
+
1140
+ chats = load_chats_for_space(request.space_id)
1141
+ chat = next((c for c in chats if c['id'] == chat_id), None)
1142
+ if not chat:
1143
+ chat = {
1144
+ 'id': chat_id,
1145
+ 'messages': [],
1146
+ 'created_at': timestamp,
1147
+ 'updated_at': timestamp
1148
+ }
1149
+ chats.append(chat)
1150
+
1151
+ chat['messages'].extend([
1152
+ {'role': 'user', 'content': request.query, 'timestamp': timestamp},
1153
+ {
1154
+ 'role': 'assistant',
1155
+ 'content': full_response,
1156
+ 'timestamp': timestamp,
1157
+ 'sources': sources,
1158
+ 'suggested_follow_ups': suggested_follow_ups,
1159
+ }
1160
+ ])
1161
+ chat['updated_at'] = timestamp
1162
+ save_chats_for_space(request.space_id, chats)
1163
+
1164
+ # 6. Yield Done
1165
+ yield _format_sse_event("done", {
1166
+ "chat_id": chat_id,
1167
+ "timestamp": timestamp,
1168
+ "response": full_response,
1169
+ "sources": sources,
1170
+ "ui_notice": getattr(llm_generator, 'last_notice', None),
1171
+ "suggested_follow_ups": suggested_follow_ups,
1172
+ })
1173
+
1174
+ except Exception as e:
1175
+ print(f"[CHAT_STREAM ERROR] {trace_id}: {str(e)}")
1176
+ yield _format_sse_event("error", {"message": str(e)})
1177
+
1178
+ return StreamingResponse(
1179
+ event_stream(),
1180
+ media_type="text/event-stream",
1181
+ headers={
1182
+ "Cache-Control": "no-cache",
1183
+ "Connection": "keep-alive",
1184
+ "X-Accel-Buffering": "no",
1185
+ },
1186
+ )
1187
+
1188
+ except Exception as exc:
1189
+ error_message = str(exc)
1190
+ elapsed_ms = (time.perf_counter() - t_stream) * 1000
1191
+ print(f"[CHAT_STREAM {trace_id}] error ms={elapsed_ms:.1f} detail={error_message}")
1192
+
1193
+ def error_stream(message: str = error_message):
1194
+ yield _format_sse_event("error", {"message": message})
1195
+
1196
+ return StreamingResponse(
1197
+ error_stream(),
1198
+ media_type="text/event-stream",
1199
+ status_code=500,
1200
+ )
1201
+
1202
+ @app.post("/api/spaces/{space_id}/upload")
1203
+ async def upload_files(space_id: str, files: List[UploadFile] = File(...)):
1204
+ """Upload and process files for a space"""
1205
+ try:
1206
+ global hybrid_retriever
1207
+ # Initialize space
1208
+ initialize_space(space_id)
1209
+
1210
+ # Save uploaded files temporarily
1211
+ space_dir = get_space_dir(space_id)
1212
+ uploads_dir = space_dir / "uploads"
1213
+ uploads_dir.mkdir(parents=True, exist_ok=True)
1214
+
1215
+ processor = DocumentProcessor()
1216
+ all_chunks = []
1217
+ processed_files = []
1218
+ skipped_files = []
1219
+
1220
+ processed_file_path = space_dir / "processed_files.json"
1221
+ existing_filenames = set()
1222
+ if processed_file_path.exists():
1223
+ with open(processed_file_path, 'r', encoding='utf-8-sig') as f:
1224
+ try:
1225
+ for item in json.load(f):
1226
+ existing_filenames.add(item.get('original_filename', item.get('filename')))
1227
+ except Exception:
1228
+ pass
1229
+
1230
+ for file in files:
1231
+ filename = (file.filename or "unnamed_upload").strip()
1232
+
1233
+ if filename in existing_filenames:
1234
+ print(f"[UPLOAD] skip file={filename} reason=already_exists")
1235
+ skipped_files.append({"filename": filename, "reason": "File already exists in this space"})
1236
+ continue
1237
+
1238
+ # Ensure we read from the start of the upload stream.
1239
+ try:
1240
+ await file.seek(0)
1241
+ except Exception:
1242
+ pass
1243
+
1244
+ content = await file.read()
1245
+ content_size = len(content) if content else 0
1246
+ if content_size == 0:
1247
+ reason = "Upload payload was empty (0 bytes)"
1248
+ skipped_files.append({"filename": filename, "reason": reason})
1249
+ print(f"[UPLOAD] skip file={filename} reason={reason}")
1250
+ continue
1251
+
1252
+ # Save file
1253
+ file_path = uploads_dir / filename
1254
+ with open(file_path, "wb") as f:
1255
+ f.write(content)
1256
+ f.flush()
1257
+ os.fsync(f.fileno())
1258
+
1259
+ try:
1260
+ if file_path.stat().st_size == 0:
1261
+ reason = "File became 0 bytes after write"
1262
+ skipped_files.append({"filename": filename, "reason": reason})
1263
+ print(f"[UPLOAD] skip file={filename} reason={reason}")
1264
+ file_path.unlink(missing_ok=True)
1265
+ continue
1266
+ except Exception:
1267
+ pass
1268
+
1269
+ # Process file and extract content
1270
+ try:
1271
+ print(f"[UPLOAD] Extracting text from newly uploaded file: {filename}")
1272
+ file_data = processor.process_file(file_path)
1273
+ content = file_data['content']
1274
+ chunk_filename = file_data.get('virtual_filename', filename)
1275
+
1276
+ # Chunk the content
1277
+ chunks = processor.chunk_text(
1278
+ content,
1279
+ chunk_size=config.CHUNK_SIZE,
1280
+ overlap=config.CHUNK_OVERLAP,
1281
+ semantic=True,
1282
+ source_filename=chunk_filename,
1283
+ )
1284
+
1285
+ # Format chunks for vector database
1286
+ formatted_chunks = []
1287
+ publication_year = file_data.get('publication_year', '0000')
1288
+ for idx, chunk in enumerate(chunks):
1289
+ formatted_chunks.append({
1290
+ 'content': chunk,
1291
+ 'metadata': {
1292
+ 'filename': chunk_filename,
1293
+ 'chunk_index': idx,
1294
+ 'total_chunks': len(chunks),
1295
+ 'source_type': file_data['format'],
1296
+ 'publication_year': publication_year,
1297
+ }
1298
+ })
1299
+
1300
+ all_chunks.extend(formatted_chunks)
1301
+ processed_files.append({
1302
+ 'filename': chunk_filename,
1303
+ 'original_filename': filename,
1304
+ 'publication_year': publication_year,
1305
+ 'chunks': len(chunks),
1306
+ 'processed_at': datetime.now().isoformat()
1307
+ })
1308
+
1309
+ # Progressively save to processed_files.json so UI can see them immediately
1310
+ try:
1311
+ p_file = space_dir / "processed_files.json"
1312
+ ex = []
1313
+ if p_file.exists():
1314
+ with open(p_file, 'r', encoding='utf-8-sig') as f:
1315
+ ex = json.load(f)
1316
+ # Filter out ones we just added to avoid duplicates if we run this multiple times
1317
+ ex = [x for x in ex if x.get('original_filename') != filename and x.get('filename') != chunk_filename]
1318
+ ex.append(processed_files[-1])
1319
+ with open(p_file, 'w', encoding='utf-8') as f:
1320
+ json.dump(ex, f, indent=2)
1321
+ except Exception as e:
1322
+ print(f"Error saving progressive processed_files.json: {e}")
1323
+
1324
+ except Exception as e:
1325
+ # Log error but continue with other files
1326
+ err = str(e)
1327
+ print(f"Error processing {filename}: {err}")
1328
+ skipped_files.append({"filename": filename, "reason": err})
1329
+ continue
1330
+
1331
+ # Add to vector database in batches to avoid size limits
1332
+ if all_chunks:
1333
+ print(f"[UPLOAD] Finished extracting text. Now embedding {len(all_chunks)} chunks for the new files. This may take a while on CPU...")
1334
+ # Extract texts, metadatas, and generate IDs
1335
+ texts = [chunk['content'] for chunk in all_chunks]
1336
+ metadatas = [chunk['metadata'] for chunk in all_chunks]
1337
+ ids = [f"{space_id}_{idx}_{uuid.uuid4().hex[:8]}" for idx in range(len(all_chunks))]
1338
+
1339
+ # Process in batches of 5000 to avoid ChromaDB batch size limit
1340
+ batch_size = 5000
1341
+ for i in range(0, len(texts), batch_size):
1342
+ batch_texts = texts[i:i + batch_size]
1343
+ batch_metadatas = metadatas[i:i + batch_size]
1344
+ batch_ids = ids[i:i + batch_size]
1345
+
1346
+ vector_db.add_documents(batch_texts, batch_metadatas, batch_ids)
1347
+ print(f"Processed batch {i//batch_size + 1}/{(len(texts)-1)//batch_size + 1}")
1348
+
1349
+ # Refresh retriever cache so BM25 index includes newly uploaded chunks.
1350
+ hybrid_retriever = HybridRetriever(vector_db, alpha=0.6)
1351
+ print(f"[UPLOAD] retriever_cache_refreshed space={space_id} chunks={len(all_chunks)}")
1352
+
1353
+ # Save processed files info — write atomically to avoid corruption
1354
+ processed_file = space_dir / "processed_files.json"
1355
+ existing = []
1356
+ if processed_file.exists():
1357
+ try:
1358
+ with open(processed_file, 'r', encoding='utf-8-sig') as f:
1359
+ existing = json.load(f)
1360
+ except Exception:
1361
+ existing = []
1362
+
1363
+ # Deduplicate to prevent double-adding from the progressive save
1364
+ new_originals = {p.get('original_filename') for p in processed_files}
1365
+ new_virtuals = {p.get('filename') for p in processed_files}
1366
+ existing = [x for x in existing if x.get('original_filename') not in new_originals and x.get('filename') not in new_virtuals]
1367
+
1368
+ existing.extend(processed_files)
1369
+ tmp_file = processed_file.with_suffix('.tmp')
1370
+ with open(tmp_file, 'w', encoding='utf-8') as f:
1371
+ json.dump(existing, f, indent=2, ensure_ascii=False)
1372
+ tmp_file.replace(processed_file) # atomic rename
1373
+
1374
+ print(f"[UPLOAD] Finished successfully! Returning response to UI.")
1375
+
1376
+ return {
1377
+ "status": "success",
1378
+ "files_processed": len(processed_files),
1379
+ "total_chunks": len(all_chunks),
1380
+ "files_skipped": len(skipped_files),
1381
+ "skipped_files": skipped_files,
1382
+ }
1383
+
1384
+ except HTTPException:
1385
+ raise
1386
+ except Exception as e:
1387
+ raise HTTPException(status_code=500, detail=str(e))
1388
+
1389
+ @app.get("/api/spaces/{space_id}/files")
1390
+ async def get_files(space_id: str):
1391
+ """Get processed files for a space"""
1392
+ processed_file = get_space_dir(space_id) / "processed_files.json"
1393
+
1394
+ if processed_file.exists():
1395
+ with open(processed_file, 'r', encoding='utf-8-sig') as f:
1396
+ return json.load(f)
1397
+
1398
+ return []
1399
+
1400
+ @app.delete("/api/spaces/{space_id}/files/{filename}")
1401
+ async def delete_file(space_id: str, filename: str):
1402
+ """Delete a specific file from a space"""
1403
+ try:
1404
+ original_filename = filename
1405
+ processed_file = get_space_dir(space_id) / "processed_files.json"
1406
+ if processed_file.exists():
1407
+ with open(processed_file, 'r', encoding='utf-8-sig') as f:
1408
+ files_data = json.load(f)
1409
+
1410
+ # Support both virtual ([YEAR] name.pdf) and original filename lookups.
1411
+ for item in files_data:
1412
+ if item.get('filename') == filename:
1413
+ original_filename = item.get('original_filename', filename)
1414
+ break
1415
+ if item.get('original_filename') == filename:
1416
+ original_filename = item.get('original_filename', filename)
1417
+ break
1418
+
1419
+ files_data = [
1420
+ f for f in files_data
1421
+ if f.get('filename') != filename and f.get('original_filename') != filename
1422
+ ]
1423
+ with open(processed_file, 'w', encoding='utf-8') as f:
1424
+ json.dump(files_data, f, indent=2, ensure_ascii=False)
1425
+
1426
+ # Remove physical file from uploads directory.
1427
+ file_path = get_space_dir(space_id) / "uploads" / original_filename
1428
+ if file_path.exists():
1429
+ file_path.unlink()
1430
+
1431
+ # Remove associated chunks from vector DB metadata.
1432
+ try:
1433
+ ids_to_delete: List[str] = []
1434
+ candidate_names = {filename, original_filename}
1435
+ try:
1436
+ for candidate in candidate_names:
1437
+ filtered = vector_db.collection.get(where={"filename": candidate})
1438
+ if filtered and filtered.get('ids'):
1439
+ ids_to_delete.extend(filtered.get('ids', []))
1440
+ except Exception:
1441
+ # Fallback path for older Chroma versions where `where` can be inconsistent.
1442
+ all_results = vector_db.collection.get()
1443
+ all_ids = all_results.get('ids', []) if all_results else []
1444
+ all_metas = all_results.get('metadatas', []) if all_results else []
1445
+ for doc_id, metadata in zip(all_ids, all_metas):
1446
+ if metadata and metadata.get('filename') in candidate_names:
1447
+ ids_to_delete.append(doc_id)
1448
+
1449
+ if ids_to_delete:
1450
+ vector_db.collection.delete(ids=list(set(ids_to_delete)))
1451
+ vector_db.client.persist()
1452
+ print(f"Deleted {len(ids_to_delete)} chunks for {filename}")
1453
+ except Exception as e:
1454
+ print(f"Vector cleanup warning for {filename}: {e}")
1455
+
1456
+ return {
1457
+ "status": "success",
1458
+ "message": f"File {filename} deleted"
1459
+ }
1460
+
1461
+ except HTTPException:
1462
+ raise
1463
+ except Exception as e:
1464
+ raise HTTPException(status_code=500, detail=str(e))
1465
+
1466
+
1467
+ import weasyprint
1468
+ import markdown
1469
+ import latex2mathml.converter
1470
+ import re
1471
+ from datetime import datetime
1472
+ import html
1473
+
1474
+ def _render_latex_to_mathml(text: str) -> str:
1475
+ """Find math expressions in text and convert using ziamath."""
1476
+ # Remove citations like [13], **[1, 2]**, etc.
1477
+ text = re.sub(r'\s*(?:\*\*)?\[\d+(?:,\s*\d+)*\](?:\*\*)?', '', text)
1478
+
1479
+ def render_math(m, display_mode):
1480
+ try:
1481
+ math_str = m.group(1).strip()
1482
+ # ziamath handles rendering SVG from latex
1483
+ svg = ziamath.Math.fromlatex(math_str).svg()
1484
+ if display_mode == 'block':
1485
+ return f'<div style="text-align: center; margin: 1em 0;">{svg}</div>'
1486
+ else:
1487
+ return f'<span style="display:inline-block; vertical-align:middle;">{svg}</span>'
1488
+ except Exception as e:
1489
+ print("MATH RENDER ERROR: ", e)
1490
+ return m.group(0)
1491
+
1492
+ # Block math using \[ ... \]
1493
+ text = re.sub(r'\\\[([\s\S]+?)\\\]', lambda m: render_math(m, 'block'), text)
1494
+
1495
+ # Inline math using \( ... \)
1496
+ text = re.sub(r'\\\(([\s\S]+?)\\\)', lambda m: render_math(m, 'inline'), text)
1497
+
1498
+ # Block math using $$
1499
+ text = re.sub(r'\$\$([\s\S]+?)\$\$', lambda m: render_math(m, 'block'), text)
1500
+
1501
+ # Inline math using $
1502
+ text = re.sub(r'\$(?!\$)([\s\S]+?)\$(?!\$)', lambda m: render_math(m, 'inline'), text)
1503
+
1504
+ return text
1505
+
1506
+ def _render_html_page(title: str, body_html: str, space_id: str) -> bytes:
1507
+ """Generates WeasyPrint PDF from HTML."""
1508
+ exported_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
1509
+ html_content = f"""
1510
+ <!DOCTYPE html>
1511
+ <html>
1512
+ <head>
1513
+ <meta charset="utf-8">
1514
+ <title>{html.escape(title)}</title>
1515
+ <style>
1516
+ body {{ font-family: sans-serif; font-size: 11pt; line-height: 1.5; color: #333; }}
1517
+ h1 {{ font-size: 18pt; margin-bottom: 5px; }}
1518
+ .meta {{ font-size: 9pt; color: #666; margin-bottom: 20px; }}
1519
+ h2 {{ font-size: 14pt; margin-top: 15px; margin-bottom: 5px; }}
1520
+ h3 {{ font-size: 12.5pt; margin-top: 10px; margin-bottom: 5px; }}
1521
+ table {{ border-collapse: collapse; width: 100%; margin-bottom: 10px; }}
1522
+ th, td {{ border: 1px solid #ccc; padding: 6px; text-align: left; font-size: 10pt; }}
1523
+ pre {{ background-color: #f5f5f5; padding: 10px; font-size: 9.5pt; overflow-x: auto; }}
1524
+ code {{ font-family: monospace; background-color: #f5f5f5; padding: 2px 4px; }}
1525
+ blockquote {{ border-left: 3px solid #ccc; margin: 0; padding-left: 10px; color: #555; }}
1526
+ math {{ font-family: "Cambria Math", "Latin Modern Math", "STIX Two Math", math, serif; font-size: 1.1em; }}
1527
+ .answer-block {{ margin-bottom: 20px; }}
1528
+ </style>
1529
+ </head>
1530
+ <body>
1531
+ <h1>{html.escape(title)}</h1>
1532
+ <div class="meta">Space: {html.escape(space_id)} <br> Exported at: {exported_at}</div>
1533
+ {body_html}
1534
+ </body>
1535
+ </html>
1536
+ """
1537
+ pdf_bytes = weasyprint.HTML(string=html_content).write_pdf()
1538
+ return pdf_bytes
1539
+
1540
+ def _build_answers_pdf_bytes(title: str, items: list[dict], space_id: str) -> bytes:
1541
+ body_parts = []
1542
+ for item in items:
1543
+ q = item.get('question', '')
1544
+ a = item.get('answer', '')
1545
+
1546
+ q_math = _render_latex_to_mathml(q)
1547
+ a_math = _render_latex_to_mathml(a)
1548
+
1549
+ q_html = markdown.markdown(q_math, extensions=['tables', 'fenced_code'])
1550
+ a_html = markdown.markdown(a_math, extensions=['tables', 'fenced_code'])
1551
+
1552
+ body_parts.append(f"<div class='answer-block'><h2>Question: {q_html}</h2><div class='answer'>{a_html}</div></div><hr>")
1553
+
1554
+ return _render_html_page(title, "".join(body_parts), space_id)
1555
+
1556
+ def _safe_pdf_filename(name: str) -> str:
1557
+ base = (name or 'notebookpro_export').strip().replace(' ', '_')
1558
+ base = re.sub(r'[^A-Za-z0-9_\-]', '', base)
1559
+ if not base:
1560
+ base = 'notebookpro_export'
1561
+ return f"{base}.pdf"
1562
+
1563
+
1564
+ def _apply_inline_format(text: str, attrs: dict) -> str:
1565
+ if not text: return text
1566
+
1567
+ # Preserve leading/trailing whitespace
1568
+ l_space = len(text) - len(text.lstrip(' \t\n'))
1569
+ r_space = len(text) - len(text.rstrip(' \t\n'))
1570
+
1571
+ inner = text.strip(' \t\n')
1572
+ if not inner: return text # If it's all spaces, don't wrap it
1573
+
1574
+ if attrs.get('bold'): inner = f"**{inner}**"
1575
+ if attrs.get('italic'): inner = f"*{inner}*"
1576
+ if attrs.get('strike'): inner = f"~~{inner}~~"
1577
+ if attrs.get('code'): inner = f"`{inner}`"
1578
+ if attrs.get('link'): inner = f"[{inner}]({attrs.get('link')})"
1579
+
1580
+ return text[:l_space] + inner + text[len(text)-r_space:] if r_space > 0 else text[:l_space] + inner
1581
+
1582
+ def _quill_or_plain_to_text(content: str) -> str:
1583
+ """Convert plain text or Quill delta JSON string into Markdown text."""
1584
+ if not content:
1585
+ return ""
1586
+
1587
+ stripped = content.strip()
1588
+ if not stripped:
1589
+ return ""
1590
+
1591
+ try:
1592
+ parsed = json.loads(stripped)
1593
+ ops = []
1594
+ if isinstance(parsed, dict) and isinstance(parsed.get('ops'), list):
1595
+ ops = parsed.get('ops', [])
1596
+ elif isinstance(parsed, list):
1597
+ ops = parsed
1598
+ else:
1599
+ return stripped
1600
+
1601
+ lines = []
1602
+ current_line_ops = []
1603
+
1604
+ for op in ops:
1605
+ if not isinstance(op, dict):
1606
+ continue
1607
+
1608
+ insert_value = op.get('insert')
1609
+ if not isinstance(insert_value, str):
1610
+ continue
1611
+
1612
+ attrs = op.get('attributes', {})
1613
+ parts = insert_value.split('\n')
1614
+
1615
+ for i, part in enumerate(parts):
1616
+ if i > 0:
1617
+ line_md = ""
1618
+ for l_op, l_attrs in current_line_ops:
1619
+ line_md += _apply_inline_format(l_op, l_attrs)
1620
+
1621
+ if attrs.get('header') == 1: line_md = f"# {line_md}"
1622
+ elif attrs.get('header') == 2: line_md = f"## {line_md}"
1623
+ elif attrs.get('header') == 3: line_md = f"### {line_md}"
1624
+ elif attrs.get('header') == 4: line_md = f"#### {line_md}"
1625
+ elif attrs.get('blockquote'): line_md = f"> {line_md}"
1626
+ elif attrs.get('code-block'): line_md = f"```\n{line_md}\n```"
1627
+ elif attrs.get('list') == 'bullet': line_md = f"* {line_md}"
1628
+ elif attrs.get('list') == 'ordered': line_md = f"1. {line_md}"
1629
+
1630
+ lines.append(line_md)
1631
+ current_line_ops = []
1632
+
1633
+ if part:
1634
+ current_line_ops.append((part, attrs))
1635
+
1636
+ if current_line_ops:
1637
+ line_md = ""
1638
+ for l_op, l_attrs in current_line_ops:
1639
+ line_md += _apply_inline_format(l_op, l_attrs)
1640
+ lines.append(line_md)
1641
+
1642
+ text = '\n'.join(lines)
1643
+ text = re.sub(r'\n{3,}', '\n\n', text)
1644
+ return text.strip()
1645
+ except Exception:
1646
+ return stripped
1647
+
1648
+
1649
+ def _extract_chat_qa(text: str) -> Optional[Dict[str, str]]:
1650
+ """Extract chat-style Q/A blocks from plain text."""
1651
+ if not text:
1652
+ return None
1653
+
1654
+ match = re.match(r'^\s*Q:\s*(.*?)\s*\n\s*\n\s*A:\s*(.*)$', text, flags=re.DOTALL | re.IGNORECASE)
1655
+ if not match:
1656
+ return None
1657
+
1658
+ question = (match.group(1) or '').strip()
1659
+ answer = (match.group(2) or '').strip()
1660
+ if not question or not answer:
1661
+ return None
1662
+ return {'question': question, 'answer': answer}
1663
+
1664
+
1665
+ def _normalize_compare_text(value: str) -> str:
1666
+ v = (value or '').lower().strip()
1667
+ v = v.replace('...', ' ')
1668
+ v = re.sub(r'[^a-z0-9\s]', ' ', v)
1669
+ v = re.sub(r'\s+', ' ', v).strip()
1670
+ return v
1671
+
1672
+
1673
+ def _is_same_question_title(title: str, question: str) -> bool:
1674
+ """Fuzzy equality to match truncated titles with full questions."""
1675
+ t = _normalize_compare_text(title)
1676
+ q = _normalize_compare_text(question)
1677
+ if not t or not q:
1678
+ return False
1679
+
1680
+ if t == q:
1681
+ return True
1682
+
1683
+ # Prefix checks for truncated titles.
1684
+ if len(t) >= 18 and q.startswith(t):
1685
+ return True
1686
+ if len(q) >= 18 and t.startswith(q):
1687
+ return True
1688
+
1689
+ # Token-prefix overlap check (first several tokens usually define the question).
1690
+ t_tokens = t.split()
1691
+ q_tokens = q.split()
1692
+ common = min(len(t_tokens), len(q_tokens), 8)
1693
+ if common >= 4 and t_tokens[:common] == q_tokens[:common]:
1694
+ return True
1695
+
1696
+ return False
1697
+
1698
+
1699
+ def _normalize_chat_entry_body(entry: NotebookEntry, body_text: str) -> str:
1700
+ """For chat entries, avoid repeating question in body when title already carries it."""
1701
+ qa = _extract_chat_qa(body_text)
1702
+ if not qa:
1703
+ return body_text
1704
+
1705
+ title = (entry.title or '').strip()
1706
+ question = qa['question'].strip()
1707
+
1708
+ if not title:
1709
+ return f"Question: {qa['question']}\n\nAnswer: {qa['answer']}"
1710
+
1711
+ # If title and question are effectively the same, keep only the answer body.
1712
+ if _is_same_question_title(title, question):
1713
+ return qa['answer']
1714
+
1715
+ return f"Question: {qa['question']}\n\nAnswer: {qa['answer']}"
1716
+
1717
+
1718
+ def _build_notebook_entries_pdf_bytes(title: str, entries: list, space_id: str) -> bytes:
1719
+ body_parts = []
1720
+ for idx, entry in enumerate(entries, 1):
1721
+ body_text = _quill_or_plain_to_text(entry.content)
1722
+ entry_title = entry.title or f"Note {idx}"
1723
+
1724
+ if entry.source_type == 'chat':
1725
+ qa = _extract_chat_qa(body_text)
1726
+ if qa and qa.get('question'):
1727
+ entry_title = qa['question']
1728
+ body_text = _normalize_chat_entry_body(entry, body_text)
1729
+
1730
+ updated = entry.updated_at.strftime('%Y-%m-%d %H:%M') if entry.updated_at else ''
1731
+
1732
+ body_text = _render_latex_to_mathml(body_text)
1733
+ body_html = markdown.markdown(body_text, extensions=['tables', 'fenced_code'])
1734
+
1735
+ updated_str = f"<div class='meta'>Updated: {updated}</div>" if updated else ""
1736
+ body_parts.append(f"<div class='answer-block'><h2>{idx}. {html.escape(entry_title)}</h2>{updated_str}<div>{body_html}</div></div><hr>")
1737
+
1738
+ return _render_html_page(title, "".join(body_parts), space_id)
1739
+
1740
+
1741
+ @app.post("/api/studio/notebook", response_model=NotebookEntry)
1742
+ async def create_notebook_entry(entry_data: NotebookEntryCreate):
1743
+ """Create a new notebook entry"""
1744
+ try:
1745
+ entry = studio_manager.create_notebook_entry(entry_data)
1746
+ return entry
1747
+ except Exception as e:
1748
+ raise HTTPException(status_code=500, detail=str(e))
1749
+
1750
+ @app.get("/api/studio/notebook/space/{space_id}")
1751
+ async def get_space_notebook(space_id: str):
1752
+ """Get or create notebook metadata for a space."""
1753
+ try:
1754
+ space = spaces_manager.get_space(space_id)
1755
+ space_name = space['name'] if space else space_id
1756
+ notebook = studio_manager.ensure_space_notebook(space_id, space_name)
1757
+ return notebook
1758
+ except Exception as e:
1759
+ raise HTTPException(status_code=500, detail=str(e))
1760
+
1761
+ @app.post("/api/studio/notebook/from-chat", response_model=NotebookEntry)
1762
+ async def add_chat_to_notebook(request: ChatToNotebookRequest):
1763
+ """Add a chat question/answer pair into a space notebook."""
1764
+ try:
1765
+ space = spaces_manager.get_space(request.space_id)
1766
+ resolved_space_name = request.space_name or (space['name'] if space else request.space_id)
1767
+
1768
+ entry = studio_manager.create_notebook_entry_from_chat(
1769
+ space_id=request.space_id,
1770
+ question=request.question,
1771
+ answer=request.answer,
1772
+ chat_id=request.chat_id,
1773
+ assistant_timestamp=request.assistant_timestamp,
1774
+ tags=request.tags,
1775
+ space_name=resolved_space_name
1776
+ )
1777
+ return entry
1778
+ except Exception as e:
1779
+ raise HTTPException(status_code=500, detail=str(e))
1780
+
1781
+ @app.get("/api/studio/notebook", response_model=List[NotebookEntry])
1782
+ async def list_notebook_entries(space_id: Optional[str] = None):
1783
+ """List all notebook entries, optionally filtered by space"""
1784
+ try:
1785
+ entries = studio_manager.list_notebook_entries(space_id)
1786
+ return entries
1787
+ except Exception as e:
1788
+ raise HTTPException(status_code=500, detail=str(e))
1789
+
1790
+ @app.get("/api/studio/notebook/{entry_id}", response_model=NotebookEntry)
1791
+ async def get_notebook_entry(entry_id: str):
1792
+ """Get a single notebook entry"""
1793
+ entry = studio_manager.get_notebook_entry(entry_id)
1794
+ if not entry:
1795
+ raise HTTPException(status_code=404, detail="Notebook entry not found")
1796
+ return entry
1797
+
1798
+ @app.put("/api/studio/notebook/{entry_id}", response_model=NotebookEntry)
1799
+ async def update_notebook_entry(entry_id: str, update_data: NotebookEntryUpdate):
1800
+ """Update a notebook entry"""
1801
+ entry = studio_manager.update_notebook_entry(entry_id, update_data)
1802
+ if not entry:
1803
+ raise HTTPException(status_code=404, detail="Notebook entry not found")
1804
+ return entry
1805
+
1806
+ @app.delete("/api/studio/notebook/{entry_id}")
1807
+ async def delete_notebook_entry(entry_id: str):
1808
+ """Delete a notebook entry"""
1809
+ success = studio_manager.delete_notebook_entry(entry_id)
1810
+ if not success:
1811
+ raise HTTPException(status_code=404, detail="Notebook entry not found")
1812
+ return {"status": "success", "message": "Notebook entry deleted"}
1813
+
1814
+
1815
+ @app.post("/api/studio/notebook/export/answer-pdf")
1816
+ async def export_single_answer_pdf(request: ExportAnswerPdfRequest):
1817
+ """Export one Q/A pair as a downloadable PDF."""
1818
+ try:
1819
+ title = request.title or "NotebookPRO Answer Export"
1820
+ pdf_bytes = _build_answers_pdf_bytes(
1821
+ title=title,
1822
+ space_id=request.space_id,
1823
+ items=[{"question": request.question, "answer": request.answer}],
1824
+ )
1825
+ filename = _safe_pdf_filename(title)
1826
+ return Response(
1827
+ content=pdf_bytes,
1828
+ media_type="application/pdf",
1829
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
1830
+ )
1831
+ except Exception as e:
1832
+ raise HTTPException(status_code=500, detail=f"Failed to export answer PDF: {e}")
1833
+
1834
+
1835
+ @app.post("/api/studio/notebook/export/combined-pdf")
1836
+ async def export_combined_answers_pdf(request: ExportCombinedAnswersPdfRequest):
1837
+ """Export all provided Q/A pairs as one combined downloadable PDF."""
1838
+ try:
1839
+ if not request.items:
1840
+ raise HTTPException(status_code=400, detail="No answers provided for export")
1841
+
1842
+ title = request.chat_title or "NotebookPRO Combined Answers"
1843
+ pdf_bytes = _build_answers_pdf_bytes(
1844
+ title=title,
1845
+ space_id=request.space_id,
1846
+ items=[{"question": i.question, "answer": i.answer} for i in request.items],
1847
+ )
1848
+ filename = _safe_pdf_filename(title)
1849
+ return Response(
1850
+ content=pdf_bytes,
1851
+ media_type="application/pdf",
1852
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
1853
+ )
1854
+ except HTTPException:
1855
+ raise
1856
+ except Exception as e:
1857
+ raise HTTPException(status_code=500, detail=f"Failed to export combined PDF: {e}")
1858
+
1859
+
1860
+ @app.get("/api/studio/notebook/export/entry/{entry_id}/pdf")
1861
+ async def export_notebook_entry_pdf(entry_id: str):
1862
+ """Export one notebook entry as a downloadable PDF."""
1863
+ try:
1864
+ entry = studio_manager.get_notebook_entry(entry_id)
1865
+ if not entry:
1866
+ raise HTTPException(status_code=404, detail="Notebook entry not found")
1867
+
1868
+ title = f"Notebook Entry - {entry.title}"
1869
+ pdf_bytes = _build_notebook_entries_pdf_bytes(title, [entry], entry.space_id)
1870
+ filename = _safe_pdf_filename(entry.title or f"entry_{entry.id}")
1871
+ return Response(
1872
+ content=pdf_bytes,
1873
+ media_type="application/pdf",
1874
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
1875
+ )
1876
+ except HTTPException:
1877
+ raise
1878
+ except Exception as e:
1879
+ raise HTTPException(status_code=500, detail=f"Failed to export notebook entry PDF: {e}")
1880
+
1881
+
1882
+ @app.get("/api/studio/notebook/export/space/{space_id}/combined-pdf")
1883
+ async def export_space_notebook_pdf(space_id: str):
1884
+ """Export all notebook entries for a space as a single PDF."""
1885
+ try:
1886
+ entries = studio_manager.list_notebook_entries(space_id=space_id)
1887
+ if not entries:
1888
+ raise HTTPException(status_code=404, detail="No notebook entries found for this space")
1889
+
1890
+ space = spaces_manager.get_space(space_id)
1891
+ space_name = space['name'] if space else space_id
1892
+ title = f"Notebook - {space_name}"
1893
+ pdf_bytes = _build_notebook_entries_pdf_bytes(title, entries, space_id)
1894
+ filename = _safe_pdf_filename(f"{space_name}_notebook")
1895
+ return Response(
1896
+ content=pdf_bytes,
1897
+ media_type="application/pdf",
1898
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
1899
+ )
1900
+ except HTTPException:
1901
+ raise
1902
+ except Exception as e:
1903
+ raise HTTPException(status_code=500, detail=f"Failed to export space notebook PDF: {e}")
1904
+
1905
+
1906
+ @app.post("/api/studio/notebook/export/selected-pdf")
1907
+ async def export_selected_notebook_pdf(request: ExportSelectedNotebookPdfRequest):
1908
+ """Export selected notebook entries for a space as a single combined PDF."""
1909
+ try:
1910
+ if not request.entry_ids:
1911
+ raise HTTPException(status_code=400, detail="No notebook entries selected for export")
1912
+
1913
+ entries = []
1914
+ for entry_id in request.entry_ids:
1915
+ entry = studio_manager.get_notebook_entry(entry_id)
1916
+ if not entry:
1917
+ raise HTTPException(status_code=404, detail=f"Notebook entry not found: {entry_id}")
1918
+ if entry.space_id != request.space_id:
1919
+ raise HTTPException(
1920
+ status_code=400,
1921
+ detail=f"Notebook entry does not belong to this space: {entry_id}",
1922
+ )
1923
+ entries.append(entry)
1924
+
1925
+ if not entries:
1926
+ raise HTTPException(status_code=404, detail="No notebook entries found for export")
1927
+
1928
+ space = spaces_manager.get_space(request.space_id)
1929
+ space_name = space['name'] if space else request.space_id
1930
+ title = request.title or f"Notebook Selection - {space_name}"
1931
+ pdf_bytes = _build_notebook_entries_pdf_bytes(title, entries, request.space_id)
1932
+ filename = _safe_pdf_filename(f"{space_name}_notebook_selected")
1933
+
1934
+ return Response(
1935
+ content=pdf_bytes,
1936
+ media_type="application/pdf",
1937
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
1938
+ )
1939
+ except HTTPException:
1940
+ raise
1941
+ except Exception as e:
1942
+ raise HTTPException(status_code=500, detail=f"Failed to export selected notebook PDF: {e}")
1943
+
1944
+
1945
+ # ===== FLASHCARD ROUTES =====
1946
+
1947
+ @app.post("/api/studio/flashcards", response_model=Flashcard)
1948
+ async def create_flashcard(card_data: FlashcardCreate):
1949
+ """Create a new flashcard"""
1950
+ try:
1951
+ card = studio_manager.create_flashcard(card_data)
1952
+ return card
1953
+ except Exception as e:
1954
+ raise HTTPException(status_code=500, detail=str(e))
1955
+
1956
+ @app.get("/api/studio/flashcards", response_model=List[Flashcard])
1957
+ async def list_flashcards(
1958
+ space_id: Optional[str] = None,
1959
+ mastery: Optional[MasteryLevel] = None
1960
+ ):
1961
+ """List all flashcards, optionally filtered"""
1962
+ try:
1963
+ cards = studio_manager.list_flashcards(space_id, mastery)
1964
+ return cards
1965
+ except Exception as e:
1966
+ raise HTTPException(status_code=500, detail=str(e))
1967
+
1968
+ @app.get("/api/studio/flashcards/{card_id}", response_model=Flashcard)
1969
+ async def get_flashcard(card_id: str):
1970
+ """Get a single flashcard"""
1971
+ card = studio_manager.get_flashcard(card_id)
1972
+ if not card:
1973
+ raise HTTPException(status_code=404, detail="Flashcard not found")
1974
+ return card
1975
+
1976
+ @app.put("/api/studio/flashcards/{card_id}", response_model=Flashcard)
1977
+ async def update_flashcard(card_id: str, update_data: FlashcardUpdate):
1978
+ """Update a flashcard"""
1979
+ card = studio_manager.update_flashcard(card_id, update_data)
1980
+ if not card:
1981
+ raise HTTPException(status_code=404, detail="Flashcard not found")
1982
+ return card
1983
+
1984
+ @app.post("/api/studio/flashcards/{card_id}/review", response_model=Flashcard)
1985
+ async def review_flashcard(card_id: str, review: FlashcardReview):
1986
+ """Record a flashcard review"""
1987
+ card = studio_manager.review_flashcard(card_id, review)
1988
+ if not card:
1989
+ raise HTTPException(status_code=404, detail="Flashcard not found")
1990
+ return card
1991
+
1992
+ @app.delete("/api/studio/flashcards/{card_id}")
1993
+ async def delete_flashcard(card_id: str):
1994
+ """Delete a flashcard"""
1995
+ success = studio_manager.delete_flashcard(card_id)
1996
+ if not success:
1997
+ raise HTTPException(status_code=404, detail="Flashcard not found")
1998
+ return {"status": "success", "message": "Flashcard deleted"}
1999
+
2000
+ @app.post("/api/studio/flashcards/generate", response_model=List[Flashcard])
2001
+ async def generate_flashcards(request: FlashcardGenerateRequest):
2002
+ """Generate flashcards from content using LLM"""
2003
+ global studio_generator
2004
+
2005
+ if not studio_generator:
2006
+ raise HTTPException(status_code=503, detail="LLM not initialized")
2007
+
2008
+ try:
2009
+ cards = await studio_generator.generate_flashcards(request)
2010
+ return cards
2011
+ except Exception as e:
2012
+ raise HTTPException(status_code=500, detail=str(e))
2013
+
2014
+
2015
+ # ===== QUIZ ROUTES =====
2016
+
2017
+ @app.post("/api/studio/quizzes", response_model=Quiz)
2018
+ async def create_quiz(quiz_data: QuizCreate):
2019
+ """Create a new quiz"""
2020
+ try:
2021
+ quiz = studio_manager.create_quiz(quiz_data)
2022
+ return quiz
2023
+ except Exception as e:
2024
+ raise HTTPException(status_code=500, detail=str(e))
2025
+
2026
+ @app.get("/api/studio/quizzes", response_model=List[Quiz])
2027
+ async def list_quizzes(space_id: Optional[str] = None):
2028
+ """List all quizzes, optionally filtered by space"""
2029
+ try:
2030
+ quizzes = studio_manager.list_quizzes(space_id)
2031
+ return quizzes
2032
+ except Exception as e:
2033
+ raise HTTPException(status_code=500, detail=str(e))
2034
+
2035
+ @app.get("/api/studio/quizzes/{quiz_id}", response_model=Quiz)
2036
+ async def get_quiz(quiz_id: str):
2037
+ """Get a quiz by ID"""
2038
+ quiz = studio_manager.get_quiz(quiz_id)
2039
+ if not quiz:
2040
+ raise HTTPException(status_code=404, detail="Quiz not found")
2041
+ return quiz
2042
+
2043
+ @app.delete("/api/studio/quizzes/{quiz_id}")
2044
+ async def delete_quiz(quiz_id: str):
2045
+ """Delete a quiz"""
2046
+ success = studio_manager.delete_quiz(quiz_id)
2047
+ if not success:
2048
+ raise HTTPException(status_code=404, detail="Quiz not found")
2049
+ return {"status": "success", "message": "Quiz deleted"}
2050
+
2051
+ @app.post("/api/studio/quizzes/generate", response_model=Quiz)
2052
+ async def generate_quiz(request: QuizGenerateRequest):
2053
+ """Generate a quiz from content using LLM"""
2054
+ global studio_generator
2055
+
2056
+ if not studio_generator:
2057
+ raise HTTPException(status_code=503, detail="LLM not initialized")
2058
+
2059
+ try:
2060
+ quiz = await studio_generator.generate_quiz(request)
2061
+ if not quiz:
2062
+ raise HTTPException(status_code=500, detail="Failed to generate quiz")
2063
+ return quiz
2064
+ except Exception as e:
2065
+ raise HTTPException(status_code=500, detail=str(e))
2066
+
2067
+ @app.post("/api/studio/quizzes/{quiz_id}/submit", response_model=QuizResult)
2068
+ async def submit_quiz(quiz_id: str, submission: QuizSubmission):
2069
+ """Submit quiz answers and get results"""
2070
+ try:
2071
+ result = studio_manager.submit_quiz(quiz_id, submission.answers)
2072
+ if not result:
2073
+ raise HTTPException(status_code=404, detail="Quiz not found")
2074
+ return result
2075
+ except Exception as e:
2076
+ raise HTTPException(status_code=500, detail=str(e))
2077
+
2078
+ @app.get("/api/studio/quizzes/{quiz_id}/history", response_model=QuizHistory)
2079
+ async def get_quiz_history(quiz_id: str):
2080
+ """Get quiz attempt history"""
2081
+ try:
2082
+ history = studio_manager.get_quiz_history(quiz_id)
2083
+ if not history:
2084
+ raise HTTPException(status_code=404, detail="Quiz not found")
2085
+ return history
2086
+ except Exception as e:
2087
+ raise HTTPException(status_code=500, detail=str(e))
2088
+
2089
+
2090
+ raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}")
2091
+
2092
+ # ==================== Run Server ====================
2093
+
2094
+ if __name__ == "__main__":
2095
+ import uvicorn
2096
+ uvicorn.run(app, host="0.0.0.0", port=7860, log_level="error")
2097
+