Coverage for src/local_deep_research/web/routers/rag.py: 95%

1317 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2RAG Management API Routes 

3 

4Provides endpoints for managing RAG indexing of library documents: 

5- Configure embedding models 

6- Index documents 

7- Get RAG statistics 

8- Bulk operations with progress tracking 

9""" 

10 

11import asyncio 

12 

13from fastapi import APIRouter, Depends, HTTPException, Request 

14from fastapi.responses import HTMLResponse, JSONResponse 

15from ..dependencies.auth import require_auth 

16from ..dependencies.rate_limit import ( 

17 limiter, 

18 upload_rate_limit_ip, 

19 upload_rate_limit_user, 

20) 

21from ..dependencies.threadpool import ( 

22 WorkerCleanupStreamingResponse, 

23 run_db_sync, 

24) 

25from ..template_config import templates 

26 

27import os 

28 

29from loguru import logger 

30from sqlalchemy import case, func 

31import atexit 

32import json 

33import uuid 

34import time 

35import contextvars 

36import threading 

37import queue 

38from concurrent.futures import ThreadPoolExecutor 

39from datetime import datetime, UTC 

40from pathlib import Path 

41from typing import Any, Dict, Optional, Set, Tuple, Annotated 

42from tenacity import ( 

43 retry, 

44 retry_if_exception, 

45 stop_after_attempt, 

46 wait_exponential, 

47) 

48 

49from sqlalchemy.orm import defer 

50 

51from ...constants import ( 

52 DEFAULT_LOCAL_SEARCH_CHUNK_OVERLAP, 

53 DEFAULT_LOCAL_SEARCH_CHUNK_SIZE, 

54 DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC, 

55 DEFAULT_LOCAL_SEARCH_INDEX_TYPE, 

56 DEFAULT_LOCAL_SEARCH_MODEL, 

57 DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS, 

58 DEFAULT_LOCAL_SEARCH_PROVIDER, 

59 DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE, 

60 DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS, 

61 DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS_JSON, 

62 FILE_PATH_SENTINELS, 

63 FILE_PATH_TEXT_ONLY, 

64) 

65from ...security.log_sanitizer import ( 

66 sanitize_error_for_client, 

67 sanitize_error_message, 

68) 

69from ...utilities.db_utils import get_settings_manager 

70from ...utilities.resource_utils import safe_close 

71from ...research_library.services.library_rag_service import LibraryRAGService 

72from ...settings.manager import SettingsManager, check_env_setting 

73from ...research_library.utils import ( 

74 apply_user_subdir, 

75 ensure_in_collection, 

76 handle_api_error, 

77) 

78from ...database.models.library import ( 

79 Document, 

80 Collection, 

81 DocumentCollection, 

82 RAGIndex, 

83 SourceType, 

84 EmbeddingProvider, 

85) 

86from ...database.models.queue import TaskMetadata 

87from ...database.thread_local_session import thread_cleanup 

88 

89from ...config.paths import get_library_directory 

90from ...constants import DEFAULT_SEARCH_TOOL 

91from ..dependencies.json_body import json_body_error 

92 

93#: Upper bound on ``page`` -- see context_overflow_api for the rationale. 

94_MAX_PAGE = 10_000 

95 

96# Keep the configure endpoint's boundary aligned with the registered setting 

97# schema in defaults/settings_local_search.json. Text splitters require integer 

98# sizes; accepting strings, floats, or booleans here would silently coerce or 

99# truncate them only after the request reached the settings/index write path. 

100_RAG_CHUNK_SIZE_MIN = 100 

101_RAG_CHUNK_SIZE_MAX = 5_000 

102_RAG_CHUNK_OVERLAP_MIN = 0 

103_RAG_CHUNK_OVERLAP_MAX = 1_000 

104 

105router = APIRouter(prefix="/library", tags=["rag"]) 

106 

107# Process-local registry tracking active SSE indexing streams for cancellation. 

108# Keyed by (username, collection_id) -> Set[threading.Event] 

109# NOTE: Process-local registry assumes single-process deployment (or sticky sessions). 

110# Multi-worker deployments (e.g. uvicorn with multiple workers without sticky sessions) 

111# would require cross-process signaling (e.g. DB-backed cancel flag or Redis pub/sub). 

112_active_sse_indexers: Dict[Tuple[str, str], Set[threading.Event]] = {} 

113_active_sse_indexers_lock = threading.Lock() 

114 

115# NOTE: Routes use username (not .get()) intentionally. 

116# Depends(require_auth) guarantees the key exists; direct access fails 

117# fast if the dependency is ever removed. 

118 

119 

120def _agent_enabled_default_on(collection) -> bool: 

121 """Read a collection's ``agent_enabled`` flag with NULL → available. 

122 

123 A missing attribute (pre-migration object) or a stored NULL both mean 

124 "available to the research agent" (default-on); only an explicit ``False`` 

125 disables. Used by every serializer so the four read sites can't drift on 

126 null-handling. ``is not False`` is safe here because the source is a 

127 Boolean column (only ever None/True/False) — NOT raw JSON input, where a 

128 falsy ``0`` must still mean disabled (handled separately at the input edge). 

129 """ 

130 return getattr(collection, "agent_enabled", True) is not False 

131 

132 

133def _is_protected_collection(collection) -> bool: 

134 """True when the collection's type is deletion-protected. 

135 

136 Single source of truth is PROTECTED_COLLECTION_TYPES in the deletion 

137 service (imported lazily, matching update_collection's usage); the 

138 serializers expose the result as ``is_protected`` so UI surfaces can 

139 hide destructive affordances instead of offering an action the server 

140 categorically refuses with a 409. 

141 """ 

142 from ...research_library.deletion.services.collection_deletion import ( 

143 PROTECTED_COLLECTION_TYPES, 

144 ) 

145 

146 return collection.collection_type in PROTECTED_COLLECTION_TYPES 

147 

148 

149# Global ThreadPoolExecutor for auto-indexing to prevent thread proliferation. 

150# 

151# ThreadPoolExecutor's internal _work_queue is unbounded. Without 

152# backpressure, a sustained burst of uploads (possible under the 

153# configurable upload rate cap from #3935) could queue thousands of indexing 

154# jobs in memory, exhausting RAM. We track pending submissions in a counter 

155# and reject new submissions once the queue is saturated. 

156_auto_index_executor: ThreadPoolExecutor | None = None 

157_auto_index_executor_lock = threading.Lock() 

158 

159# Per-(user, collection) locks guarding the start-background-index 

160# check-and-create path. Keyed by tuple so two users indexing different 

161# collections don't serialize against each other. 

162_start_bg_index_locks: dict[tuple[str, str], threading.Lock] = {} 

163 

164# Maximum in-flight + queued indexing jobs. Each job is one upload batch 

165# (an unbounded list of document IDs), so per-job duration varies with batch 

166# size; 100 is a buffer on the number of *queued batches*, not documents. 

167# The OOM bound still holds because a queued job holds only a small list of 

168# ID strings, not document contents. Tunable if real-world workloads need 

169# more headroom. 

170_MAX_PENDING_AUTO_INDEX_JOBS = 100 

171_pending_auto_index_jobs = 0 

172_pending_auto_index_lock = threading.Lock() 

173 

174 

175def _get_auto_index_executor() -> ThreadPoolExecutor: 

176 """Get or create the global auto-indexing executor (thread-safe).""" 

177 global _auto_index_executor 

178 with _auto_index_executor_lock: 

179 if _auto_index_executor is None: 

180 _auto_index_executor = ThreadPoolExecutor( 

181 max_workers=4, 

182 thread_name_prefix="auto_index_", 

183 ) 

184 return _auto_index_executor 

185 

186 

187def _try_reserve_auto_index_slot() -> bool: 

188 """Reserve a queue slot if capacity allows. Returns True on success. 

189 

190 Caller MUST call ``_release_auto_index_slot`` when the job completes 

191 (success OR failure), otherwise the counter leaks and eventually 

192 blocks all submissions. 

193 """ 

194 global _pending_auto_index_jobs 

195 with _pending_auto_index_lock: 

196 if _pending_auto_index_jobs >= _MAX_PENDING_AUTO_INDEX_JOBS: 

197 return False 

198 _pending_auto_index_jobs += 1 

199 return True 

200 

201 

202def _release_auto_index_slot() -> None: 

203 """Release a previously reserved queue slot.""" 

204 global _pending_auto_index_jobs 

205 with _pending_auto_index_lock: 

206 _pending_auto_index_jobs = max(0, _pending_auto_index_jobs - 1) 

207 

208 

209def _shutdown_auto_index_executor() -> None: 

210 """Shutdown the auto-index executor gracefully. 

211 

212 Holds ``_auto_index_executor_lock`` for the read+nullify so two 

213 concurrent callers (e.g. atexit + a test teardown) can't race into an 

214 ``AttributeError`` on the ``None`` reference; the blocking ``shutdown`` 

215 runs outside the lock. Resets the pending-jobs counter afterwards so a 

216 re-created executor (tests, a dev-server reload) starts from a clean count 

217 instead of inheriting a stale, possibly-saturated value. 

218 """ 

219 global _auto_index_executor, _pending_auto_index_jobs 

220 with _auto_index_executor_lock: 

221 executor = _auto_index_executor 

222 _auto_index_executor = None 

223 if executor is not None: 

224 executor.shutdown(wait=True) 

225 with _pending_auto_index_lock: 

226 _pending_auto_index_jobs = 0 

227 

228 

229atexit.register(_shutdown_auto_index_executor) 

230 

231 

232def get_rag_service( 

233 request: Request, 

234 username: str, 

235 collection_id: Optional[str] = None, 

236 use_defaults: bool = False, 

237) -> LibraryRAGService: 

238 """ 

239 Get RAG service instance with appropriate settings. 

240 

241 Args: 

242 request: The FastAPI request (for session lookup). 

243 username: The authenticated username. 

244 collection_id: Optional collection UUID to load stored settings from 

245 use_defaults: When True, ignore stored collection settings and use 

246 current defaults. Pass True on force-reindex so that the new 

247 default embedding model is picked up. 

248 """ 

249 from ...research_library.services.rag_service_factory import ( 

250 get_rag_service as _get_rag_service, 

251 ) 

252 from ...database.session_passwords import session_password_store 

253 

254 session_id = request.session.get("session_id") 

255 db_password = None 

256 if session_id: 

257 db_password = session_password_store.get_session_password( 

258 username, session_id 

259 ) 

260 if not db_password: 

261 db_password = session_password_store.get_any_session_password(username) 

262 return _get_rag_service( 

263 username, 

264 collection_id, 

265 use_defaults=use_defaults, 

266 db_password=db_password, 

267 ) 

268 

269 

270def _get_text_separators(settings): 

271 """Return configured text separators, parsing string values if needed.""" 

272 # Load the stored setting. It may already be a list or a JSON string. 

273 text_separators = settings.get_setting( 

274 "local_search_text_separators", 

275 DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS_JSON, 

276 ) 

277 

278 # If the setting is stored as a string, parse it into a list. A value that 

279 # is not valid JSON (e.g. a not-yet-migrated corrupt row) falls back to the 

280 # default separators — migration #4298 heals existing corrupt data. 

281 if isinstance(text_separators, str): 

282 try: 

283 text_separators = json.loads(text_separators) 

284 except json.JSONDecodeError: 

285 logger.warning( 

286 "Invalid JSON for local_search_text_separators setting: {!r}. Using default separators.", 

287 text_separators, 

288 ) 

289 text_separators = DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS 

290 

291 return text_separators 

292 

293 

294def _parse_configured_text_separators(value): 

295 """Coerce a text-separator setting to a list of strings, or None. 

296 

297 Called via ``asyncio.to_thread`` from ``configure_rag``: ``value`` 

298 comes straight from the request body, so a caller can hand it a 

299 string up to that route's 16 MB body cap. ``json.loads`` runs at 

300 ~110 ms/MB, which is ~1.7 s at the cap -- fine on a worker thread 

301 (Flask charged the same work to a request thread), a whole-instance 

302 freeze on the event loop under single-worker uvicorn. 

303 """ 

304 separators = value 

305 if isinstance(value, str): 

306 try: 

307 separators = json.loads(value) 

308 except json.JSONDecodeError: 

309 return None 

310 if not isinstance(separators, list) or not all( 

311 isinstance(separator, str) for separator in separators 

312 ): 

313 return None 

314 return separators 

315 

316 

317# Module prefix for exceptions DEFINED inside LDR itself. An exception whose 

318# class lives under ``local_deep_research`` is something only we can fix, so we 

319# steer the user to file a bug instead of second-guessing their model choice. 

320# 

321# We deliberately do NOT treat bare builtins (KeyError/TypeError/RuntimeError/ 

322# ...) as internal: ``type(exc).__module__`` is where the class is *defined*, 

323# not where it was *raised*, so a builtin tells us nothing about whether LDR or 

324# a provider/langchain frame produced it. On this path an OpenAI-compatible 

325# server that returns a malformed 200 body can surface a bare TypeError from 

326# langchain's response parser; telling the user to "report it on GitHub" for 

327# that is exactly the misleading guidance #4208 set out to remove. 

328_INTERNAL_MODULE_PREFIX = "local_deep_research" 

329 

330# Module prefixes whose exceptions are upstream-provider errors (network, 

331# auth, model not found, etc.). Matched against ``type(exc).__module__``. 

332_UPSTREAM_MODULE_PREFIXES = ( 

333 "openai", 

334 "httpx", 

335 "requests", 

336 "urllib3", 

337 "anthropic", 

338) 

339 

340 

341def _module_matches(type_module: str, prefix: str) -> bool: 

342 """True if ``type_module`` is ``prefix`` itself or one of its submodules.""" 

343 return type_module == prefix or type_module.startswith(f"{prefix}.") 

344 

345 

346def _format_test_embedding_error(exc: Exception, model: str) -> str: 

347 """Build the user-facing error message for /api/rag/test-embedding. 

348 

349 Categorizes the exception by the module its *class* is defined in so the 

350 UI distinguishes LDR-internal bugs (which the user can't fix and 

351 shouldn't be steered into changing their model over) from real 

352 upstream/provider errors. The previous implementation ran a keyword-match 

353 heuristic over every exception text — see #4208, where 

354 ``NoSettingsContextError`` surfaced as a misleading "try a dedicated 

355 embedding model" suggestion. 

356 

357 Echoing the exception text at all is *opt-in* (CWE-209 / CodeQL alert 

358 8001). Only the allowlisted upstream-provider modules get their detail 

359 surfaced; every other module is treated the way LDR-internal exceptions 

360 already were — class name only. ``sanitize_error_message`` removes 

361 credential *shapes*, not server filesystem paths, SQL text or dependency 

362 internals, and those are exactly what a ``sqlalchemy.exc.*`` or 

363 ``builtins.OSError`` raised under ``get_embedding_function`` carries. 

364 

365 The detail that *is* surfaced goes through 

366 :func:`sanitize_error_for_client` (credential redaction, control-char 

367 strip and a length cap) rather than the bare credential scrubber, so a 

368 provider cannot replay a multi-kilobyte body into the response. 

369 """ 

370 raw_message = str(exc).strip() or type(exc).__name__ 

371 type_name = type(exc).__name__ 

372 type_module = type(exc).__module__ or "" 

373 

374 if _module_matches(type_module, _INTERNAL_MODULE_PREFIX): 

375 # Internal LDR exceptions can carry filesystem paths / SQL fragments 

376 # that sanitize_error_message() does not pattern-match, so we do not 

377 # echo their detail to the browser — the full trace is in the server 

378 # logs (logger.exception at the call site) which the user is asked to 

379 # attach. 

380 return ( 

381 f"Embedding test failed for model '{model}' due to an " 

382 f"internal LDR error ({type_name}). This is a bug in LDR, " 

383 "not your configuration. Please report it on GitHub with " 

384 "the server logs." 

385 ) 

386 

387 if any( 

388 _module_matches(type_module, prefix) 

389 for prefix in _UPSTREAM_MODULE_PREFIXES 

390 ): 

391 return ( 

392 f"Embedding test failed for model '{model}'. The provider " 

393 f"returned an error: {sanitize_error_for_client(raw_message)}" 

394 ) 

395 

396 # Default-deny. Everything outside the upstream allowlist reaches here: 

397 # sqlalchemy.exc.* (the try block opens the user's SQLCipher database, and 

398 # a DBAPIError's str() renders the driver message plus [SQL: ...] and 

399 # [parameters: ...]), builtins.OSError/PermissionError/FileNotFoundError 

400 # (absolute server paths and the OS account name), chromadb, 

401 # sentence_transformers, langchain_core, botocore... None of that is 

402 # touched by the credential-shape scrubber, so the detail is withheld and 

403 # only the class name — a fixed identifier, not reflected input — is 

404 # returned. The full text is in the server logs (logger.exception at the 

405 # call site). 

406 # 

407 # Worded deliberately unlike the internal-LDR branch above: a bare 

408 # TypeError from langchain's response parser is not an LDR bug, and 

409 # steering the user to "report it on GitHub" for it is the misleading 

410 # guidance #4208 removed. 

411 return ( 

412 f"Embedding test failed for model '{model}' ({type_name}). " 

413 "Check the provider URL and model name; the full error is in the " 

414 "server logs." 

415 ) 

416 

417 

418# Config API Routes 

419 

420 

421@router.get("/api/config/supported-formats") 

422def get_supported_formats( 

423 request: Request, username: Annotated[str, Depends(require_auth)] 

424): 

425 """Return list of supported file formats for upload. 

426 

427 This endpoint provides the single source of truth for supported file 

428 extensions, pulling from the document_loaders registry. The UI can 

429 use this to dynamically update the file input accept attribute. 

430 """ 

431 from ...document_loaders import get_supported_extensions 

432 

433 extensions = get_supported_extensions() 

434 # Sort extensions for consistent display 

435 extensions = sorted(extensions) 

436 

437 return { 

438 "extensions": extensions, 

439 "accept_string": ",".join(extensions), 

440 "count": len(extensions), 

441 } 

442 

443 

444# Page Routes 

445 

446 

447@router.get("/embedding-settings") 

448def embedding_settings_page( 

449 request: Request, username: Annotated[str, Depends(require_auth)] 

450): 

451 """Render the Embedding Settings page.""" 

452 return templates.TemplateResponse( 

453 request=request, 

454 name="pages/embedding_settings.html", 

455 context={"request": request, "active_page": "embedding-settings"}, 

456 ) 

457 

458 

459@router.get("/document/{document_id}/chunks") 

460def view_document_chunks( 

461 request: Request, 

462 document_id, 

463 username: Annotated[str, Depends(require_auth)], 

464): 

465 """View all chunks for a document across all collections.""" 

466 from ...database.session_context import get_user_db_session 

467 from ...database.models.library import DocumentChunk 

468 

469 with get_user_db_session(username) as db_session: 

470 # Get document info 

471 document = db_session.query(Document).filter_by(id=document_id).first() 

472 

473 if not document: 

474 # Browser navigation: return text/html, not JSON. These four page 

475 # routes are reached from library.html as ordinary <a href> links, so a 

476 # stale link showed the user a raw {"error": ...} body in the browser's 

477 # JSON viewer. main returned `"Document not found", 404` (text/html) 

478 # here; the sibling /api/ routes keep JSON. 

479 return HTMLResponse("Document not found", status_code=404) 

480 

481 # Get all chunks for this document 

482 chunks = ( 

483 db_session.query(DocumentChunk) 

484 .filter(DocumentChunk.source_id == document_id) 

485 .order_by(DocumentChunk.collection_name, DocumentChunk.chunk_index) 

486 .all() 

487 ) 

488 

489 # Group chunks by collection 

490 chunks_by_collection = {} 

491 for chunk in chunks: 

492 coll_name = chunk.collection_name 

493 if coll_name not in chunks_by_collection: 493 ↛ 507line 493 didn't jump to line 507 because the condition on line 493 was always true

494 # Get collection display name 

495 collection_id = coll_name.replace("collection_", "") 

496 collection = ( 

497 db_session.query(Collection) 

498 .filter_by(id=collection_id) 

499 .first() 

500 ) 

501 chunks_by_collection[coll_name] = { 

502 "name": collection.name if collection else coll_name, 

503 "id": collection_id, 

504 "chunks": [], 

505 } 

506 

507 chunks_by_collection[coll_name]["chunks"].append( 

508 { 

509 "id": chunk.id, 

510 "index": chunk.chunk_index, 

511 "text": chunk.chunk_text, 

512 "word_count": chunk.word_count, 

513 "start_char": chunk.start_char, 

514 "end_char": chunk.end_char, 

515 "embedding_model": chunk.embedding_model, 

516 "embedding_model_type": chunk.embedding_model_type.value 

517 if chunk.embedding_model_type 

518 else None, 

519 "embedding_dimension": chunk.embedding_dimension, 

520 "created_at": chunk.created_at, 

521 } 

522 ) 

523 

524 return templates.TemplateResponse( 

525 request=request, 

526 name="pages/document_chunks.html", 

527 context={ 

528 "request": request, 

529 "document": document, 

530 "chunks_by_collection": chunks_by_collection, 

531 "total_chunks": len(chunks), 

532 }, 

533 ) 

534 

535 

536@router.get("/collections") 

537def collections_page( 

538 request: Request, username: Annotated[str, Depends(require_auth)] 

539): 

540 """Render the Collections page.""" 

541 return templates.TemplateResponse( 

542 request=request, 

543 name="pages/collections.html", 

544 context={"request": request, "active_page": "collections"}, 

545 ) 

546 

547 

548# NOTE: this static route MUST be registered before the parameterized 

549# /collections/{collection_id} below — FastAPI matches in registration 

550# order, so the param route would otherwise swallow /collections/create 

551# as collection_id="create" and render the details page instead of the 

552# create form (fenced by tests/web/routers/test_route_ordering.py). 

553@router.get("/collections/create") 

554def collection_create_page( 

555 request: Request, username: Annotated[str, Depends(require_auth)] 

556): 

557 """Render the Create Collection page.""" 

558 return templates.TemplateResponse( 

559 request=request, 

560 name="pages/collection_create.html", 

561 context={"request": request, "active_page": "collections"}, 

562 ) 

563 

564 

565def _validated_collection_id(collection_id: str) -> str: 

566 """Return `collection_id` if it is a well-formed collection id, else 404. 

567 

568 Collection ids are server-generated UUID4 strings (`Collection.id` is 

569 String(36), assigned from `str(uuid.uuid4())`), so anything else is either 

570 a typo or an injection attempt and cannot match a real row. 

571 

572 This is the fix for a stored-reflection XSS: the page templates interpolate 

573 this value into an inline `onclick` handler, and HTML-escaping is NOT 

574 sufficient there -- the browser entity-decodes attribute content before 

575 treating it as JS, so an escaped quote reopens the string. Validating the 

576 shape at the boundary means no template can receive a value capable of 

577 breaking out, regardless of the context it is interpolated into. That is 

578 strictly more robust than fixing the one template, because it also covers 

579 collection_details.html and anything added later. 

580 

581 (`/collections/create` is a separate static route registered ahead of the 

582 parameterised ones, so it never reaches this.) 

583 """ 

584 try: 

585 uuid.UUID(str(collection_id)) 

586 except (ValueError, AttributeError, TypeError): 

587 logger.warning(f"Rejected malformed collection_id: {collection_id!r}") 

588 raise HTTPException(status_code=404, detail="Collection not found") 

589 return str(collection_id) 

590 

591 

592@router.get("/collections/{collection_id}") 

593def collection_details_page( 

594 request: Request, 

595 collection_id, 

596 username: Annotated[str, Depends(require_auth)], 

597): 

598 """Render the Collection Details page.""" 

599 collection_id = _validated_collection_id(collection_id) 

600 return templates.TemplateResponse( 

601 request=request, 

602 name="pages/collection_details.html", 

603 context={ 

604 "active_page": "collections", 

605 "collection_id": collection_id, 

606 }, 

607 ) 

608 

609 

610@router.get("/collections/{collection_id}/upload") 

611def collection_upload_page( 

612 request: Request, 

613 collection_id, 

614 username: Annotated[str, Depends(require_auth)], 

615): 

616 """Render the Collection Upload page.""" 

617 collection_id = _validated_collection_id(collection_id) 

618 

619 from ...database.session_context import get_user_db_session 

620 from ...utilities.db_utils import get_settings_manager 

621 

622 # Get the upload PDF storage setting 

623 with get_user_db_session(username) as db_session: 

624 settings = get_settings_manager(db_session, username) 

625 upload_pdf_storage = settings.get_setting( 

626 "research_library.upload_pdf_storage", "none" 

627 ) 

628 # Only allow valid values for uploads (no filesystem) 

629 if upload_pdf_storage not in ("database", "none"): 

630 upload_pdf_storage = "none" 

631 

632 return templates.TemplateResponse( 

633 request=request, 

634 name="pages/collection_upload.html", 

635 context={ 

636 "active_page": "collections", 

637 "collection_id": collection_id, 

638 "collection_name": None, 

639 "upload_pdf_storage": upload_pdf_storage, 

640 }, 

641 ) 

642 

643 

644# API Routes 

645 

646 

647@router.get("/api/rag/settings") 

648def get_current_settings( 

649 request: Request, 

650 username: Annotated[str, Depends(require_auth)], 

651): 

652 """Get current RAG configuration from settings.""" 

653 from ...database.session_context import get_user_db_session 

654 

655 try: 

656 with get_user_db_session(username) as db_session: 

657 settings = get_settings_manager(db_session, username) 

658 

659 return { 

660 "success": True, 

661 "settings": { 

662 "embedding_provider": settings.get_setting( 

663 "local_search_embedding_provider", 

664 DEFAULT_LOCAL_SEARCH_PROVIDER, 

665 ), 

666 "embedding_model": settings.get_setting( 

667 "local_search_embedding_model", 

668 DEFAULT_LOCAL_SEARCH_MODEL, 

669 ), 

670 "chunk_size": settings.get_setting( 

671 "local_search_chunk_size", 

672 DEFAULT_LOCAL_SEARCH_CHUNK_SIZE, 

673 ), 

674 "chunk_overlap": settings.get_setting( 

675 "local_search_chunk_overlap", 

676 DEFAULT_LOCAL_SEARCH_CHUNK_OVERLAP, 

677 ), 

678 "splitter_type": settings.get_setting( 

679 "local_search_splitter_type", 

680 DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE, 

681 ), 

682 "text_separators": _get_text_separators(settings), 

683 "distance_metric": settings.get_setting( 

684 "local_search_distance_metric", 

685 DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC, 

686 ), 

687 "normalize_vectors": settings.get_setting( 

688 "local_search_normalize_vectors", 

689 DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS, 

690 ), 

691 "index_type": settings.get_setting( 

692 "local_search_index_type", 

693 DEFAULT_LOCAL_SEARCH_INDEX_TYPE, 

694 ), 

695 }, 

696 } 

697 except Exception as e: 

698 return handle_api_error("getting RAG settings", e) 

699 

700 

701@router.post("/api/rag/test-embedding") 

702async def test_embedding( 

703 request: Request, 

704 username: Annotated[str, Depends(require_auth)], 

705): 

706 """Test an embedding configuration by generating a test embedding.""" 

707 from ...database.session_context import get_user_db_session 

708 from ...utilities.db_utils import get_settings_manager 

709 

710 # Pre-bound: the except block below formats an error using these, so a 

711 # malformed/empty body would otherwise raise UnboundLocalError from 

712 # inside the handler's own error path. 

713 provider = None 

714 model = None 

715 try: 

716 data = await request.json() 

717 if not isinstance(data, dict): 

718 return json_body_error("success", "Request body must be valid JSON") 

719 provider = data.get("provider") 

720 model = data.get("model") 

721 test_text = data.get("test_text", "This is a test.") 

722 

723 if not provider or not model: 

724 return JSONResponse( 

725 {"success": False, "error": "Provider and model are required"}, 

726 status_code=400, 

727 ) 

728 

729 # Import embedding functions 

730 from ...embeddings.embeddings_config import ( 

731 get_embedding_function, 

732 ) 

733 

734 logger.info( 

735 f"Testing embedding with provider={provider}, model={model}" 

736 ) 

737 

738 # Get user's settings so provider URLs (e.g. Ollama) are resolved correctly. 

739 # Sync SQLAlchemy + a network-bound embedding call — both must run in a 

740 # thread, never on the uvicorn event loop. 

741 def _run_embedding_test() -> tuple[Any, int]: 

742 with get_user_db_session(username) as db_session: 

743 settings = get_settings_manager(db_session, username) 

744 settings_snapshot = ( 

745 settings.get_all_settings() 

746 if hasattr(settings, "get_all_settings") 

747 else {} 

748 ) 

749 start = time.time() 

750 ef = get_embedding_function( 

751 provider=provider, 

752 model_name=model, 

753 settings_snapshot=settings_snapshot, 

754 ) 

755 emb = ef([test_text])[0] 

756 return emb, int((time.time() - start) * 1000) 

757 

758 # run_db_sync (not raw to_thread): the thunk opens a 

759 # get_user_db_session block, and to_thread's reused workers would 

760 # keep the user's thread-local session attached after the task. 

761 embedding, response_time_ms = await run_db_sync(_run_embedding_test) 

762 

763 # Get embedding dimension 

764 dimension = len(embedding) if hasattr(embedding, "__len__") else None 

765 

766 return { 

767 "success": True, 

768 "dimension": dimension, 

769 "response_time_ms": response_time_ms, 

770 "provider": provider, 

771 "model": model, 

772 } 

773 

774 except json.JSONDecodeError: 

775 # Caught here (ahead of the broad `except Exception` below) so a 

776 # malformed body gets the route's own "success"-shaped 400 instead 

777 # of falling into `_format_test_embedding_error`, which would echo 

778 # the raw decoder message (e.g. "Expecting value: line 1 column 1") 

779 # back to the client as part of a hardcoded 500. 

780 return json_body_error("success", "Request body must be valid JSON") 

781 except Exception as e: 

782 # Egress policy denial → clean 4xx (the embedding never fired). 

783 from ...security.egress.policy import PolicyDeniedError 

784 

785 if isinstance(e, PolicyDeniedError): 785 ↛ 786line 785 didn't jump to line 786 because the condition on line 785 was never true

786 return JSONResponse( 

787 { 

788 "success": False, 

789 "error": ( 

790 f"Embedding provider '{provider}' refused by egress " 

791 f"policy ({e.decision.reason}). Disable 'Require " 

792 "local embeddings' or pick a local provider." 

793 ), 

794 }, 

795 status_code=400, 

796 ) 

797 logger.exception("Error during testing embedding") 

798 user_message = _format_test_embedding_error(e, model) 

799 # CWE-209 (CodeQL "Information exposure through an exception"): 

800 # user_message is exception-derived, but _format_test_embedding_error() 

801 # echoes exception text only for the allowlisted upstream-provider 

802 # modules, and only after sanitize_error_for_client(). Every other 

803 # exception — LDR-internal, sqlalchemy.exc.*, OSError, chromadb... — 

804 # is reduced to its class name, so no server path, SQL fragment or 

805 # dependency internal reaches this response. See that function's 

806 # docstring for the full rationale. 

807 return JSONResponse( 

808 {"success": False, "error": user_message}, status_code=500 

809 ) 

810 

811 

812@router.get("/api/rag/models") 

813def get_available_models( 

814 request: Request, 

815 username: Annotated[str, Depends(require_auth)], 

816): 

817 """Get list of available embedding providers and models.""" 

818 from ...database.session_context import get_user_db_session 

819 from ...utilities.db_utils import get_settings_manager 

820 

821 try: 

822 from ...embeddings.embeddings_config import _get_provider_classes 

823 from ...security.egress.policy import ( 

824 Decision, 

825 PolicyDeniedError, 

826 ) 

827 

828 # Get current settings for providers. STRICT snapshot is mandatory 

829 # at this policy-sensitive seam: a non-strict read silently falls 

830 # back to JSON defaults when the underlying settings query fails 

831 # (SQLAlchemyError / stale enum row), and a defaults-only snapshot 

832 # can lack the operator-selected scope/provider inputs that the 

833 # local-only posture is supposed to enforce. We refuse before any 

834 # provider discovery, credential read, or network probe instead of 

835 # admitting a cloud embedder under a permissive fallback. 

836 with get_user_db_session(username) as db_session: 

837 settings = get_settings_manager(db_session, username) 

838 try: 

839 settings_snapshot = ( 

840 settings.get_all_settings(strict=True) 

841 if hasattr(settings, "get_all_settings") 

842 else {} 

843 ) 

844 except PolicyDeniedError: 

845 raise 

846 except Exception as exc: 

847 raise PolicyDeniedError( 

848 Decision(False, "settings_unavailable"), 

849 target="available_rag_models", 

850 ) from exc 

851 

852 # Get provider classes 

853 provider_classes = _get_provider_classes() 

854 

855 # Egress policy: fetching a cloud provider's model list opens a 

856 # connection that carries the API key off-machine. Under an effective 

857 # local-only embeddings posture (PRIVATE_ONLY / adaptive-private) we 

858 # must NOT probe remote-classified providers — mirror the PEP in 

859 # embeddings_config.get_embeddings(). Build the run context once and 

860 # fail closed (skip remote model fetches) if the policy can't evaluate. 

861 def _embeddings_model_fetch_allowed(provider_key: str) -> bool: 

862 try: 

863 from ...security.egress.policy import ( 

864 context_from_snapshot, 

865 evaluate_embeddings, 

866 ) 

867 from ...config.thread_settings import ( 

868 get_setting_from_snapshot, 

869 ) 

870 from ...search_system import username_from_snapshot 

871 

872 primary = ( 

873 get_setting_from_snapshot( 

874 "search.tool", 

875 default=DEFAULT_SEARCH_TOOL, 

876 settings_snapshot=settings_snapshot, 

877 ) 

878 or DEFAULT_SEARCH_TOOL 

879 ) 

880 # Thread username so a per-user private retriever primary 

881 # resolves PRIVATE_ONLY (forcing local embeddings) and this 

882 # route can't probe a remote embedder for that user. 

883 ctx = context_from_snapshot( 

884 settings_snapshot, 

885 primary, 

886 # `session.get(...)` here was a leftover Flask global -- 

887 # undefined in this module, so this line raised NameError 

888 # on EVERY call (the snapshot never carries `_username`: 

889 # only ensure_snapshot_username injects it, and this route 

890 # does not call it). The enclosing `except Exception` failed 

891 # closed and returned False, so the model-list probe was 

892 # skipped for every provider and the embeddings dropdown 

893 # was permanently empty for every user. `username` is the 

894 # route's authenticated user, closed over from 

895 # get_available_models. 

896 username=username_from_snapshot(settings_snapshot) 

897 or username, 

898 ) 

899 if not ctx.require_local_embeddings: 899 ↛ 901line 899 didn't jump to line 901 because the condition on line 899 was always true

900 return True 

901 decision = evaluate_embeddings( 

902 provider_key, ctx, settings_snapshot=settings_snapshot 

903 ) 

904 if not decision.allowed: 

905 logger.bind(policy_audit=True).info( 

906 "skipping embeddings model-list probe " 

907 "(local-only egress posture)", 

908 provider=provider_key, 

909 reason=decision.reason, 

910 ) 

911 return decision.allowed 

912 except Exception: 

913 # Fail closed: a policy/snapshot error must not open a cloud 

914 # probe under a local-only posture. 

915 logger.bind(policy_audit=True).warning( 

916 "embeddings model-list egress check failed; skipping probe", 

917 provider=provider_key, 

918 exc_info=True, 

919 ) 

920 return False 

921 

922 # Provider display names 

923 provider_labels = { 

924 "sentence_transformers": "Sentence Transformers (Local)", 

925 "ollama": "Ollama (Local)", 

926 "openai": "OpenAI API", 

927 } 

928 

929 # Get provider options and models by looping through providers 

930 provider_options = [] 

931 providers = {} 

932 

933 for provider_key, provider_class in provider_classes.items(): 

934 available = provider_class.is_available(settings_snapshot) 

935 

936 # Always show the provider in the dropdown so users can 

937 # configure its settings (e.g. fix a wrong Ollama URL). 

938 provider_options.append( 

939 { 

940 "value": provider_key, 

941 "label": provider_labels.get(provider_key, provider_key), 

942 "available": available, 

943 } 

944 ) 

945 

946 # Only fetch models when the provider is reachable AND egress 

947 # policy permits probing it (cloud providers are skipped under a 

948 # local-only posture). 

949 if available and _embeddings_model_fetch_allowed(provider_key): 

950 models = provider_class.get_available_models(settings_snapshot) 

951 providers[provider_key] = [ 

952 { 

953 "value": m["value"], 

954 "label": m["label"], 

955 "provider": provider_key, 

956 **( 

957 {"is_embedding": m["is_embedding"]} 

958 if "is_embedding" in m 

959 else {} 

960 ), 

961 } 

962 for m in models 

963 ] 

964 else: 

965 providers[provider_key] = [] 

966 

967 return { 

968 "success": True, 

969 "provider_options": provider_options, 

970 "providers": providers, 

971 } 

972 

973 except Exception as e: 

974 # Egress policy denial (incl. settings_unavailable) → clean 4xx/5xx 

975 # so the caller distinguishes a refused probe from a server crash. 

976 from ...security.egress.policy import PolicyDeniedError as _PDE 

977 

978 if isinstance(e, _PDE): 

979 reason = e.decision.reason 

980 status_code = 503 if reason == "settings_unavailable" else 400 

981 logger.bind(policy_audit=True).info( 

982 "available-models refused by egress policy", 

983 reason=reason, 

984 target=e.target, 

985 ) 

986 return JSONResponse( 

987 { 

988 "success": False, 

989 "error": ( 

990 "Settings are currently unavailable; cannot safely " 

991 "enumerate embedding models." 

992 ) 

993 if reason == "settings_unavailable" 

994 else f"Available-models request refused ({reason}).", 

995 }, 

996 status_code=status_code, 

997 ) 

998 return handle_api_error("getting available models", e) 

999 

1000 

1001@router.get("/api/rag/info") 

1002def get_index_info( 

1003 request: Request, username: Annotated[str, Depends(require_auth)] 

1004): 

1005 """Get information about the current RAG index.""" 

1006 from ...database.library_init import get_default_library_id 

1007 

1008 try: 

1009 # Get collection_id from request or use default Library collection 

1010 collection_id = request.query_params.get("collection_id") 

1011 if not collection_id: 

1012 collection_id = get_default_library_id(username) 

1013 

1014 logger.info( 

1015 f"Getting RAG index info for collection_id: {collection_id}" 

1016 ) 

1017 

1018 # `with`: LocalEmbeddingManager eagerly opens httpx client pairs for 

1019 # Ollama embeddings, so an unclosed service leaks file descriptors on 

1020 # every call (#4407). This is a hot endpoint — the collection page 

1021 # hits it on each load. 

1022 with get_rag_service(request, username, collection_id) as rag_service: 

1023 info = rag_service.get_current_index_info(collection_id) 

1024 

1025 if info is None: 

1026 logger.info( 

1027 f"No RAG index found for collection_id: {collection_id}" 

1028 ) 

1029 return {"success": True, "info": None, "message": "No index found"} 

1030 

1031 logger.info(f"Found RAG index for collection_id: {collection_id}") 

1032 return {"success": True, "info": info} 

1033 except Exception as e: 

1034 return handle_api_error("getting index info", e) 

1035 

1036 

1037@router.get("/api/rag/stats") 

1038def get_rag_stats( 

1039 request: Request, username: Annotated[str, Depends(require_auth)] 

1040): 

1041 """Get RAG statistics for a collection.""" 

1042 from ...database.library_init import get_default_library_id 

1043 

1044 try: 

1045 # Get collection_id from request or use default Library collection 

1046 collection_id = request.query_params.get("collection_id") 

1047 if not collection_id: 

1048 collection_id = get_default_library_id(username) 

1049 

1050 with get_rag_service(request, username, collection_id) as rag_service: 

1051 stats = rag_service.get_rag_stats(collection_id) 

1052 

1053 return {"success": True, "stats": stats} 

1054 except Exception as e: 

1055 return handle_api_error("getting RAG stats", e) 

1056 

1057 

1058@router.post("/api/rag/index-document") 

1059async def index_document( 

1060 request: Request, username: Annotated[str, Depends(require_auth)] 

1061): 

1062 """Index a single document in a collection.""" 

1063 from ...database.library_init import get_default_library_id 

1064 

1065 text_doc_id = None 

1066 try: 

1067 data = await request.json() 

1068 if not isinstance(data, dict): 

1069 return json_body_error("success", "Request body must be valid JSON") 

1070 text_doc_id = data.get("text_doc_id") 

1071 force_reindex = data.get("force_reindex", False) 

1072 collection_id = data.get("collection_id") 

1073 

1074 if not isinstance(force_reindex, bool): 

1075 return JSONResponse( 

1076 { 

1077 "success": False, 

1078 "error": "force_reindex must be a boolean", 

1079 }, 

1080 status_code=400, 

1081 ) 

1082 

1083 if not text_doc_id: 

1084 return JSONResponse( 

1085 {"success": False, "error": "text_doc_id is required"}, 

1086 status_code=400, 

1087 ) 

1088 

1089 if not collection_id: 

1090 # Opens the user's SQLCipher session (sync) — threadpool it. 

1091 collection_id = await run_db_sync(get_default_library_id, username) 

1092 

1093 def _index_sync(): 

1094 with get_rag_service( 

1095 request, username, collection_id 

1096 ) as rag_service: 

1097 return rag_service.index_document( 

1098 text_doc_id, collection_id, force_reindex 

1099 ) 

1100 

1101 result = await run_db_sync(_index_sync) 

1102 

1103 if result["status"] == "error": 

1104 return JSONResponse( 

1105 {"success": False, "error": result.get("error")}, 

1106 status_code=400, 

1107 ) 

1108 

1109 return {"success": True, "result": result} 

1110 except json.JSONDecodeError: 

1111 return json_body_error("success", "Request body must be valid JSON") 

1112 except Exception as e: 

1113 return handle_api_error(f"indexing document {text_doc_id}", e) 

1114 

1115 

1116@router.post("/api/rag/remove-document") 

1117async def remove_document( 

1118 request: Request, username: Annotated[str, Depends(require_auth)] 

1119): 

1120 """Remove a document from RAG in a collection.""" 

1121 from ...database.library_init import get_default_library_id 

1122 

1123 # Pre-bound for the same reason as test_embedding above: the except 

1124 # block interpolates text_doc_id into its error message. 

1125 text_doc_id = None 

1126 try: 

1127 data = await request.json() 

1128 if not isinstance(data, dict): 

1129 return json_body_error("success", "Request body must be valid JSON") 

1130 text_doc_id = data.get("text_doc_id") 

1131 collection_id = data.get("collection_id") 

1132 

1133 if not text_doc_id: 

1134 return JSONResponse( 

1135 {"success": False, "error": "text_doc_id is required"}, 

1136 status_code=400, 

1137 ) 

1138 

1139 if not collection_id: 1139 ↛ 1143line 1139 didn't jump to line 1143 because the condition on line 1139 was always true

1140 # Opens the user's SQLCipher session (sync) — threadpool it. 

1141 collection_id = await run_db_sync(get_default_library_id, username) 

1142 

1143 def _remove_sync(): 

1144 with get_rag_service( 

1145 request, username, collection_id 

1146 ) as rag_service: 

1147 return rag_service.remove_document_from_rag( 

1148 text_doc_id, collection_id 

1149 ) 

1150 

1151 result = await run_db_sync(_remove_sync) 

1152 

1153 if result["status"] == "error": 

1154 return JSONResponse( 

1155 {"success": False, "error": result.get("error")}, 

1156 status_code=400, 

1157 ) 

1158 

1159 return {"success": True, "result": result} 

1160 except json.JSONDecodeError: 

1161 return json_body_error("success", "Request body must be valid JSON") 

1162 except Exception as e: 

1163 return handle_api_error(f"removing document {text_doc_id}", e) 

1164 

1165 

1166@router.get("/api/rag/index-all") 

1167def index_all( 

1168 request: Request, 

1169 username: Annotated[str, Depends(require_auth)], 

1170): 

1171 """Index all documents in a collection with Server-Sent Events progress.""" 

1172 from ...database.session_context import get_user_db_session 

1173 from ...database.library_init import get_default_library_id 

1174 from ...utilities.db_utils import get_settings_manager 

1175 

1176 # Parse as bool — a raw string "false" is truthy, which would silently 

1177 # force a full reindex for every caller that asked to skip already-indexed 

1178 # documents. 

1179 force_reindex = ( 

1180 request.query_params.get("force_reindex", "false").lower() == "true" 

1181 ) 

1182 

1183 # Get collection_id from request or use default Library collection 

1184 collection_id = request.query_params.get("collection_id") 

1185 if not collection_id: 

1186 collection_id = get_default_library_id(username) 

1187 

1188 logger.info( 

1189 f"Starting index-all for collection_id: {collection_id}, force_reindex: {force_reindex}" 

1190 ) 

1191 

1192 # Create RAG service in request context before generator runs. 

1193 # use_defaults=force_reindex mirrors index_collection / the background 

1194 # worker so a force-reindex resolves the same embedding config. 

1195 rag_service = get_rag_service( 

1196 request, username, collection_id, use_defaults=force_reindex 

1197 ) 

1198 

1199 # Fetch batch size from settings in this thread before generator runs 

1200 with get_user_db_session(username) as db_session: 

1201 settings = get_settings_manager(db_session, username) 

1202 batch_size = int(settings.get_setting("rag.indexing_batch_size", 15)) 

1203 try: 

1204 _max_workers = int( 

1205 settings.get_setting("rag.indexing_max_parallel_docs", 4) 

1206 ) 

1207 except Exception: 

1208 _max_workers = 4 

1209 _max_workers = max(1, min(_max_workers, 16)) 

1210 

1211 def generate(): 

1212 """Generator function for SSE progress updates.""" 

1213 _sse_cancel = threading.Event() 

1214 try: 

1215 # Send initial status 

1216 yield f"data: {json.dumps({'type': 'start', 'message': 'Starting bulk indexing...'})}\n\n" 

1217 

1218 # Get document IDs to index from DocumentCollection. All DB work 

1219 # happens inside this SHORT-LIVED session; NO ``yield`` runs while 

1220 # it is held (the "collection not found" error is yielded AFTER the 

1221 # block) — keeping a get_user_db_session scope open across a yield 

1222 # risks the Starlette/anyio thread-affinity corruption fixed in 

1223 # download_bulk (__enter__/__exit__ can land on different threads). 

1224 collection_found = True 

1225 doc_info = [] 

1226 with get_user_db_session(username) as db_session: 

1227 # Persist embedding metadata + clean up on force-reindex via the 

1228 # shared helpers, so this bulk route behaves like the 

1229 # single-collection SSE route and the background worker 

1230 # (previously it stored no embedding_dimension and never reset 

1231 # stale chunks/indices on force-reindex). 

1232 collection = ( 

1233 db_session.query(Collection) 

1234 .filter_by(id=collection_id) 

1235 .first() 

1236 ) 

1237 

1238 if not collection: 

1239 collection_found = False 

1240 else: 

1241 # Persist the new embedding config AND reset the old 

1242 # chunks/RAGIndex in ONE transaction. Two separate commits 

1243 # would leave a crash window where the Collection carries 

1244 # the NEW embedding config while the OLD RAGIndex row + 

1245 # FAISS file (built with the old config) still exist — a 

1246 # config/index mismatch. 

1247 changed = False 

1248 faiss_reset_paths = [] 

1249 if collection.embedding_model is None or force_reindex: 

1250 _store_collection_embedding_metadata( 

1251 collection, rag_service 

1252 ) 

1253 changed = True 

1254 if force_reindex: 

1255 faiss_reset_paths = _reset_collection_for_reindex( 

1256 db_session, collection_id 

1257 ) 

1258 changed = True 

1259 if changed: 

1260 db_session.commit() 

1261 _unlink_reindex_faiss_files(faiss_reset_paths) 

1262 

1263 doc_info = [ 

1264 (doc.id, doc.title) 

1265 for _dc, doc in _query_documents_to_index( 

1266 db_session, collection_id, force_reindex 

1267 ) 

1268 ] 

1269 

1270 if not collection_found: 

1271 yield f"data: {json.dumps({'type': 'error', 'error': 'Collection not found'})}\n\n" 

1272 return 

1273 

1274 if not doc_info: 

1275 yield f"data: {json.dumps({'type': 'complete', 'results': {'successful': 0, 'skipped': 0, 'failed': 0, 'message': 'No documents to index'}})}\n\n" 

1276 return 

1277 

1278 results = {"successful": 0, "skipped": 0, "failed": 0, "errors": []} 

1279 total = len(doc_info) 

1280 

1281 # Process documents in batches to pace SSE progress events. 

1282 # The batch boundary is purely cosmetic now — documents within 

1283 # a batch run in parallel via the bounded worker pool below. 

1284 # batch_size and _max_workers are read from settings before the 

1285 # generator starts (closure capture) — no DB work in-stream. 

1286 processed = 0 

1287 

1288 for i in range(0, len(doc_info), batch_size): 

1289 batch = doc_info[i : i + batch_size] 

1290 

1291 # Run the per-batch docs through the bounded parallel 

1292 # helper. ``as_completed`` order means progress events 

1293 # fire in completion order, not submission order — the 

1294 # `processed` counter below keeps the SSE payload 

1295 # monotonically increasing so the UI percent stays sane. 

1296 # ``is_cancelled`` polls the SSE-disconnect event so an 

1297 # early-disconnect watcher (or this generator's own 

1298 # ``finally``) can short-circuit the batch before all 

1299 # queued futures run. The helper still drains in-flight 

1300 # workers via ``pool.shutdown(wait=True, ...)`` so no 

1301 # worker outlives the helper return — see 

1302 # ``index_documents_parallel`` for the rationale. 

1303 parallel_result = rag_service.index_documents_parallel( 

1304 batch, 

1305 collection_id, 

1306 force_reindex=force_reindex, 

1307 max_workers=_max_workers, 

1308 is_cancelled=_sse_cancel.is_set, 

1309 ) 

1310 batch_results = parallel_result["results"] 

1311 

1312 for doc_id, title in batch: 

1313 processed += 1 

1314 result = batch_results[doc_id] 

1315 

1316 # Send progress update 

1317 yield f"data: {json.dumps({'type': 'progress', 'current': processed, 'total': total, 'title': title, 'percent': int((processed / total) * 100)})}\n\n" 

1318 

1319 if result["status"] == "success": 1319 ↛ 1320line 1319 didn't jump to line 1320 because the condition on line 1319 was never true

1320 results["successful"] += 1 

1321 elif result["status"] in ("skipped", "cleared"): 1321 ↛ 1325line 1321 didn't jump to line 1325 because the condition on line 1321 was always true

1322 # "cleared" (empty-text purge) is a handled non-failure. 

1323 results["skipped"] += 1 

1324 else: 

1325 results["failed"] += 1 

1326 results["errors"].append( 

1327 { 

1328 "doc_id": doc_id, 

1329 "title": title, 

1330 "error": result.get("error"), 

1331 } 

1332 ) 

1333 

1334 # Send completion status 

1335 yield f"data: {json.dumps({'type': 'complete', 'results': results})}\n\n" 

1336 

1337 # Log final status for debugging 

1338 logger.info( 

1339 f"Bulk indexing complete: {results['successful']} successful, {results['skipped']} skipped, {results['failed']} failed" 

1340 ) 

1341 

1342 except Exception: 

1343 logger.exception("Error in bulk indexing") 

1344 yield f"data: {json.dumps({'type': 'error', 'error': 'An internal error occurred during indexing'})}\n\n" 

1345 finally: 

1346 # Generator ``finally`` runs at stream completion (or client 

1347 # disconnect via ``GeneratorExit``) — the safe place to release 

1348 # the RAG service's embedding-manager httpx clients. Closing at 

1349 # the outer route scope would tear it down before the streamed 

1350 # generator runs. ``safe_close`` swallows close-time errors so 

1351 # a broken Ollama doesn't mask the original generator outcome. 

1352 # 

1353 # Signal cancellation BEFORE closing: the helper has already 

1354 # drained in-flight workers (its ``shutdown(wait=True, ...)`` 

1355 # guarantees no worker outlives the helper return), so setting 

1356 # the event here is for any in-progress batch loop iteration 

1357 # and any future code path that bypasses the helper. 

1358 _sse_cancel.set() 

1359 safe_close(rag_service, "rag_service (index-all SSE)") 

1360 

1361 return WorkerCleanupStreamingResponse( 

1362 generate(), 

1363 media_type="text/event-stream", 

1364 headers={ 

1365 "Cache-Control": "no-cache, no-transform", 

1366 "X-Accel-Buffering": "no", 

1367 }, 

1368 ) 

1369 

1370 

1371@router.post("/api/rag/configure") 

1372async def configure_rag( 

1373 request: Request, 

1374 username: Annotated[str, Depends(require_auth)], 

1375): 

1376 """ 

1377 Change RAG configuration (embedding model, chunk size, etc.). 

1378 This will create a new index with the new configuration. 

1379 """ 

1380 from ...database.session_context import get_user_db_session 

1381 from ...utilities.db_utils import get_settings_manager 

1382 

1383 try: 

1384 data = await request.json() 

1385 if not isinstance(data, dict): 

1386 return json_body_error("success", "Request body must be valid JSON") 

1387 embedding_model = data.get("embedding_model") 

1388 embedding_provider = data.get("embedding_provider") 

1389 chunk_size = data.get("chunk_size") 

1390 chunk_overlap = data.get("chunk_overlap") 

1391 collection_id = data.get("collection_id") 

1392 

1393 # Get new advanced settings (with defaults) 

1394 splitter_type = data.get( 

1395 "splitter_type", DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE 

1396 ) 

1397 text_separators = data.get( 

1398 "text_separators", DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS 

1399 ) 

1400 distance_metric = data.get( 

1401 "distance_metric", DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC 

1402 ) 

1403 normalize_vectors = data.get( 

1404 "normalize_vectors", DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS 

1405 ) 

1406 index_type = data.get("index_type", DEFAULT_LOCAL_SEARCH_INDEX_TYPE) 

1407 

1408 if ( 

1409 not embedding_model 

1410 or not embedding_provider 

1411 or chunk_size is None 

1412 or chunk_overlap is None 

1413 ): 

1414 return JSONResponse( 

1415 { 

1416 "success": False, 

1417 "error": "All configuration parameters are required (embedding_model, embedding_provider, chunk_size, chunk_overlap", 

1418 }, 

1419 status_code=400, 

1420 ) 

1421 

1422 if type(chunk_size) is not int or not ( 

1423 _RAG_CHUNK_SIZE_MIN <= chunk_size <= _RAG_CHUNK_SIZE_MAX 

1424 ): 

1425 return JSONResponse( 

1426 { 

1427 "success": False, 

1428 "error": ( 

1429 "chunk_size must be an integer between " 

1430 f"{_RAG_CHUNK_SIZE_MIN} and {_RAG_CHUNK_SIZE_MAX}" 

1431 ), 

1432 }, 

1433 status_code=400, 

1434 ) 

1435 

1436 if type(chunk_overlap) is not int or not ( 

1437 _RAG_CHUNK_OVERLAP_MIN <= chunk_overlap <= _RAG_CHUNK_OVERLAP_MAX 

1438 ): 

1439 return JSONResponse( 

1440 { 

1441 "success": False, 

1442 "error": ( 

1443 "chunk_overlap must be an integer between " 

1444 f"{_RAG_CHUNK_OVERLAP_MIN} and " 

1445 f"{_RAG_CHUNK_OVERLAP_MAX}" 

1446 ), 

1447 }, 

1448 status_code=400, 

1449 ) 

1450 

1451 if chunk_overlap > chunk_size: 

1452 return JSONResponse( 

1453 { 

1454 "success": False, 

1455 "error": ( 

1456 "chunk_overlap must be less than or equal to chunk_size" 

1457 ), 

1458 }, 

1459 status_code=400, 

1460 ) 

1461 

1462 if not isinstance(normalize_vectors, bool): 

1463 return JSONResponse( 

1464 { 

1465 "success": False, 

1466 "error": "normalize_vectors must be a boolean", 

1467 }, 

1468 status_code=400, 

1469 ) 

1470 

1471 # The local_search_text_separators setting is registered with 

1472 # ui_element "json", so store the separators as a proper list. A 

1473 # string payload (e.g. a textarea value) is parsed into a list 

1474 # first; anything that isn't a JSON array of strings is rejected 

1475 # outright (400) rather than silently coerced to defaults, so a 

1476 # malformed payload can't get written into settings or fed into 

1477 # the chunker. 

1478 text_separators = await asyncio.to_thread( 

1479 _parse_configured_text_separators, text_separators 

1480 ) 

1481 if text_separators is None: 

1482 return JSONResponse( 

1483 { 

1484 "success": False, 

1485 "error": "text_separators must be a JSON array of strings", 

1486 }, 

1487 status_code=400, 

1488 ) 

1489 

1490 # All settings writes + the collection reconfiguration (FAISS index 

1491 # creation) run sync I/O. Offload to a worker thread so the uvicorn 

1492 # event loop is free to serve other requests during the potentially 

1493 # slow index-create path. 

1494 def _persist_configuration(): 

1495 """Persist the settings and (optionally) create/promote the 

1496 collection's index in ONE database transaction. 

1497 

1498 Settings and the selected index must land atomically: a 

1499 mid-way failure (locked/environment-locked setting, DB error) 

1500 must leave BOTH unchanged, never commit the settings while the 

1501 old index stays "current" (or vice versa) — that mismatch is 

1502 exactly what would let a subsequent search silently run 

1503 against a stale/inconsistent configuration. 

1504 

1505 Returns a ``JSONResponse`` for an error that should short- 

1506 circuit the request, or the new/reused index hash (``None`` 

1507 when no ``collection_id`` was supplied) on success. 

1508 """ 

1509 with get_user_db_session(username) as db_session: 

1510 settings = get_settings_manager(db_session, username) 

1511 

1512 requested_settings = ( 

1513 ("local_search_embedding_model", embedding_model), 

1514 ("local_search_embedding_provider", embedding_provider), 

1515 ("local_search_chunk_size", int(chunk_size)), 

1516 ("local_search_chunk_overlap", int(chunk_overlap)), 

1517 ("local_search_splitter_type", splitter_type), 

1518 ("local_search_text_separators", text_separators), 

1519 ("local_search_distance_metric", distance_metric), 

1520 ( 

1521 "local_search_normalize_vectors", 

1522 normalize_vectors, 

1523 ), 

1524 ("local_search_index_type", index_type), 

1525 ) 

1526 requested_setting_keys = [ 

1527 key for key, _value in requested_settings 

1528 ] 

1529 

1530 if settings.settings_locked: 

1531 return JSONResponse( 

1532 { 

1533 "success": False, 

1534 "error": "RAG configuration is locked by app.lock_settings", 

1535 }, 

1536 status_code=403, 

1537 ) 

1538 

1539 # Environment-locked settings (LDR_* env vars) must never be 

1540 # overwritten through a settings-mutation endpoint. Refuse 

1541 # the WHOLE request up front, before any write, rather than 

1542 # silently skipping just the locked keys — that would both 

1543 # mislead the caller with a 200 and partially apply an 

1544 # inconsistent configuration. 

1545 environment_locked_keys = [ 

1546 key 

1547 for key, _value in requested_settings 

1548 if check_env_setting(key) is not None 

1549 ] 

1550 if environment_locked_keys: 

1551 return JSONResponse( 

1552 { 

1553 "success": False, 

1554 "error": ( 

1555 "Settings " 

1556 f"{', '.join(environment_locked_keys)} " 

1557 "are environment-locked" 

1558 ), 

1559 }, 

1560 status_code=403, 

1561 ) 

1562 

1563 for key, value in requested_settings: 

1564 if not settings.set_setting(key, value, commit=False): 

1565 db_session.rollback() 

1566 return JSONResponse( 

1567 { 

1568 "success": False, 

1569 "error": "Unable to save RAG configuration. No changes were applied.", 

1570 }, 

1571 status_code=500, 

1572 ) 

1573 

1574 index_hash = None 

1575 if collection_id: 

1576 with LibraryRAGService( 

1577 username=username, 

1578 embedding_model=embedding_model, 

1579 embedding_provider=embedding_provider, 

1580 chunk_size=int(chunk_size), 

1581 chunk_overlap=int(chunk_overlap), 

1582 splitter_type=splitter_type, 

1583 text_separators=text_separators, 

1584 distance_metric=distance_metric, 

1585 normalize_vectors=normalize_vectors, 

1586 index_type=index_type, 

1587 ) as new_rag_service: 

1588 rag_index = new_rag_service._get_or_create_rag_index( 

1589 collection_id, db_session=db_session, commit=False 

1590 ) 

1591 index_hash = rag_index.index_hash 

1592 

1593 db_session.commit() 

1594 settings.emit_settings_changed_after_commit( 

1595 requested_setting_keys 

1596 ) 

1597 return index_hash 

1598 

1599 # run_db_sync (not raw to_thread): get_user_db_session/LibraryRAGService 

1600 # open the user's DB session; reused to_thread workers must not retain it. 

1601 result = await run_db_sync(_persist_configuration) 

1602 if isinstance(result, JSONResponse): 

1603 return result 

1604 index_hash = result 

1605 

1606 if collection_id: 

1607 return { 

1608 "success": True, 

1609 "message": "Configuration updated for collection. You can now index documents with the new settings.", 

1610 "index_hash": index_hash, 

1611 } 

1612 

1613 # Just saving default settings without updating a specific collection 

1614 return { 

1615 "success": True, 

1616 "message": "Default embedding settings saved successfully. New collections will use these settings.", 

1617 } 

1618 

1619 except json.JSONDecodeError: 

1620 return json_body_error("success", "Request body must be valid JSON") 

1621 except Exception as e: 

1622 return handle_api_error("configuring RAG", e) 

1623 

1624 

1625@router.get("/api/rag/documents") 

1626def get_documents( 

1627 request: Request, username: Annotated[str, Depends(require_auth)] 

1628): 

1629 """Get library documents with their RAG status for the default Library collection (paginated).""" 

1630 from ...database.session_context import get_user_db_session 

1631 from ...database.library_init import get_default_library_id 

1632 

1633 try: 

1634 # Get pagination parameters 

1635 try: 

1636 page = int(request.query_params.get("page", "1")) 

1637 except (TypeError, ValueError): 

1638 page = 1 

1639 try: 

1640 per_page = int(request.query_params.get("per_page", "50")) 

1641 except (TypeError, ValueError): 

1642 per_page = 50 

1643 page = max(1, page) 

1644 per_page = max(1, min(per_page, 500)) 

1645 filter_type = request.query_params.get( 

1646 "filter", "all" 

1647 ) # all, indexed, unindexed 

1648 

1649 # Validate pagination parameters 

1650 # Upper-bound the page as well. A numerically valid but astronomical 

1651 # ?page=10**40 reaches .offset() and raises OverflowError from 

1652 # SQLite's 64-bit integer conversion, surfacing as a 500. The 

1653 # try/except above only rescues NON-numeric input. Mirrors 

1654 # metrics.py's _MAX_PAGE, which already handles this correctly. 

1655 page = max(1, min(page, _MAX_PAGE)) 

1656 per_page = min(max(10, per_page), 100) # Limit between 10-100 

1657 

1658 # Close current thread's session to force fresh connection 

1659 from ...database.thread_local_session import cleanup_current_thread 

1660 

1661 cleanup_current_thread() 

1662 

1663 # Get collection_id from request or use default Library collection 

1664 collection_id = request.query_params.get("collection_id") 

1665 if not collection_id: 

1666 collection_id = get_default_library_id(username) 

1667 

1668 logger.info( 

1669 f"Getting documents for collection_id: {collection_id}, filter: {filter_type}, page: {page}" 

1670 ) 

1671 

1672 with get_user_db_session(username) as db_session: 

1673 # Expire all cached objects to ensure we get fresh data from DB 

1674 db_session.expire_all() 

1675 

1676 # Import RagDocumentStatus model 

1677 from ...database.models.library import RagDocumentStatus 

1678 

1679 # Build base query - join Document with DocumentCollection for the collection 

1680 # LEFT JOIN with rag_document_status to check indexed status 

1681 query = ( 

1682 db_session.query( 

1683 Document, DocumentCollection, RagDocumentStatus 

1684 ) 

1685 .join( 

1686 DocumentCollection, 

1687 (DocumentCollection.document_id == Document.id) 

1688 & (DocumentCollection.collection_id == collection_id), 

1689 ) 

1690 .outerjoin( 

1691 RagDocumentStatus, 

1692 (RagDocumentStatus.document_id == Document.id) 

1693 & (RagDocumentStatus.collection_id == collection_id), 

1694 ) 

1695 ) 

1696 

1697 logger.debug(f"Base query for collection {collection_id}: {query}") 

1698 

1699 # Apply filters based on rag_document_status existence 

1700 if filter_type == "indexed": 

1701 query = query.filter(RagDocumentStatus.document_id.isnot(None)) 

1702 elif filter_type == "unindexed": 

1703 # Documents in collection but not indexed yet 

1704 query = query.filter(RagDocumentStatus.document_id.is_(None)) 

1705 

1706 # Get total count before pagination 

1707 total_count = query.count() 

1708 logger.info( 

1709 f"Found {total_count} total documents for collection {collection_id} with filter {filter_type}" 

1710 ) 

1711 

1712 # Apply pagination 

1713 results = ( 

1714 query.order_by(Document.created_at.desc()) 

1715 .limit(per_page) 

1716 .offset((page - 1) * per_page) 

1717 .all() 

1718 ) 

1719 

1720 documents = [ 

1721 { 

1722 "id": doc.id, 

1723 "title": doc.title, 

1724 "original_url": doc.original_url, 

1725 "rag_indexed": rag_status is not None, 

1726 "chunk_count": rag_status.chunk_count if rag_status else 0, 

1727 "created_at": doc.created_at.isoformat() 

1728 if doc.created_at 

1729 else None, 

1730 } 

1731 for doc, doc_collection, rag_status in results 

1732 ] 

1733 

1734 # Debug logging to help diagnose indexing status issues 

1735 indexed_count = sum(1 for d in documents if d["rag_indexed"]) 

1736 

1737 # Additional debug: check rag_document_status for this collection 

1738 all_indexed_statuses = ( 

1739 db_session.query(RagDocumentStatus) 

1740 .filter_by(collection_id=collection_id) 

1741 .all() 

1742 ) 

1743 logger.info( 

1744 f"rag_document_status table shows: {len(all_indexed_statuses)} documents indexed for collection {collection_id}" 

1745 ) 

1746 

1747 logger.info( 

1748 f"Returning {len(documents)} documents on page {page}: " 

1749 f"{indexed_count} indexed, {len(documents) - indexed_count} not indexed" 

1750 ) 

1751 

1752 return { 

1753 "success": True, 

1754 "documents": documents, 

1755 "pagination": { 

1756 "page": page, 

1757 "per_page": per_page, 

1758 "total": total_count, 

1759 "pages": (total_count + per_page - 1) // per_page, 

1760 }, 

1761 } 

1762 except Exception as e: 

1763 return handle_api_error("getting documents", e) 

1764 

1765 

1766# Collection Management Routes 

1767 

1768 

1769@router.get("/api/collections") 

1770def get_collections( 

1771 request: Request, username: Annotated[str, Depends(require_auth)] 

1772): 

1773 """Get all document collections for the current user.""" 

1774 from ...database.session_context import get_user_db_session 

1775 

1776 try: 

1777 with get_user_db_session(username) as db_session: 

1778 # No need to filter by username - each user has their own database 

1779 collections = db_session.query(Collection).all() 

1780 

1781 # Canonical durable count. Reconciliation maintains these rows from 

1782 # actual FAISS membership; the legacy DocumentCollection.indexed flag 

1783 # is retained only for compatibility and must not drive user-visible 

1784 # truth. 

1785 collection_counts = { 

1786 collection_id: ( 

1787 int(document_count or 0), 

1788 int(indexed_count or 0), 

1789 ) 

1790 for collection_id, document_count, indexed_count in ( 

1791 db_session.query( 

1792 DocumentCollection.collection_id, 

1793 func.count(DocumentCollection.document_id), 

1794 func.count( 

1795 case( 

1796 ( 

1797 DocumentCollection.indexed.is_(True), 

1798 DocumentCollection.document_id, 

1799 ), 

1800 else_=None, 

1801 ) 

1802 ), 

1803 ) 

1804 .group_by(DocumentCollection.collection_id) 

1805 .all() 

1806 ) 

1807 } 

1808 

1809 result = [] 

1810 for coll in collections: 

1811 document_count, indexed_document_count = collection_counts.get( 

1812 coll.id, 

1813 (0, 0), 

1814 ) 

1815 collection_data = { 

1816 "id": coll.id, 

1817 "name": coll.name, 

1818 "description": coll.description, 

1819 "created_at": coll.created_at.isoformat() 

1820 if coll.created_at 

1821 else None, 

1822 "collection_type": coll.collection_type, 

1823 "is_default": coll.is_default 

1824 if hasattr(coll, "is_default") 

1825 else False, 

1826 "is_public": bool(getattr(coll, "is_public", False)), 

1827 "agent_enabled": _agent_enabled_default_on(coll), 

1828 "document_count": document_count, 

1829 "indexed_document_count": indexed_document_count, 

1830 "folder_count": len(coll.linked_folders) 

1831 if hasattr(coll, "linked_folders") 

1832 else 0, 

1833 } 

1834 

1835 # Include embedding metadata if available 

1836 if coll.embedding_model: 

1837 collection_data["embedding"] = { 

1838 "model": coll.embedding_model, 

1839 "provider": coll.embedding_model_type.value 

1840 if coll.embedding_model_type 

1841 else None, 

1842 "dimension": coll.embedding_dimension, 

1843 "chunk_size": coll.chunk_size, 

1844 "chunk_overlap": coll.chunk_overlap, 

1845 } 

1846 else: 

1847 collection_data["embedding"] = None 

1848 

1849 result.append(collection_data) 

1850 

1851 return {"success": True, "collections": result} 

1852 except Exception as e: 

1853 return handle_api_error("getting collections", e) 

1854 

1855 

1856@router.post("/api/collections") 

1857async def create_collection( 

1858 request: Request, username: Annotated[str, Depends(require_auth)] 

1859): 

1860 """Create a new document collection.""" 

1861 data = await request.json() 

1862 if not isinstance(data, dict): 

1863 return json_body_error("success", "Request body must be valid JSON") 

1864 return await run_db_sync(_create_collection_sync, data, username) 

1865 

1866 

1867def _create_collection_sync(data, username): 

1868 from ...database.session_context import get_user_db_session 

1869 

1870 try: 

1871 name = data.get("name", "") 

1872 if not isinstance(name, str): 

1873 # `.get(key, "")` only supplies the default when the key is 

1874 # ABSENT; a present-but-wrong-typed value (e.g. `"name": 123` 

1875 # or `"name": null`) passes through untouched and blows up on 

1876 # `.strip()` with AttributeError -> 500. Reject it explicitly. 

1877 return JSONResponse( 

1878 {"success": False, "error": "Name must be a string"}, 

1879 status_code=400, 

1880 ) 

1881 name = name.strip() 

1882 

1883 description = data.get("description", "") 

1884 if not isinstance(description, str): 

1885 return JSONResponse( 

1886 {"success": False, "error": "Description must be a string"}, 

1887 status_code=400, 

1888 ) 

1889 description = description.strip() 

1890 

1891 collection_type = data.get("type", "user_uploads") 

1892 if not isinstance(collection_type, str): 

1893 # Ported from main's Flask original, which returned 

1894 # ``jsonify({...}), 400``. Neither half survives here: 

1895 # ``jsonify`` is undefined (NameError -> 500), and a 

1896 # ``(body, status)`` tuple is a Flask convention FastAPI does 

1897 # not honour. Matches the sibling error returns below. 

1898 return JSONResponse( 

1899 { 

1900 "success": False, 

1901 "error": "Collection type must be a string", 

1902 }, 

1903 status_code=400, 

1904 ) 

1905 # Allowlist user-creatable types. System types (notes, 

1906 # default_library, research_history) are lazy-created by their 

1907 # owning subsystems; a user-crafted impostor (e.g. type="notes") 

1908 # would be undeletable under PROTECTED_COLLECTION_TYPES and could 

1909 # nondeterministically win _get_or_create_notes_collection's 

1910 # .first() lookup, splitting the notes corpus. 

1911 allowed_types = {"user_uploads", "user_collection"} 

1912 if collection_type not in allowed_types: 

1913 return JSONResponse( 

1914 { 

1915 "success": False, 

1916 "error": ( 

1917 f"Invalid collection type '{collection_type}'. " 

1918 f"Allowed: {sorted(allowed_types)}" 

1919 ), 

1920 }, 

1921 status_code=400, 

1922 ) 

1923 # Egress classification, default private (the safe choice). A public 

1924 # collection counts as a public engine and may use cloud inference. 

1925 is_public = data.get("is_public", False) 

1926 if not isinstance(is_public, bool): 

1927 return JSONResponse( 

1928 { 

1929 "success": False, 

1930 "error": "is_public must be a boolean", 

1931 }, 

1932 status_code=400, 

1933 ) 

1934 # Usability switch (NOT egress): offer this collection to the research 

1935 # agent? Default True (available) — behaviour-preserving. An explicit 

1936 # JSON null normalizes to True so it matches how NULL is read back 

1937 # everywhere else (NULL → available); only an explicit false disables. 

1938 agent_enabled_raw = data.get("agent_enabled", True) 

1939 if agent_enabled_raw is not None and not isinstance( 

1940 agent_enabled_raw, bool 

1941 ): 

1942 return JSONResponse( 

1943 { 

1944 "success": False, 

1945 "error": "agent_enabled must be a boolean or null", 

1946 }, 

1947 status_code=400, 

1948 ) 

1949 agent_enabled = True if agent_enabled_raw is None else agent_enabled_raw 

1950 

1951 if not name: 

1952 return JSONResponse( 

1953 {"success": False, "error": "Name is required"}, status_code=400 

1954 ) 

1955 

1956 with get_user_db_session(username) as db_session: 

1957 # Check if collection with this name already exists in this user's database 

1958 existing = db_session.query(Collection).filter_by(name=name).first() 

1959 

1960 if existing: 

1961 return JSONResponse( 

1962 { 

1963 "success": False, 

1964 "error": f"Collection '{name}' already exists", 

1965 }, 

1966 status_code=400, 

1967 ) 

1968 

1969 # Create new collection (no username needed - each user has their own DB) 

1970 # Note: created_at uses default=utcnow() in the model, so we don't need to set it manually 

1971 collection = Collection( 

1972 id=str(uuid.uuid4()), # Generate UUID for collection 

1973 name=name, 

1974 description=description, 

1975 collection_type=collection_type, 

1976 is_public=is_public, 

1977 agent_enabled=agent_enabled, 

1978 ) 

1979 

1980 db_session.add(collection) 

1981 db_session.commit() 

1982 

1983 return { 

1984 "success": True, 

1985 "collection": { 

1986 "id": collection.id, 

1987 "name": collection.name, 

1988 "description": collection.description, 

1989 "created_at": collection.created_at.isoformat(), 

1990 "collection_type": collection.collection_type, 

1991 "is_public": bool(collection.is_public), 

1992 "agent_enabled": _agent_enabled_default_on(collection), 

1993 }, 

1994 } 

1995 except Exception as e: 

1996 return handle_api_error("creating collection", e) 

1997 

1998 

1999@router.put("/api/collections/{collection_id}") 

2000async def update_collection( 

2001 request: Request, 

2002 collection_id, 

2003 username: Annotated[str, Depends(require_auth)], 

2004): 

2005 """Update a collection's details.""" 

2006 data = await request.json() 

2007 if not isinstance(data, dict): 

2008 return json_body_error("success", "Request body must be valid JSON") 

2009 return await run_db_sync( 

2010 _update_collection_sync, data, collection_id, username 

2011 ) 

2012 

2013 

2014def _update_collection_sync(data, collection_id, username): 

2015 from ...database.session_context import get_user_db_session 

2016 

2017 try: 

2018 name = data.get("name", "") 

2019 if not isinstance(name, str): 

2020 # See _create_collection_sync: `.get(key, "")` only supplies 

2021 # the default when the key is absent, so a present-but-wrong- 

2022 # typed value reaches `.strip()` untouched -> AttributeError -> 500. 

2023 return JSONResponse( 

2024 {"success": False, "error": "Name must be a string"}, 

2025 status_code=400, 

2026 ) 

2027 name = name.strip() 

2028 

2029 description = data.get("description", "") 

2030 if not isinstance(description, str): 

2031 return JSONResponse( 

2032 {"success": False, "error": "Description must be a string"}, 

2033 status_code=400, 

2034 ) 

2035 description = description.strip() 

2036 

2037 is_public = data.get("is_public") 

2038 if "is_public" in data and not isinstance(is_public, bool): 

2039 return JSONResponse( 

2040 { 

2041 "success": False, 

2042 "error": "is_public must be a boolean", 

2043 }, 

2044 status_code=400, 

2045 ) 

2046 

2047 agent_enabled = data.get("agent_enabled") 

2048 if ( 

2049 "agent_enabled" in data 

2050 and agent_enabled is not None 

2051 and not isinstance(agent_enabled, bool) 

2052 ): 

2053 return JSONResponse( 

2054 { 

2055 "success": False, 

2056 "error": "agent_enabled must be a boolean or null", 

2057 }, 

2058 status_code=400, 

2059 ) 

2060 

2061 with get_user_db_session(username) as db_session: 

2062 # No need to filter by username - each user has their own database 

2063 collection = ( 

2064 db_session.query(Collection).filter_by(id=collection_id).first() 

2065 ) 

2066 

2067 if not collection: 

2068 return JSONResponse( 

2069 {"success": False, "error": "Collection not found"}, 

2070 status_code=404, 

2071 ) 

2072 

2073 # System collections (Notes, Library, Research History) own 

2074 # first-class user data whose lifecycle is managed by other 

2075 # subsystems. Renaming them via this generic endpoint 

2076 # bypasses their intended invariants and confuses every UI 

2077 # surface that lists collections. Mirrors PROTECTED_COLLECTION_TYPES 

2078 # in the deletion service. Only identity fields (name, 

2079 # description) are locked — the is_public / agent_enabled 

2080 # toggles below are deliberate per-collection settings that 

2081 # must keep working for system collections too. 

2082 from ...research_library.deletion.services.collection_deletion import ( 

2083 PROTECTED_COLLECTION_TYPES, 

2084 ) 

2085 

2086 if collection.collection_type in PROTECTED_COLLECTION_TYPES and ( 

2087 name or "description" in data 

2088 ): 

2089 return JSONResponse( 

2090 { 

2091 "success": False, 

2092 "error": ( 

2093 f"Cannot rename or redescribe system " 

2094 f"collection '{collection.name}' " 

2095 f"(type={collection.collection_type})." 

2096 ), 

2097 }, 

2098 status_code=409, 

2099 ) 

2100 

2101 if name: 

2102 # Check if new name conflicts with existing collection 

2103 existing = ( 

2104 db_session.query(Collection) 

2105 .filter( 

2106 Collection.name == name, 

2107 Collection.id != collection_id, 

2108 ) 

2109 .first() 

2110 ) 

2111 

2112 if existing: 

2113 return JSONResponse( 

2114 { 

2115 "success": False, 

2116 "error": f"Collection '{name}' already exists", 

2117 }, 

2118 status_code=400, 

2119 ) 

2120 

2121 collection.name = name 

2122 

2123 # Only write when the caller sends the key (sending "" still 

2124 # clears) — otherwise toggle-only PUTs wipe the description. 

2125 if "description" in data: 

2126 collection.description = description 

2127 

2128 # Egress classification toggle (only when the caller sends it). 

2129 if "is_public" in data: 

2130 collection.is_public = is_public 

2131 

2132 # Research-agent availability toggle (only when the caller sends it). 

2133 # Explicit null normalizes to True (available) for parity with how 

2134 # NULL is read back elsewhere; only an explicit false disables. 

2135 if "agent_enabled" in data: 

2136 collection.agent_enabled = ( 

2137 True if agent_enabled is None else agent_enabled 

2138 ) 

2139 

2140 db_session.commit() 

2141 

2142 return { 

2143 "success": True, 

2144 "collection": { 

2145 "id": collection.id, 

2146 "name": collection.name, 

2147 "description": collection.description, 

2148 "created_at": collection.created_at.isoformat() 

2149 if collection.created_at 

2150 else None, 

2151 "collection_type": collection.collection_type, 

2152 "is_public": bool(collection.is_public), 

2153 "agent_enabled": _agent_enabled_default_on(collection), 

2154 }, 

2155 } 

2156 except Exception as e: 

2157 return handle_api_error("updating collection", e) 

2158 

2159 

2160def _try_pdf_upgrade( 

2161 *, 

2162 db_session, 

2163 document, 

2164 file_content: bytes, 

2165 filename: str, 

2166 pdf_storage: str, 

2167 pdf_storage_manager, 

2168) -> bool: 

2169 """Check PDF upgrade eligibility and attempt upgrading a Document to include PDF bytes. 

2170 

2171 Returns True if the document was upgraded, False otherwise. Exceptions 

2172 raised during the upgrade attempt are logged and swallowed; the caller 

2173 sees False and the upload continues as a non-upgraded success. 

2174 """ 

2175 if pdf_storage != "database" or pdf_storage_manager is None: 

2176 return False 

2177 

2178 # NOTE: Only the PDF magic-byte check is needed here. 

2179 # Size limits are already enforced by the async upload wrapper, before 

2180 # these bytes are ever buffered. 

2181 # Filename sanitization already happens via sanitize_filename() in 

2182 # upload_to_collection() before this helper is invoked. 

2183 # See PR #3145 review for details. 

2184 if file_content[:4] != b"%PDF": 

2185 logger.debug( 

2186 "Skipping PDF upgrade for {}: not a PDF file", 

2187 filename, 

2188 ) 

2189 return False 

2190 

2191 try: 

2192 return bool( 

2193 pdf_storage_manager.upgrade_to_pdf( 

2194 document=document, 

2195 pdf_content=file_content, 

2196 session=db_session, 

2197 ) 

2198 ) 

2199 except Exception: 

2200 logger.exception(f"Failed to upgrade PDF for {filename}") 

2201 return False 

2202 

2203 

2204@router.post("/api/collections/{collection_id}/upload") 

2205@upload_rate_limit_user 

2206@upload_rate_limit_ip 

2207async def upload_to_collection( 

2208 request: Request, 

2209 collection_id, 

2210 username: Annotated[str, Depends(require_auth)], 

2211): 

2212 """Upload files to a collection. 

2213 

2214 The async wrapper consumes the multipart form (the only legitimate 

2215 awaits — request.form() and per-file file.read()) and validates 

2216 sizes. The sync DB body — opening the SQLCipher session, hashing, 

2217 text extraction, and Document/Collection writes — is offloaded to 

2218 a threadpool so a multi-file upload doesn't stall the event loop 

2219 behind PBKDF2 key derivation and per-file extraction work. 

2220 """ 

2221 # Starlette's form parser yields STARLETTE UploadFile instances; 

2222 # fastapi.UploadFile is a SUBCLASS of it on fastapi>=0.113, so 

2223 # `isinstance(f, fastapi.UploadFile)` is False for every real 

2224 # upload and the filter below silently dropped all files 

2225 # (every upload 400'd with "No files provided"). 

2226 from starlette.datastructures import UploadFile 

2227 

2228 # Per-file and total upload limits to prevent memory exhaustion. 

2229 # 

2230 # These MUST come from FileUploadValidator, not a route-local 

2231 # constant: GET /api/config/limits (research.py) advertises 

2232 # FileUploadValidator.MAX_FILE_SIZE / MAX_FILES_PER_REQUEST to the 

2233 # frontend as "the backend's authoritative limits", and 

2234 # BodySizeLimitMiddleware (fastapi_app.py) already enforces the same 

2235 # constants as the global per-request body cap for every route, 

2236 # including this one. A previous hardening pass gave this route its 

2237 # own tighter, undocumented cap (100MB / 50 files) instead of reusing 

2238 # FileUploadValidator like origin/main's Flask handler did — so a 

2239 # file the frontend was told is acceptable could be silently 

2240 # rejected here with a different limit. Only the size/count checks 

2241 # are reused (not validate_upload's MIME/PDF-structure checks): this 

2242 # route indexes arbitrary document types via document_loaders, not 

2243 # just PDFs. 

2244 from ...security import FileUploadValidator 

2245 

2246 MAX_FILE_SIZE = FileUploadValidator.MAX_FILE_SIZE 

2247 MAX_FILES_PER_UPLOAD = FileUploadValidator.MAX_FILES_PER_REQUEST 

2248 MAX_TOTAL_UPLOAD_SIZE = MAX_FILES_PER_UPLOAD * MAX_FILE_SIZE 

2249 

2250 # Early request size check based on Content-Length, before reading body. 

2251 content_length = request.headers.get("content-length") 

2252 if content_length: 

2253 try: 

2254 cl = int(content_length) 

2255 if cl > MAX_TOTAL_UPLOAD_SIZE: 2255 ↛ 2256line 2255 didn't jump to line 2256 because the condition on line 2255 was never true

2256 return JSONResponse( 

2257 { 

2258 "success": False, 

2259 "error": f"Request too large. Max {MAX_TOTAL_UPLOAD_SIZE // (1024 * 1024)}MB", 

2260 }, 

2261 status_code=413, 

2262 ) 

2263 except ValueError: 

2264 pass 

2265 

2266 # Parse the multipart form once and reuse — request.form() can only be 

2267 # consumed once per request. 

2268 form = await request.form() 

2269 upload_files: list[UploadFile] = [ 

2270 f for f in form.getlist("files") if isinstance(f, UploadFile) 

2271 ] 

2272 if not upload_files: 

2273 return JSONResponse( 

2274 {"success": False, "error": "No files provided"}, 

2275 status_code=400, 

2276 ) 

2277 if len(upload_files) > MAX_FILES_PER_UPLOAD: 

2278 return JSONResponse( 

2279 { 

2280 "success": False, 

2281 "error": f"Too many files. Max {MAX_FILES_PER_UPLOAD} per upload.", 

2282 }, 

2283 status_code=400, 

2284 ) 

2285 

2286 # Buffer every file's bytes here in the async path — UploadFile.read() 

2287 # is async (the body may still be streaming from the client), and we 

2288 # need the bytes available before we can hand control to the sync 

2289 # threadpool worker. Large-file rejection happens up front so we 

2290 # don't load anything we'll just throw away. 

2291 pdf_storage_form_value = form.get("pdf_storage") 

2292 files_data: list[dict] = [] # [{filename, content, oversized}] 

2293 for uf in upload_files: 

2294 # Preserve empty browser file inputs in the submitted-part count, but 

2295 # do not read or process them. The canonical Flask route skipped these 

2296 # parts while still reporting them in summary.total. 

2297 if not uf.filename: 

2298 files_data.append( 

2299 {"filename": "", "content": None, "oversized": False} 

2300 ) 

2301 continue 

2302 # Per-file size guard before reading into memory (UploadFile.size 

2303 # is set when the client sent Content-Length per part). 

2304 if uf.size is not None and uf.size > MAX_FILE_SIZE: 

2305 files_data.append( 

2306 { 

2307 "filename": uf.filename, 

2308 "content": None, 

2309 "oversized": True, 

2310 } 

2311 ) 

2312 continue 

2313 content = await uf.read() 

2314 files_data.append( 

2315 { 

2316 "filename": uf.filename, 

2317 "content": content, 

2318 "oversized": len(content) > MAX_FILE_SIZE, 

2319 } 

2320 ) 

2321 

2322 session_id = request.session.get("session_id") 

2323 return await run_db_sync( 

2324 _upload_to_collection_sync, 

2325 files_data, 

2326 pdf_storage_form_value, 

2327 collection_id, 

2328 username, 

2329 session_id, 

2330 MAX_FILE_SIZE, 

2331 ) 

2332 

2333 

2334def _upload_to_collection_sync( 

2335 files_data, 

2336 pdf_storage_form_value, 

2337 collection_id, 

2338 username, 

2339 session_id, 

2340 MAX_FILE_SIZE, 

2341): 

2342 from ...database.session_context import ( 

2343 get_user_db_session, 

2344 ) 

2345 from ...utilities.db_utils import get_settings_manager 

2346 from ...security import sanitize_filename, UnsafeFilenameError 

2347 import hashlib 

2348 import uuid 

2349 from ...research_library.services.pdf_storage_manager import ( 

2350 PDFStorageManager, 

2351 resolve_pdf_storage_mode, 

2352 ) 

2353 

2354 try: 

2355 with get_user_db_session(username) as db_session: 

2356 settings = get_settings_manager(db_session, username) 

2357 

2358 # Verify collection exists in this user's database 

2359 collection = ( 

2360 db_session.query(Collection).filter_by(id=collection_id).first() 

2361 ) 

2362 

2363 if not collection: 

2364 return JSONResponse( 

2365 {"success": False, "error": "Collection not found"}, 

2366 status_code=404, 

2367 ) 

2368 

2369 # Get PDF storage mode from form data, falling back to user's setting 

2370 default_pdf_storage = settings.get_setting( 

2371 "research_library.upload_pdf_storage", "none" 

2372 ) 

2373 # `is None`, not `or`: Flask's form.get(k, default) returned the 

2374 # default only when the field was ABSENT. An explicit empty 

2375 # `pdf_storage=` fell through to the not-in-(database,none) 

2376 # check below. `or` also swallows the empty string, silently 

2377 # substituting the user's stored default -- which may be 

2378 # "database" -- for a caller who explicitly sent nothing. 

2379 pdf_storage = ( 

2380 default_pdf_storage 

2381 if pdf_storage_form_value is None 

2382 else pdf_storage_form_value 

2383 ) 

2384 if pdf_storage not in ("database", "none"): 2384 ↛ 2387line 2384 didn't jump to line 2387 because the condition on line 2384 was never true

2385 # Security: user uploads can only use database (encrypted) or none (text-only) 

2386 # Filesystem storage is not allowed for user uploads 

2387 pdf_storage = "none" 

2388 

2389 # Initialize PDF storage manager if storing PDFs in database 

2390 pdf_storage_manager = None 

2391 if pdf_storage == "database": 

2392 library_root = settings.get_setting( 

2393 "research_library.storage_path", 

2394 str(get_library_directory()), 

2395 ) 

2396 library_root = str( 

2397 Path(os.path.expandvars(library_root)) 

2398 .expanduser() 

2399 .resolve() 

2400 ) 

2401 shared_library = settings.get_setting( 

2402 "research_library.shared_library", False 

2403 ) 

2404 # Per-user library root (issue #5521), mirroring the sibling 

2405 # PDFStorageManager construction sites (library.py's 

2406 # view_pdf_page, DownloadService.__init__, zotero 

2407 # sync_service._library_root()): narrow the shared base to 

2408 # this user's own subdirectory so two users' uploads can't 

2409 # collide under one shared root. Not exploitable today since 

2410 # this instance is only used in "database" mode (bytes go to 

2411 # DocumentBlob, never through library_root), but the isolation 

2412 # should hold regardless of which methods happen to read it. 

2413 # 

2414 # Route the mode through resolve_pdf_storage_mode() as well, 

2415 # mirroring sync_service's own database-only construction — 

2416 # `pdf_storage` is already guaranteed "database" by the branch 

2417 # above, so this is a no-op today, but it ties the 

2418 # construction to the actual resolved value (rather than a 

2419 # bare literal) so it can't silently start bypassing the 

2420 # unencrypted-filesystem gate if this code is ever refactored 

2421 # to pass `pdf_storage` straight through. 

2422 pdf_storage_manager = PDFStorageManager( 

2423 library_root=apply_user_subdir( 

2424 Path(library_root), username, shared_library 

2425 ), 

2426 storage_mode=resolve_pdf_storage_mode(pdf_storage), 

2427 ) 

2428 logger.info("PDF storage mode: database (encrypted)") 

2429 else: 

2430 logger.info("PDF storage mode: none (text-only)") 

2431 

2432 uploaded_files = [] 

2433 errors = [] 

2434 

2435 # Track hashes processed earlier in THIS request so we can 

2436 # distinguish intra-batch duplicates (two uploaded files with 

2437 # identical content) from duplicates against a pre-existing 

2438 # library doc. Without this, the second occurrence of an 

2439 # intra-batch duplicate was reported as "already_in_collection" 

2440 # under the FIRST occurrence's filename -- the warning looked 

2441 # like it was skipping books that were actually being added, 

2442 # and the skipped entry showed the wrong filename (#5495). 

2443 # Maps content hash -> Document so a later identical PDF in the 

2444 # same batch can still run the PDF-upgrade path against the 

2445 # document kept from the first occurrence. 

2446 seen_hashes: dict[str, Document] = {} 

2447 

2448 for fd in files_data: 

2449 # Match main: an empty browser file input counts as a submitted 

2450 # part in the summary but is not processed as an upload. 

2451 if not fd["filename"]: 

2452 continue 

2453 

2454 # Files were already buffered + size-checked in the async 

2455 # wrapper; oversized entries arrive with content=None and 

2456 # oversized=True so we can record an error here without 

2457 # having held the bytes in memory. 

2458 if fd["oversized"]: 

2459 # Sanitize BEFORE echoing. Flask sanitized first and only 

2460 # ever put the cleaned name into `errors`; the port 

2461 # inverted the order, so this branch reflected the raw 

2462 # multipart filename — attacker-chosen bytes — straight 

2463 # back in the JSON response. Masked today only because the 

2464 # single consumer escapes it client-side, which is not a 

2465 # property this handler should depend on. Every other 

2466 # errors.append below already uses the sanitized name. 

2467 try: 

2468 safe_name = sanitize_filename(fd["filename"]) 

2469 except UnsafeFilenameError: 

2470 safe_name = "rejected" 

2471 errors.append( 

2472 { 

2473 "filename": safe_name, 

2474 "error": f"File too large (max {MAX_FILE_SIZE // (1024 * 1024)}MB)", 

2475 } 

2476 ) 

2477 continue 

2478 

2479 try: 

2480 filename = sanitize_filename(fd["filename"]) 

2481 except UnsafeFilenameError: 

2482 errors.append( 

2483 { 

2484 "filename": "rejected", 

2485 "error": "Invalid or unsafe filename", 

2486 } 

2487 ) 

2488 continue 

2489 

2490 # Per-file SAVEPOINT: a failure rolls back only this file so 

2491 # earlier successes (and seen_hashes) stay consistent with 

2492 # the outer transaction. Commit the savepoint on every 

2493 # non-exception exit — including soft validation failures — 

2494 # so we never leave nested SAVEPOINTs stacked open across 

2495 # the batch (same pattern as research_sources_service). 

2496 sp = None 

2497 try: 

2498 sp = db_session.begin_nested() 

2499 

2500 # Size limits were already enforced in the async wrapper 

2501 # (oversized entries arrive with content=None and are 

2502 # rejected above), so main's Content-Length pre-flight and 

2503 # post-read re-check have no work left to do here. 

2504 file_content = fd["content"] 

2505 # Calculate file hash for deduplication 

2506 file_hash = hashlib.sha256(file_content).hexdigest() 

2507 

2508 # Intra-batch duplicate: an earlier file in THIS 

2509 # request had identical bytes. Report it as its own 

2510 # status so the UI doesn't show it under the first 

2511 # occurrence's filename and pretend it was a 

2512 # "skipped - already in collection" hit. The first 

2513 # occurrence already linked the matching Document to 

2514 # this collection (or to the library), so doing the 

2515 # Document hash lookup and link check here would be 

2516 # misleading at best. Still allow PDF upgrade when 

2517 # the kept twin was stored text-only and this copy 

2518 # is a PDF with database storage enabled. 

2519 if file_hash in seen_hashes: 

2520 logger.info( 

2521 "Intra-batch duplicate detected: {} (hash {})", 

2522 filename, 

2523 file_hash, 

2524 ) 

2525 target_doc = seen_hashes[file_hash] 

2526 pdf_upgraded = _try_pdf_upgrade( 

2527 db_session=db_session, 

2528 document=target_doc, 

2529 file_content=file_content, 

2530 filename=filename, 

2531 pdf_storage=pdf_storage, 

2532 pdf_storage_manager=pdf_storage_manager, 

2533 ) 

2534 sp.commit() 

2535 uploaded_files.append( 

2536 { 

2537 "filename": filename, 

2538 "status": "duplicate_in_batch", 

2539 "id": target_doc.id, 

2540 "pdf_upgraded": pdf_upgraded, 

2541 } 

2542 ) 

2543 continue 

2544 

2545 # Check if document already exists 

2546 existing_doc = ( 

2547 db_session.query(Document) 

2548 .filter_by(document_hash=file_hash) 

2549 .first() 

2550 ) 

2551 

2552 if existing_doc: 

2553 # Document exists, check if we can upgrade to include PDF 

2554 pdf_upgraded = _try_pdf_upgrade( 

2555 db_session=db_session, 

2556 document=existing_doc, 

2557 file_content=file_content, 

2558 filename=filename, 

2559 pdf_storage=pdf_storage, 

2560 pdf_storage_manager=pdf_storage_manager, 

2561 ) 

2562 

2563 # Check if already in collection 

2564 existing_link = ( 

2565 db_session.query(DocumentCollection) 

2566 .filter_by( 

2567 document_id=existing_doc.id, 

2568 collection_id=collection_id, 

2569 ) 

2570 .first() 

2571 ) 

2572 

2573 if not existing_link: 

2574 ensure_in_collection( 

2575 db_session, existing_doc.id, collection_id 

2576 ) 

2577 status = "added_to_collection" 

2578 if pdf_upgraded: 

2579 status = "added_to_collection_pdf_upgraded" 

2580 sp.commit() 

2581 uploaded_files.append( 

2582 { 

2583 # Report the file the user actually 

2584 # uploaded, not the existing doc's 

2585 # filename. Otherwise the user sees 

2586 # one filename repeated under both 

2587 # "added to collection" and 

2588 # "new uploads" (or here under 

2589 # "already in collection") and 

2590 # can't tell which file was which. 

2591 "filename": filename, 

2592 "status": status, 

2593 "id": existing_doc.id, 

2594 "pdf_upgraded": pdf_upgraded, 

2595 } 

2596 ) 

2597 seen_hashes[file_hash] = existing_doc 

2598 else: 

2599 status = "already_in_collection" 

2600 if pdf_upgraded: 

2601 status = "pdf_upgraded" 

2602 sp.commit() 

2603 uploaded_files.append( 

2604 { 

2605 "filename": filename, 

2606 "status": status, 

2607 "id": existing_doc.id, 

2608 "pdf_upgraded": pdf_upgraded, 

2609 } 

2610 ) 

2611 seen_hashes[file_hash] = existing_doc 

2612 else: 

2613 # Create new document 

2614 from ...document_loaders import ( 

2615 extract_text_from_bytes, 

2616 is_extension_supported, 

2617 ) 

2618 

2619 file_extension = Path(filename).suffix.lower() 

2620 

2621 # Validate extension is supported before extraction 

2622 if not is_extension_supported(file_extension): 

2623 sp.commit() 

2624 errors.append( 

2625 { 

2626 "filename": filename, 

2627 "error": f"Unsupported format: {file_extension}", 

2628 } 

2629 ) 

2630 continue 

2631 

2632 # Use file_type without leading dot for storage 

2633 file_type = ( 

2634 file_extension[1:] 

2635 if file_extension.startswith(".") 

2636 else file_extension 

2637 ) 

2638 

2639 # Extract text using document_loaders module 

2640 extracted_text = extract_text_from_bytes( 

2641 file_content, file_extension, filename 

2642 ) 

2643 

2644 # Clean the extracted text to remove surrogate characters 

2645 if extracted_text: 

2646 from ...text_processing import remove_surrogates 

2647 

2648 extracted_text = remove_surrogates(extracted_text) 

2649 

2650 if not extracted_text: 

2651 sp.commit() 

2652 errors.append( 

2653 { 

2654 "filename": filename, 

2655 "error": f"Could not extract text from {file_type} file", 

2656 } 

2657 ) 

2658 logger.warning( 

2659 f"Skipping file {filename} - no text could be extracted" 

2660 ) 

2661 continue 

2662 

2663 # Get or create the user_upload source type 

2664 logger.info( 

2665 f"Getting or creating user_upload source type for {filename}" 

2666 ) 

2667 source_type = ( 

2668 db_session.query(SourceType) 

2669 .filter_by(name="user_upload") 

2670 .first() 

2671 ) 

2672 if not source_type: 2672 ↛ 2673line 2672 didn't jump to line 2673 because the condition on line 2672 was never true

2673 logger.info("Creating new user_upload source type") 

2674 source_type = SourceType( 

2675 id=str(uuid.uuid4()), 

2676 name="user_upload", 

2677 display_name="User Upload", 

2678 description="Documents uploaded by users", 

2679 icon="fas fa-upload", 

2680 ) 

2681 db_session.add(source_type) 

2682 db_session.flush() 

2683 logger.info( 

2684 f"Created source type with ID: {source_type.id}" 

2685 ) 

2686 else: 

2687 logger.info( 

2688 f"Found existing source type with ID: {source_type.id}" 

2689 ) 

2690 

2691 # Create document with extracted text (no username needed - in user's own database) 

2692 # Note: uploaded_at uses default=utcnow() in the model, so we don't need to set it manually 

2693 doc_id = str(uuid.uuid4()) 

2694 logger.info( 

2695 f"Creating document {doc_id} for {filename}" 

2696 ) 

2697 

2698 # Determine storage mode and file_path 

2699 store_pdf_in_db = ( 

2700 pdf_storage == "database" 

2701 and file_type == "pdf" 

2702 and pdf_storage_manager is not None 

2703 ) 

2704 

2705 new_doc = Document( 

2706 id=doc_id, 

2707 source_type_id=source_type.id, 

2708 filename=filename, 

2709 document_hash=file_hash, 

2710 file_size=len(file_content), 

2711 file_type=file_type, 

2712 text_content=extracted_text, # Always store extracted text 

2713 file_path=None 

2714 if store_pdf_in_db 

2715 else FILE_PATH_TEXT_ONLY, 

2716 storage_mode="database" 

2717 if store_pdf_in_db 

2718 else "none", 

2719 ) 

2720 db_session.add(new_doc) 

2721 db_session.flush() # Get the ID 

2722 logger.info( 

2723 f"Document {new_doc.id} created successfully" 

2724 ) 

2725 

2726 # Store PDF in encrypted database if requested 

2727 pdf_stored = False 

2728 if store_pdf_in_db: 

2729 try: 

2730 pdf_storage_manager.save_pdf( 

2731 pdf_content=file_content, 

2732 document=new_doc, 

2733 session=db_session, 

2734 filename=filename, 

2735 ) 

2736 pdf_stored = True 

2737 logger.info( 

2738 f"PDF stored in encrypted database for {filename}" 

2739 ) 

2740 except Exception: 

2741 logger.exception( 

2742 f"Failed to store PDF in database for {filename}" 

2743 ) 

2744 # Continue without PDF storage - text is still saved 

2745 

2746 # Add to collection 

2747 ensure_in_collection( 

2748 db_session, new_doc.id, collection_id 

2749 ) 

2750 

2751 # Release the per-file SAVEPOINT so later failures cannot 

2752 # undo this file's writes (and so soft/success paths do 

2753 # not leave nested SAVEPOINTs stacked open). 

2754 sp.commit() 

2755 

2756 uploaded_files.append( 

2757 { 

2758 "filename": filename, 

2759 "status": "uploaded", 

2760 "id": new_doc.id, 

2761 "text_length": len(extracted_text), 

2762 "pdf_stored": pdf_stored, 

2763 } 

2764 ) 

2765 seen_hashes[file_hash] = new_doc 

2766 

2767 except Exception: 

2768 # A per-file savepoint rollback isolates the failure of this 

2769 # file so earlier successful uploads in the batch remain intact 

2770 # in the transaction state. Record the error and log the traceback 

2771 # FIRST so a rollback failure cannot suppress them or crash the batch. 

2772 errors.append( 

2773 { 

2774 "filename": filename, 

2775 "error": "Failed to upload file", 

2776 } 

2777 ) 

2778 logger.exception(f"Error uploading file {filename}") 

2779 if sp is not None: 

2780 try: 

2781 sp.rollback() 

2782 except Exception: 

2783 logger.opt(exception=True).warning( 

2784 f"Failed to rollback savepoint for {filename}" 

2785 ) 

2786 

2787 db_session.commit() 

2788 

2789 # Trigger auto-indexing for successfully uploaded documents 

2790 document_ids = [ 

2791 f["id"] 

2792 for f in uploaded_files 

2793 if f.get("status") in ("uploaded", "added_to_collection") 

2794 ] 

2795 if document_ids: 

2796 from ...database.session_passwords import session_password_store 

2797 

2798 # session_id was captured by the async wrapper before to_thread 

2799 db_password = session_password_store.get_session_password( 

2800 username, session_id 

2801 ) 

2802 if db_password: 

2803 trigger_auto_index( 

2804 document_ids, collection_id, username, db_password 

2805 ) 

2806 

2807 return { 

2808 "success": True, 

2809 "uploaded": uploaded_files, 

2810 "errors": errors, 

2811 "summary": { 

2812 "total": len(files_data), 

2813 "successful": len(uploaded_files), 

2814 "failed": len(errors), 

2815 }, 

2816 } 

2817 

2818 except Exception as e: 

2819 return handle_api_error("uploading files", e) 

2820 

2821 

2822# Research History Semantic Search Routes have been moved to 

2823# web/routers/library_search.py 

2824 

2825 

2826@router.get("/api/collections/{collection_id}/documents") 

2827def get_collection_documents( 

2828 request: Request, 

2829 collection_id, 

2830 username: Annotated[str, Depends(require_auth)], 

2831): 

2832 """Get all documents in a collection.""" 

2833 from ...database.session_context import get_user_db_session 

2834 

2835 try: 

2836 with get_user_db_session(username) as db_session: 

2837 # Verify collection exists in this user's database 

2838 collection = ( 

2839 db_session.query(Collection).filter_by(id=collection_id).first() 

2840 ) 

2841 

2842 if not collection: 

2843 return JSONResponse( 

2844 {"success": False, "error": "Collection not found"}, 

2845 status_code=404, 

2846 ) 

2847 

2848 # Look up note source type to filter notes into separate array 

2849 note_source_type = ( 

2850 db_session.query(SourceType).filter_by(name="note").first() 

2851 ) 

2852 note_source_type_id = ( 

2853 note_source_type.id if note_source_type else None 

2854 ) 

2855 

2856 # Get documents through junction table. Compute "has text in DB" 

2857 # at the SQL level and defer the text_content column, so listing a 

2858 # collection doesn't pull every document's full text body into 

2859 # memory just to test it for truthiness (#4560). 

2860 has_text_col = ( 

2861 Document.text_content.isnot(None) 

2862 & (Document.text_content != "") 

2863 ).label("has_text_db") 

2864 doc_links = ( 

2865 db_session.query(DocumentCollection, Document, has_text_col) 

2866 .join(Document) 

2867 .options(defer(Document.text_content)) 

2868 .filter(DocumentCollection.collection_id == collection_id) 

2869 .all() 

2870 ) 

2871 

2872 documents = [] 

2873 for link, doc, has_text_db in doc_links: 

2874 # Skip notes — they go in the separate notes array 

2875 if ( 

2876 note_source_type_id 

2877 and doc.source_type_id == note_source_type_id 

2878 ): 

2879 continue 

2880 # Check if PDF file is stored 

2881 has_pdf = bool( 

2882 doc.file_path and doc.file_path not in FILE_PATH_SENTINELS 

2883 ) 

2884 has_text_db = bool(has_text_db) 

2885 

2886 # Use title if available, otherwise filename 

2887 display_title = doc.title or doc.filename or "Untitled" 

2888 

2889 # Get source type name 

2890 source_type_name = ( 

2891 doc.source_type.name if doc.source_type else "unknown" 

2892 ) 

2893 

2894 # Check if document is in other collections 

2895 other_collections_count = ( 

2896 db_session.query(DocumentCollection) 

2897 .filter( 

2898 DocumentCollection.document_id == doc.id, 

2899 DocumentCollection.collection_id != collection_id, 

2900 ) 

2901 .count() 

2902 ) 

2903 

2904 documents.append( 

2905 { 

2906 "id": doc.id, 

2907 "filename": display_title, 

2908 "title": display_title, 

2909 "file_type": doc.file_type, 

2910 "file_size": doc.file_size, 

2911 "uploaded_at": doc.created_at.isoformat() 

2912 if doc.created_at 

2913 else None, 

2914 "indexed": link.indexed, 

2915 "chunk_count": link.chunk_count, 

2916 "last_indexed_at": link.last_indexed_at.isoformat() 

2917 if link.last_indexed_at 

2918 else None, 

2919 "has_pdf": has_pdf, 

2920 "has_text_db": has_text_db, 

2921 "source_type": source_type_name, 

2922 "in_other_collections": other_collections_count > 0, 

2923 "other_collections_count": other_collections_count, 

2924 } 

2925 ) 

2926 

2927 # Get notes in this collection (note_source_type resolved above) 

2928 notes = [] 

2929 if note_source_type: 

2930 note_links = ( 

2931 db_session.query(DocumentCollection, Document) 

2932 .join(Document) 

2933 .filter( 

2934 DocumentCollection.collection_id == collection_id, 

2935 Document.source_type_id == note_source_type.id, 

2936 ) 

2937 .all() 

2938 ) 

2939 for link, note in note_links: 

2940 content = note.text_content or "" 

2941 notes.append( 

2942 { 

2943 "id": note.id, 

2944 "title": note.title or "Untitled", 

2945 "content_preview": content[:200] + "..." 

2946 if len(content) > 200 

2947 else content, 

2948 "tags": note.tags or [], 

2949 "pinned": note.favorite, 

2950 "created_at": note.created_at.isoformat() 

2951 if note.created_at 

2952 else None, 

2953 "updated_at": note.updated_at.isoformat() 

2954 if note.updated_at 

2955 else None, 

2956 "indexed": link.indexed, 

2957 "chunk_count": link.chunk_count, 

2958 "source_type": "note", 

2959 } 

2960 ) 

2961 

2962 # Get index file size if available 

2963 index_file_size = None 

2964 index_file_size_bytes = None 

2965 collection_name = f"collection_{collection_id}" 

2966 rag_index = ( 

2967 db_session.query(RAGIndex) 

2968 .filter_by(collection_name=collection_name) 

2969 .first() 

2970 ) 

2971 if rag_index and rag_index.index_path: 

2972 from pathlib import Path 

2973 

2974 index_path = Path(rag_index.index_path) 

2975 if index_path.exists(): 2975 ↛ 2986line 2975 didn't jump to line 2986 because the condition on line 2975 was always true

2976 size_bytes = index_path.stat().st_size 

2977 index_file_size_bytes = size_bytes 

2978 # Format as human-readable 

2979 if size_bytes < 1024: 

2980 index_file_size = f"{size_bytes} B" 

2981 elif size_bytes < 1024 * 1024: 

2982 index_file_size = f"{size_bytes / 1024:.1f} KB" 

2983 else: 

2984 index_file_size = f"{size_bytes / (1024 * 1024):.1f} MB" 

2985 

2986 return { 

2987 "success": True, 

2988 "collection": { 

2989 "id": collection.id, 

2990 "name": collection.name, 

2991 "description": collection.description, 

2992 "is_public": bool(getattr(collection, "is_public", False)), 

2993 "agent_enabled": _agent_enabled_default_on(collection), 

2994 "embedding_model": collection.embedding_model, 

2995 "embedding_model_type": collection.embedding_model_type.value 

2996 if collection.embedding_model_type 

2997 else None, 

2998 "embedding_dimension": collection.embedding_dimension, 

2999 "chunk_size": collection.chunk_size, 

3000 "chunk_overlap": collection.chunk_overlap, 

3001 # Advanced settings 

3002 "splitter_type": collection.splitter_type, 

3003 "distance_metric": collection.distance_metric, 

3004 "index_type": collection.index_type, 

3005 "normalize_vectors": collection.normalize_vectors, 

3006 # Index file info 

3007 "index_file_size": index_file_size, 

3008 "index_file_size_bytes": index_file_size_bytes, 

3009 "collection_type": collection.collection_type, 

3010 # Deletion policy comes from the server so the UI can't 

3011 # drift from PROTECTED_COLLECTION_TYPES: the details page 

3012 # hides its Delete button for system collections, whose 

3013 # deletion the service refuses with a 409 anyway. 

3014 "is_protected": _is_protected_collection(collection), 

3015 }, 

3016 "documents": documents, 

3017 "notes": notes, 

3018 } 

3019 

3020 except Exception as e: 

3021 return handle_api_error("getting collection documents", e) 

3022 

3023 

3024# ============================================================================= 

3025# Shared collection-indexing helpers 

3026# 

3027# Three routes index a collection: the single-collection SSE route 

3028# (index_collection), the bulk SSE route (index_all), and the background worker 

3029# (_background_index_worker). These helpers hold the logic that must stay 

3030# identical across all three so it cannot drift again — the embedding metadata 

3031# to persist, the force-reindex cleanup, and the query for documents to index. 

3032# The callers differ only in how they report progress (SSE stream vs 

3033# TaskMetadata) and so keep their own loops. 

3034# ============================================================================= 

3035 

3036 

3037def _store_collection_embedding_metadata(collection, rag_service): 

3038 """Persist the embedding/index configuration used to index a collection. 

3039 

3040 Run on first index or force-reindex. Includes the embedding dimension, 

3041 probed by embedding a test string (the same way LibraryRAGService derives 

3042 it for the RAGIndex row). Previously this read 

3043 ``embedding_manager.provider.embedding_dimension``, an attribute the real 

3044 LocalEmbeddingManager does not have, so the probe always failed and 

3045 ``embedding_dimension`` was persisted as NULL via both indexing paths. Does 

3046 not commit — the caller owns the transaction. 

3047 """ 

3048 embedding_dim = None 

3049 try: 

3050 # Derive the dimension by embedding a test string — mirrors 

3051 # LibraryRAGService.* which computes len(embed_query("test")) for the 

3052 # RAGIndex row. The embedding manager has no usable dimension attribute. 

3053 test_embedding = rag_service.embedding_manager.embeddings.embed_query( 

3054 "test" 

3055 ) 

3056 embedding_dim = len(test_embedding) 

3057 except Exception: 

3058 logger.debug("Could not determine embedding dimension", exc_info=True) 

3059 

3060 collection.embedding_model = rag_service.embedding_model 

3061 collection.embedding_model_type = EmbeddingProvider( 

3062 rag_service.embedding_provider 

3063 ) 

3064 collection.embedding_dimension = embedding_dim 

3065 collection.chunk_size = rag_service.chunk_size 

3066 collection.chunk_overlap = rag_service.chunk_overlap 

3067 # Advanced settings 

3068 collection.splitter_type = rag_service.splitter_type 

3069 collection.text_separators = rag_service.text_separators 

3070 collection.distance_metric = rag_service.distance_metric 

3071 # Ensure normalize_vectors is a proper boolean for the database 

3072 collection.normalize_vectors = bool(rag_service.normalize_vectors) 

3073 collection.index_type = rag_service.index_type 

3074 logger.info( 

3075 f"Stored embedding metadata for collection: provider={rag_service.embedding_provider}" 

3076 ) 

3077 

3078 

3079def _reset_collection_for_reindex(db_session, collection_id): 

3080 """Clear old chunks, FAISS indices, and indexed-state before a force rebuild. 

3081 

3082 Prevents mixed-model vectors / stale chunks when re-indexing (e.g. if a 

3083 previous run was cancelled midway). Shared so a force-reindex behaves the 

3084 same whether started via the SSE route or the background worker. Does not 

3085 commit — the caller owns the transaction. 

3086 

3087 Returns the list of on-disk FAISS index paths to unlink AFTER the caller 

3088 commits. The RAGIndex ROWS are deleted in this transaction, but the files 

3089 must NOT be removed before the commit that could still roll back — a 

3090 rollback would restore the rows while the files were already gone, leaving 

3091 the collection pointing at missing indices (mirrors collection deletion). 

3092 """ 

3093 # Import stays function-local: hoisting it would pull in the whole 

3094 # deletion package (deletion/__init__ imports its services) at 

3095 # route-module import time for a path only exercised on force-reindex. 

3096 from ...research_library.deletion.utils.cascade_helper import ( 

3097 CascadeHelper, 

3098 ) 

3099 

3100 collection_name = f"collection_{collection_id}" 

3101 

3102 # Delete all old document chunks from DB 

3103 deleted_chunks = CascadeHelper.delete_collection_chunks( 

3104 db_session, collection_name 

3105 ) 

3106 logger.info( 

3107 f"Cleared {deleted_chunks} old chunks for collection {collection_id}" 

3108 ) 

3109 

3110 # Delete old RAGIndex records (DB only). Files unlinked by the caller after 

3111 # commit (see docstring). RagDocumentStatus cascade-deletes via FK. 

3112 rag_result = CascadeHelper.delete_rag_indices_for_collection( 

3113 db_session, collection_name, unlink_files=False 

3114 ) 

3115 logger.info( 

3116 f"Cleared old RAG indices for collection {collection_id}: {rag_result}" 

3117 ) 

3118 

3119 # Mark all documents as unindexed 

3120 db_session.query(DocumentCollection).filter_by( 

3121 collection_id=collection_id 

3122 ).update( 

3123 { 

3124 DocumentCollection.indexed: False, 

3125 DocumentCollection.chunk_count: 0, 

3126 } 

3127 ) 

3128 logger.info(f"Reset indexing state for collection {collection_id}") 

3129 return rag_result["index_paths"] 

3130 

3131 

3132def _unlink_reindex_faiss_files(index_paths): 

3133 """Unlink FAISS index files returned by _reset_collection_for_reindex, AFTER 

3134 the caller's DB commit. Best-effort: the DB delete is already durable.""" 

3135 if not index_paths: 

3136 return 

3137 from ...research_library.deletion.utils.cascade_helper import ( 

3138 CascadeHelper, 

3139 ) 

3140 from ...config.paths import get_cache_directory 

3141 

3142 # NOTE: allowed_root is the COARSE shared rag_indices/ root, not the 

3143 # tighter per-user rag_indices/<sha256(user)>/ subdir. It cannot be 

3144 # narrowed to the per-user subdir without risking refused deletes: 

3145 # pre-per-user-scoping (legacy) indexes live DIRECTLY in this shared 

3146 # root (see library_rag_service._migrate_legacy_index_files), so a 

3147 # per-user allowed_root would reject a legacy-layout RAGIndex.index_path 

3148 # and orphan its files. The containment check still blocks symlink / 

3149 # ancestor escapes outside rag_indices/ entirely. 

3150 rag_indices_root = get_cache_directory() / "rag_indices" 

3151 for path in index_paths: 

3152 CascadeHelper.delete_faiss_index_files( 

3153 path, allowed_root=rag_indices_root 

3154 ) 

3155 

3156 

3157def _query_documents_to_index(db_session, collection_id, force_reindex): 

3158 """Return the ``(DocumentCollection, Document)`` pairs to index. 

3159 

3160 When not force-reindexing, only un-indexed documents are returned. 

3161 """ 

3162 query = ( 

3163 db_session.query(DocumentCollection, Document) 

3164 .join(Document) 

3165 # Don't load the large text_content body: the indexing loop only 

3166 # reads doc.id/filename/title (index_document re-fetches by id in a 

3167 # worker thread), so loading every document's full text here just to 

3168 # iterate is what exhausts memory on big collections (#4560). 

3169 .options(defer(Document.text_content)) 

3170 .filter(DocumentCollection.collection_id == collection_id) 

3171 ) 

3172 

3173 if not force_reindex: 

3174 # IMPORTANT: DO NOT CHANGE THIS LINE! DO NOT REMOVE THIS COMMENT! 

3175 # We MUST use `== False` instead of `not DocumentCollection.indexed` 

3176 # The Python `not` operator does NOT work correctly in SQLAlchemy filters. 

3177 # Using `not` will cause the query to return NO results (zero documents). 

3178 # SQLAlchemy requires explicit comparison: `== False` or `== True` 

3179 # This has been fixed multiple times - DO NOT change it back to `not`! 

3180 query = query.filter(DocumentCollection.indexed == False) # noqa: E712 

3181 

3182 return query.all() 

3183 

3184 

3185@router.get("/api/collections/{collection_id}/index") 

3186def index_collection( 

3187 request: Request, 

3188 collection_id, 

3189 username: Annotated[str, Depends(require_auth)], 

3190): 

3191 """Index all documents in a collection with Server-Sent Events progress.""" 

3192 from ...database.session_context import get_user_db_session 

3193 from ...database.session_passwords import session_password_store 

3194 

3195 # Parse as bool — a raw string "false" is truthy, which would silently 

3196 # force a full reindex for every caller that asked to skip already-indexed 

3197 # documents. 

3198 force_reindex = ( 

3199 request.query_params.get("force_reindex", "false").lower() == "true" 

3200 ) 

3201 session_id = request.session.get("session_id") 

3202 

3203 logger.info(f"Starting index_collection, force_reindex={force_reindex}") 

3204 

3205 # Get password for thread access to encrypted database 

3206 db_password = None 

3207 if session_id: 

3208 db_password = session_password_store.get_session_password( 

3209 username, session_id 

3210 ) 

3211 

3212 # Create RAG service — on force reindex use current default model 

3213 rag_service = get_rag_service( 

3214 request, username, collection_id, use_defaults=force_reindex 

3215 ) 

3216 logger.info( 

3217 f"RAG service created: provider={rag_service.embedding_provider}" 

3218 ) 

3219 

3220 # Resolve the parallel worker bound before the generator runs — the 

3221 # parallel helper must never touch SettingsManager from a worker thread. 

3222 with get_user_db_session(username, db_password) as _settings_session: 

3223 _settings = get_settings_manager(_settings_session, username) 

3224 try: 

3225 _max_workers = int( 

3226 _settings.get_setting("rag.indexing_max_parallel_docs", 4) 

3227 ) 

3228 except Exception: 

3229 _max_workers = 4 

3230 _max_workers = max(1, min(_max_workers, 16)) 

3231 

3232 def generate(): 

3233 """Generator for SSE progress updates.""" 

3234 logger.info("SSE generator started") 

3235 _sse_cancel = threading.Event() 

3236 with _active_sse_indexers_lock: 

3237 _active_sse_indexers.setdefault( 

3238 (username, collection_id), set() 

3239 ).add(_sse_cancel) 

3240 worker_thread = None 

3241 try: 

3242 # All setup DB work happens in a SHORT-LIVED session; the per-doc 

3243 # ids/filenames are snapshotted into plain tuples so the long 

3244 # indexing loop below (which yields progress + heartbeats) holds NO 

3245 # session across a yield — see download_bulk for the Starlette/anyio 

3246 # thread-affinity rationale. index_document opens its own session 

3247 # per document in a worker thread and commits its own writes, so the 

3248 # old outer db_session.commit()/flush() was vestigial. 

3249 collection_found = True 

3250 collection_name = None 

3251 doc_info = [] 

3252 with get_user_db_session(username, db_password) as db_session: 

3253 # Verify collection exists in this user's database 

3254 collection = ( 

3255 db_session.query(Collection) 

3256 .filter_by(id=collection_id) 

3257 .first() 

3258 ) 

3259 

3260 if not collection: 

3261 collection_found = False 

3262 else: 

3263 collection_name = collection.name 

3264 # Store embedding metadata AND reset the old index in ONE 

3265 # commit (prevents stale / mixed-model vectors). Committing 

3266 # the config write separately from the reset leaves a window 

3267 # where a crash keeps the new config but the old vectors 

3268 # survive. 

3269 changed = False 

3270 faiss_reset_paths = [] 

3271 if collection.embedding_model is None or force_reindex: 

3272 _store_collection_embedding_metadata( 

3273 collection, rag_service 

3274 ) 

3275 changed = True 

3276 if force_reindex: 

3277 faiss_reset_paths = _reset_collection_for_reindex( 

3278 db_session, collection_id 

3279 ) 

3280 changed = True 

3281 if changed: 

3282 db_session.commit() 

3283 _unlink_reindex_faiss_files(faiss_reset_paths) 

3284 

3285 # Snapshot (id, display name) for each document to index. 

3286 doc_info = [ 

3287 (doc.id, doc.filename or doc.title or "Unknown") 

3288 for _link, doc in _query_documents_to_index( 

3289 db_session, collection_id, force_reindex 

3290 ) 

3291 ] 

3292 

3293 if not collection_found: 

3294 yield f"data: {json.dumps({'type': 'error', 'error': 'Collection not found'})}\n\n" 

3295 return 

3296 

3297 if not doc_info: 

3298 logger.info("No documents to index in collection") 

3299 yield f"data: {json.dumps({'type': 'complete', 'results': {'successful': 0, 'skipped': 0, 'failed': 0, 'message': 'No documents to index'}})}\n\n" 

3300 return 

3301 

3302 total = len(doc_info) 

3303 logger.info(f"Found {total} documents to index") 

3304 results = { 

3305 "successful": 0, 

3306 "skipped": 0, 

3307 "failed": 0, 

3308 "errors": [], 

3309 } 

3310 

3311 yield f"data: {json.dumps({'type': 'start', 'message': f'Indexing {total} documents in collection: {collection_name}'})}\n\n" 

3312 

3313 # Fan out indexing across a bounded worker pool. We run the 

3314 # pool in a background thread so the SSE generator's main 

3315 # thread can still yield heartbeats during long embedding 

3316 # round-trips (matters for nginx / browser SSE timeout 

3317 # behaviour). The progress_callback fired by the parallel 

3318 # helper pushes per-document events into ``progress_queue``, 

3319 # which the generator drains as they arrive. 

3320 # 

3321 # ``_max_workers`` is resolved from settings before the 

3322 # generator starts (closure capture) so the parallel helper 

3323 # never touches SettingsManager from inside a worker. 

3324 progress_queue: queue.Queue = queue.Queue() 

3325 _SENTINEL_COMPLETE = object() 

3326 _SENTINEL_ERROR = object() 

3327 

3328 def _on_progress( 

3329 completed: int, 

3330 total: int, 

3331 title: str, 

3332 status: str, 

3333 ) -> None: 

3334 progress_queue.put( 

3335 ( 

3336 "progress", 

3337 completed, 

3338 total, 

3339 title, 

3340 status, 

3341 ) 

3342 ) 

3343 

3344 def _run_parallel(): 

3345 try: 

3346 # ``is_cancelled`` polls the SSE-disconnect event 

3347 # so the helper can exit early between completions 

3348 # when the client has gone away; the helper's 

3349 # ``pool.shutdown(wait=True, ...)`` still drains 

3350 # in-flight workers before returning so no worker 

3351 # outlives the helper return — see ``index_documents_parallel`` for the 

3352 # rationale. 

3353 aggregate = rag_service.index_documents_parallel( 

3354 doc_info, 

3355 collection_id, 

3356 force_reindex=force_reindex, 

3357 max_workers=_max_workers, 

3358 progress_callback=_on_progress, 

3359 is_cancelled=_sse_cancel.is_set, 

3360 ) 

3361 progress_queue.put((_SENTINEL_COMPLETE, aggregate)) 

3362 except Exception as exc: 

3363 logger.exception( 

3364 "Parallel indexing in SSE generator crashed" 

3365 ) 

3366 progress_queue.put((_SENTINEL_ERROR, exc)) 

3367 finally: 

3368 # Best-effort thread-local DB session cleanup so a 

3369 # long-running worker batch doesn't accumulate 

3370 # scoped sessions (#3194). 

3371 try: 

3372 from ...database.thread_local_session import ( 

3373 cleanup_current_thread, 

3374 ) 

3375 

3376 cleanup_current_thread() 

3377 except Exception: 

3378 logger.debug( 

3379 "best-effort thread-local DB session cleanup", 

3380 exc_info=True, 

3381 ) 

3382 

3383 # Copy the request's contextvars so username-dependent 

3384 # fallbacks (e.g. log attribution) inside the worker resolve 

3385 # to the authenticated user rather than None. 

3386 _par_ctx = contextvars.copy_context() 

3387 worker_thread = threading.Thread( 

3388 target=_par_ctx.run, 

3389 args=(_run_parallel,), 

3390 daemon=True, 

3391 name="index-collection-parallel", 

3392 ) 

3393 worker_thread.start() 

3394 

3395 # Drain the queue: yield progress events as they arrive, 

3396 # yield heartbeat comments on idle to keep SSE proxies 

3397 # from timing out, exit on completion / error sentinels. 

3398 heartbeat_interval = 5 # seconds 

3399 _aggregate = None 

3400 while True: 

3401 try: 

3402 item = progress_queue.get(timeout=heartbeat_interval) 

3403 except queue.Empty: 

3404 # No event in 5s. Heartbeat unless the worker 

3405 # died without signalling (would hang forever). 

3406 if worker_thread.is_alive(): 

3407 yield f": heartbeat {total}\n\n" 

3408 continue 

3409 logger.exception( 

3410 "Indexer worker exited without completion " 

3411 "signal; aborting SSE" 

3412 ) 

3413 yield f"data: {json.dumps({'type': 'error', 'error': 'Indexer stopped unexpectedly'})}\n\n" 

3414 return 

3415 

3416 if item[0] is _SENTINEL_COMPLETE: 

3417 _aggregate = item[1] 

3418 break 

3419 if item[0] is _SENTINEL_ERROR: 3419 ↛ 3420line 3419 didn't jump to line 3420 because the condition on line 3419 was never true

3420 yield f"data: {json.dumps({'type': 'error', 'error': 'An internal error occurred during indexing'})}\n\n" 

3421 return 

3422 

3423 _kind, completed, total, title, status = item 

3424 yield f"data: {json.dumps({'type': 'progress', 'current': completed, 'total': total, 'filename': title, 'percent': int((completed / total) * 100)})}\n\n" 

3425 

3426 if status == "error": 

3427 # Per-doc error surfaced so the browser can show 

3428 # failed documents inline. Scrub creds/keys here 

3429 # (the full trace stays in server logs above). 

3430 # Look the error up from the aggregate's errors 

3431 # list — at this point _aggregate is None 

3432 # (indexer still running), so we accept a brief 

3433 # imprecision on the exact error text and emit 

3434 # a generic doc_error; the final ``complete`` 

3435 # event below will list precise errors. 

3436 yield f"data: {json.dumps({'type': 'doc_error', 'filename': title, 'error': 'Indexing failed'})}\n\n" 

3437 

3438 if _aggregate is not None: 3438 ↛ 3460line 3438 didn't jump to line 3460 because the condition on line 3438 was always true

3439 results["successful"] = _aggregate["successful"] 

3440 results["skipped"] = _aggregate["skipped"] 

3441 results["failed"] = _aggregate["failed"] 

3442 # Replace placeholder errors with the structured ones 

3443 # from the parallel helper so the final ``complete`` 

3444 # event matches what previous serial runs returned. 

3445 results["errors"] = [ 

3446 { 

3447 "filename": err.get("title"), 

3448 "error": sanitize_error_message(str(err.get("error"))) 

3449 or "Failed to index document", 

3450 } 

3451 for err in _aggregate.get("errors", []) 

3452 ] 

3453 logger.info( 

3454 f"Indexing complete: " 

3455 f"{results['successful']} successful, " 

3456 f"{results['failed']} failed, " 

3457 f"{results['skipped']} skipped" 

3458 ) 

3459 

3460 yield f"data: {json.dumps({'type': 'complete', 'results': results})}\n\n" 

3461 logger.info("SSE generator finished successfully") 

3462 

3463 except Exception: 

3464 logger.exception("Error in collection indexing") 

3465 yield f"data: {json.dumps({'type': 'error', 'error': 'An internal error occurred during indexing'})}\n\n" 

3466 finally: 

3467 # Signal disconnect/cancellation first so any in-flight checks notice it immediately, 

3468 # then remove this specific stream's cancel event from the registry under lock. 

3469 _sse_cancel.set() 

3470 with _active_sse_indexers_lock: 

3471 events = _active_sse_indexers.get((username, collection_id)) 

3472 if events is not None: 3472 ↛ 3484line 3472 didn't jump to line 3484

3473 events.discard(_sse_cancel) 

3474 if not events: 3474 ↛ 3484line 3474 didn't jump to line 3484

3475 _active_sse_indexers.pop( 

3476 (username, collection_id), None 

3477 ) 

3478 # On client disconnect this finally can run on the event loop 

3479 # thread (generator close), so the drain must be bounded: give 

3480 # in-flight workers a short grace period, then hand the 

3481 # remaining join + service close to a detached daemon thread. 

3482 # _sse_cancel is already set, so the parallel helper admits no 

3483 # new work and terminates once in-flight docs finish. 

3484 if worker_thread is not None and worker_thread.is_alive(): 

3485 worker_thread.join(timeout=5.0) 

3486 if worker_thread is not None and worker_thread.is_alive(): 

3487 logger.info( 

3488 "Indexer still draining after SSE close; deferring " 

3489 "cleanup to background thread" 

3490 ) 

3491 _lingering_worker = worker_thread 

3492 

3493 def _drain_and_close(): 

3494 _lingering_worker.join() 

3495 safe_close( 

3496 rag_service, 

3497 "rag_service (index-collection SSE, deferred)", 

3498 ) 

3499 

3500 threading.Thread( 

3501 target=_drain_and_close, 

3502 daemon=True, 

3503 name="index-collection-drain", 

3504 ).start() 

3505 else: 

3506 safe_close(rag_service, "rag_service (index-collection SSE)") 

3507 

3508 response = WorkerCleanupStreamingResponse( 

3509 generate(), media_type="text/event-stream" 

3510 ) 

3511 # Prevent buffering for proper SSE streaming 

3512 response.headers["Cache-Control"] = "no-cache, no-transform" 

3513 response.headers["Connection"] = "keep-alive" 

3514 response.headers["X-Accel-Buffering"] = "no" 

3515 return response 

3516 

3517 

3518# ============================================================================= 

3519# Background Indexing Endpoints 

3520# ============================================================================= 

3521 

3522 

3523def _get_rag_service_for_thread( 

3524 collection_id: str, 

3525 username: str, 

3526 db_password: str, 

3527 use_defaults: bool = False, 

3528) -> LibraryRAGService: 

3529 """ 

3530 Create RAG service for use in background threads (no Flask context). 

3531 

3532 Delegates settings resolution to the shared rag_service_factory, then 

3533 propagates db_password to the embedding manager for thread-safe DB access. 

3534 """ 

3535 from ...research_library.services.rag_service_factory import ( 

3536 get_rag_service as _get_rag_service, 

3537 ) 

3538 

3539 service = _get_rag_service( 

3540 username, 

3541 collection_id, 

3542 use_defaults=use_defaults, 

3543 db_password=db_password, 

3544 ) 

3545 # The factory passes db_password to LibraryRAGService, but __init__ stores 

3546 # it in the backing field (_db_password) without propagating to sub-managers. 

3547 # Re-assign via the property setter to propagate to embedding_manager and 

3548 # integrity_manager, which need it for thread-safe session access. 

3549 service.db_password = db_password 

3550 return service 

3551 

3552 

3553def trigger_auto_index( 

3554 document_ids: list[str], 

3555 collection_id: str, 

3556 username: str, 

3557 db_password: str, 

3558) -> None: 

3559 """ 

3560 Trigger automatic RAG indexing for documents if auto-indexing is enabled. 

3561 

3562 This function checks the auto_index_enabled setting and spawns a background 

3563 thread to index the specified documents. It does not block the caller. 

3564 

3565 Args: 

3566 document_ids: List of document IDs to index 

3567 collection_id: The collection to index into 

3568 username: The username for database access 

3569 db_password: The user's database password for thread-safe access 

3570 """ 

3571 from ...database.session_context import get_user_db_session 

3572 

3573 if not document_ids: 

3574 logger.debug("No documents to auto-index") 

3575 return 

3576 

3577 # Check if auto-indexing is enabled 

3578 try: 

3579 with get_user_db_session(username, db_password) as db_session: 

3580 settings = SettingsManager(db_session) 

3581 auto_index_enabled = settings.get_bool_setting( 

3582 "research_library.auto_index_enabled", True 

3583 ) 

3584 

3585 if not auto_index_enabled: 

3586 logger.debug("Auto-indexing is disabled, skipping") 

3587 return 

3588 

3589 try: 

3590 _auto_max_workers = int( 

3591 settings.get_setting("rag.indexing_max_parallel_docs", 4) 

3592 ) 

3593 except Exception: 

3594 _auto_max_workers = 4 

3595 _auto_max_workers = max(1, min(_auto_max_workers, 16)) 

3596 except Exception: 

3597 logger.exception( 

3598 "Failed to check auto-index setting, skipping auto-index" 

3599 ) 

3600 return 

3601 

3602 # Reserve a queue slot before submission. If the indexing queue is 

3603 # saturated (too many uploads in flight), drop this auto-index job 

3604 # rather than letting the executor's unbounded internal queue grow 

3605 # without bound. The user can reindex manually later via the UI. 

3606 if not _try_reserve_auto_index_slot(): 

3607 logger.warning( 

3608 "Auto-index queue saturated ({}+ jobs pending); dropping " 

3609 "auto-index for {} document(s) in collection {}. " 

3610 "Documents are uploaded; trigger a manual reindex if needed.", 

3611 _MAX_PENDING_AUTO_INDEX_JOBS, 

3612 len(document_ids), 

3613 collection_id, 

3614 ) 

3615 return 

3616 

3617 logger.info( 

3618 f"Auto-indexing {len(document_ids)} documents in collection {collection_id}" 

3619 ) 

3620 

3621 # Release the reserved slot EXACTLY ONCE per submission. CPython's 

3622 # ThreadPoolExecutor.submit() enqueues the work item BEFORE it tries to 

3623 # spin up a worker thread; if thread-start raises (e.g. 

3624 # RuntimeError("can't start new thread")), the item is already queued, so 

3625 # a live worker can run _wrapped_worker (releasing in its finally) AND the 

3626 # except block below also fires. A per-call lock + flag makes the release 

3627 # idempotent so that double-fire only releases once — otherwise the 

3628 # counter under-counts in-flight jobs and erodes the OOM bound (the 

3629 # max(0, ...) floor would silently hide the drift). 

3630 _release_lock = threading.Lock() 

3631 _slot_released = {"done": False} 

3632 

3633 def _release_slot_once(): 

3634 with _release_lock: 

3635 if _slot_released["done"]: 

3636 return 

3637 _slot_released["done"] = True 

3638 _release_auto_index_slot() 

3639 

3640 def _wrapped_worker(*args, **kwargs): 

3641 try: 

3642 _auto_index_documents_worker(*args, **kwargs) 

3643 finally: 

3644 _release_slot_once() 

3645 

3646 # Submit to thread pool (bounded concurrency, prevents thread proliferation). 

3647 # Build the executor inside the try too: if _get_auto_index_executor() 

3648 # itself fails (OS thread/mutex exhaustion), the slot must still be 

3649 # released — otherwise a reserved slot leaks permanently. 

3650 try: 

3651 executor = _get_auto_index_executor() 

3652 executor.submit( 

3653 _wrapped_worker, 

3654 document_ids, 

3655 collection_id, 

3656 username, 

3657 db_password, 

3658 _auto_max_workers, 

3659 ) 

3660 except Exception: 

3661 # If submit itself fails (executor shutting down, OOM, etc.), the 

3662 # wrapped worker may never run, so release the slot here. The release 

3663 # is idempotent: if the work item was already enqueued and a worker 

3664 # ran (and released) before thread-start failed, this is a no-op. Do 

3665 # NOT re-raise: the upload has already been committed by the caller, 

3666 # so propagating would turn a successful upload into a 500 (and prompt 

3667 # the client to retry, creating duplicates). Auto-indexing is simply 

3668 # skipped; the user can trigger a manual reindex later via the UI. 

3669 _release_slot_once() 

3670 logger.exception( 

3671 "Failed to submit auto-index job for {} document(s) in " 

3672 "collection {}; upload succeeded, auto-indexing skipped. " 

3673 "Trigger a manual reindex if needed.", 

3674 len(document_ids), 

3675 collection_id, 

3676 ) 

3677 

3678 

3679@thread_cleanup 

3680def _auto_index_documents_worker( 

3681 document_ids: list[str], 

3682 collection_id: str, 

3683 username: str, 

3684 db_password: str, 

3685 max_workers: int = 4, 

3686) -> None: 

3687 """ 

3688 Background worker to index documents automatically. 

3689 

3690 This is a simpler worker than _background_index_worker - it doesn't track 

3691 progress via TaskMetadata since it's meant to be a lightweight auto-indexing 

3692 operation. 

3693 

3694 Documents inside one call are indexed concurrently via 

3695 :meth:`LibraryRAGService.index_documents_parallel`. The outer 

3696 ``_auto_index_executor`` already parallelises separate jobs at the upload 

3697 layer (one job per upload batch) and we additionally fan out inside each 

3698 job, so a burst of uploads doesn't serialise within a single upload's 

3699 document set. 

3700 

3701 The ``max_workers`` parameter is resolved on the caller (which owns 

3702 the Flask request context and therefore a real 

3703 :func:`get_settings_manager` call). Background-thread workers must 

3704 NOT call ``get_settings_manager()`` without an explicit 

3705 ``db_session=`` — see pre-commit hook 

3706 ``check-settings-manager-thread-safety`` and issue #3453. 

3707 """ 

3708 

3709 try: 

3710 # Create RAG service (thread-safe, no Flask context needed) 

3711 with _get_rag_service_for_thread( 

3712 collection_id, username, db_password 

3713 ) as rag_service: 

3714 # The auto-indexer doesn't surface per-doc titles to a user, 

3715 # so we stub them with empty strings — the parallel helper 

3716 # only reads titles for progress reporting. 

3717 doc_info = [(doc_id, "") for doc_id in document_ids] 

3718 aggregate = rag_service.index_documents_parallel( 

3719 doc_info, 

3720 collection_id, 

3721 force_reindex=False, 

3722 max_workers=max(1, min(int(max_workers), 16)), 

3723 ) 

3724 logger.info( 

3725 f"Auto-indexing complete: {aggregate['successful']}" 

3726 f"/{len(document_ids)} documents indexed " 

3727 f"(skipped={aggregate['skipped']}, " 

3728 f"failed={aggregate['failed']})" 

3729 ) 

3730 

3731 except Exception: 

3732 logger.exception("Auto-indexing worker failed") 

3733 

3734 

3735def _sanitized_indexing_errors(results: dict, limit: int = 50) -> list: 

3736 """Return a sanitized, bounded list of per-document errors for the 

3737 indexing task metadata. Used by both terminal paths of 

3738 :func:`_background_index_worker` so the scrubbing logic is defined once. 

3739 """ 

3740 return [ 

3741 { 

3742 "doc_id": item.get("doc_id"), 

3743 "title": item.get("title"), 

3744 "error": sanitize_error_message( 

3745 str(item.get("error") or "Indexing failed") 

3746 ), 

3747 } 

3748 for item in results.get("errors", [])[:limit] 

3749 ] 

3750 

3751 

3752@thread_cleanup 

3753def _background_index_worker( 

3754 task_id: str, 

3755 collection_id: str, 

3756 username: str, 

3757 db_password: str, 

3758 force_reindex: bool, 

3759 max_workers: int = 4, 

3760): 

3761 """ 

3762 Background worker thread for indexing documents. 

3763 Updates TaskMetadata with progress and checks for cancellation. 

3764 

3765 The ``max_workers`` parameter bounds the per-job fan-out into 

3766 :meth:`LibraryRAGService.index_documents_parallel`. It must be 

3767 resolved on the route (which has Flask app context), not inside 

3768 this worker — background threads cannot call 

3769 :func:`get_settings_manager` safely (#3453, pre-commit hook 

3770 ``check-settings-manager-thread-safety``). 

3771 """ 

3772 from ...database.session_context import get_user_db_session 

3773 

3774 try: 

3775 # Create RAG service (thread-safe, no Flask context needed) 

3776 with _get_rag_service_for_thread( 

3777 collection_id, username, db_password, use_defaults=force_reindex 

3778 ) as rag_service: 

3779 with get_user_db_session(username, db_password) as db_session: 

3780 # Get collection 

3781 collection = ( 

3782 db_session.query(Collection) 

3783 .filter_by(id=collection_id) 

3784 .first() 

3785 ) 

3786 

3787 if not collection: 

3788 _update_task_status( 

3789 username, 

3790 db_password, 

3791 task_id, 

3792 status="failed", 

3793 error_message="Collection not found", 

3794 ) 

3795 return 

3796 

3797 # Store embedding metadata AND reset the old index in ONE commit, 

3798 # so a crash between them can't leave the new config committed 

3799 # while the old (stale/mixed-model) vectors survive. 

3800 changed = False 

3801 faiss_reset_paths = [] 

3802 if collection.embedding_model is None or force_reindex: 

3803 _store_collection_embedding_metadata( 

3804 collection, rag_service 

3805 ) 

3806 changed = True 

3807 if force_reindex: 

3808 faiss_reset_paths = _reset_collection_for_reindex( 

3809 db_session, collection_id 

3810 ) 

3811 changed = True 

3812 if changed: 

3813 db_session.commit() 

3814 _unlink_reindex_faiss_files(faiss_reset_paths) 

3815 

3816 # Get documents to index 

3817 doc_links = _query_documents_to_index( 

3818 db_session, collection_id, force_reindex 

3819 ) 

3820 

3821 if not doc_links: 

3822 _update_task_status( 

3823 username, 

3824 db_password, 

3825 task_id, 

3826 status="completed", 

3827 progress_message="No documents to index", 

3828 ) 

3829 return 

3830 

3831 total = len(doc_links) 

3832 results = {"successful": 0, "skipped": 0, "failed": 0} 

3833 

3834 # Update task with total count 

3835 _update_task_status( 

3836 username, 

3837 db_password, 

3838 task_id, 

3839 progress_total=total, 

3840 progress_message=f"Indexing {total} documents", 

3841 ) 

3842 

3843 # The caller (start_background_index, which owns the 

3844 # Flask request context) resolved ``max_workers`` from 

3845 # ``rag.indexing_max_parallel_docs`` and forwarded it 

3846 # here. Workers must NOT call ``get_settings_manager()`` 

3847 # themselves — no Flask app context, pre-commit 

3848 # ``check-settings-manager-thread-safety`` would reject, 

3849 # and #3453 documents the data-loss shape that produces. 

3850 _max_workers = max(1, min(int(max_workers), 16)) 

3851 

3852 doc_info = [ 

3853 (doc.id, doc.filename or doc.title or "Unknown") 

3854 for _link, doc in doc_links 

3855 ] 

3856 

3857 def _on_progress( 

3858 completed: int, 

3859 total: int, 

3860 title: str, 

3861 status: str, 

3862 ) -> None: 

3863 _update_task_status( 

3864 username, 

3865 db_password, 

3866 task_id, 

3867 progress_current=completed, 

3868 progress_message=( 

3869 f"Indexing {completed}/{total}: {title}" 

3870 ), 

3871 ) 

3872 

3873 def _is_cancelled() -> bool: 

3874 return _is_task_cancelled(username, db_password, task_id) 

3875 

3876 aggregate = rag_service.index_documents_parallel( 

3877 doc_info, 

3878 collection_id, 

3879 force_reindex=force_reindex, 

3880 max_workers=_max_workers, 

3881 progress_callback=_on_progress, 

3882 is_cancelled=_is_cancelled, 

3883 ) 

3884 

3885 results["successful"] = aggregate["successful"] 

3886 results["skipped"] = aggregate["skipped"] 

3887 results["failed"] = aggregate["failed"] 

3888 results["errors"] = aggregate["errors"] 

3889 

3890 if aggregate["cancelled"]: 

3891 completed = ( 

3892 aggregate["successful"] 

3893 + aggregate["skipped"] 

3894 + aggregate["failed"] 

3895 ) 

3896 _update_task_status( 

3897 username, 

3898 db_password, 

3899 task_id, 

3900 status="cancelled", 

3901 progress_message=( 

3902 f"Cancelled after {completed}/{total} documents" 

3903 ), 

3904 ) 

3905 logger.info( 

3906 f"Indexing task {task_id} was cancelled " 

3907 f"after {completed}/{total} documents" 

3908 ) 

3909 db_session.commit() 

3910 return 

3911 

3912 db_session.commit() 

3913 

3914 # Wrap reconciliation so a transient store/DB error does not 

3915 # leave the task in "processing" forever (the previous code 

3916 # let exceptions escape, so _update_task_status never ran and 

3917 # the UI polled indefinitely — see PR #5235 review comment 

3918 # 5085604502). On failure we still record a terminal status 

3919 # so cleanup_old_tasks can reap the row. 

3920 try: 

3921 reconciliation = rag_service.reconcile_collection_index( 

3922 collection_id 

3923 ) 

3924 except Exception as exc: 

3925 logger.exception( 

3926 f"Background indexing task {task_id}: reconciliation failed" 

3927 ) 

3928 reconciliation = { 

3929 "indexed_documents": results["successful"], 

3930 "indexed_chunks": 0, 

3931 "live_vectors": 0, 

3932 "orphan_vectors": 0, 

3933 "reconciliation_skipped": True, 

3934 "reconciliation_reason": sanitize_error_message(str(exc)), 

3935 } 

3936 if not isinstance(reconciliation, dict): 

3937 reconciliation = { 

3938 "indexed_documents": results["successful"], 

3939 "indexed_chunks": 0, 

3940 "live_vectors": 0, 

3941 "orphan_vectors": 0, 

3942 } 

3943 # The reconciler returns ``reconciliation_skipped=True`` when 

3944 # live_ids is empty but chunk rows exist (refuse-to-shrink 

3945 # guard). Surface this as a task failure so the user sees the 

3946 # durable-state inconsistency instead of a misleading 

3947 # "0 indexed" success. 

3948 if isinstance(reconciliation, dict) and reconciliation.get( 

3949 "reconciliation_skipped" 

3950 ): 

3951 reason = reconciliation.get("reconciliation_reason") or ( 

3952 reconciliation.get("reason") 

3953 or "stored vectors could not be loaded" 

3954 ) 

3955 sanitized_reason = sanitize_error_message(reason) 

3956 logger.warning( 

3957 f"Background indexing task {task_id}: reconciliation " 

3958 f"skipped — {sanitized_reason}" 

3959 ) 

3960 _update_task_status( 

3961 username, 

3962 db_password, 

3963 task_id, 

3964 status="failed", 

3965 progress_current=total, 

3966 progress_message=( 

3967 f"Completed: {results['successful']} indexed, " 

3968 f"{results['failed']} failed, " 

3969 f"{results['skipped']} skipped; reconciliation " 

3970 f"skipped: {sanitized_reason}" 

3971 ), 

3972 error_message=( 

3973 f"Reconciliation skipped: {sanitized_reason}. " 

3974 "Indexed flags preserved; verify the FAISS index " 

3975 "before retrying." 

3976 ), 

3977 result_metadata={ 

3978 **{k: v for k, v in results.items() if k != "errors"}, 

3979 # Omit durable_indexed_documents/chunks and 

3980 # live_vectors/orphan_vectors: true durable state is 

3981 # unverified on the skipped path, and the UI 

3982 # suppresses its "Durable vector store: ..." sentence 

3983 # when these keys are absent (``Number.isInteger`` 

3984 # on undefined returns false). Reporting zeros here 

3985 # would imply data loss where state is merely 

3986 # unverified — see PR #5235 review comment 

3987 # 5085604502. 

3988 "reconciliation_skipped": True, 

3989 "reconciliation_reason": sanitized_reason, 

3990 "errors": _sanitized_indexing_errors(results), 

3991 }, 

3992 ) 

3993 return 

3994 durable_documents = reconciliation["indexed_documents"] 

3995 durable_chunks = reconciliation["indexed_chunks"] 

3996 task_result = { 

3997 **results, 

3998 "durable_indexed_documents": durable_documents, 

3999 "durable_indexed_chunks": durable_chunks, 

4000 "live_vectors": reconciliation["live_vectors"], 

4001 "orphan_vectors": reconciliation["orphan_vectors"], 

4002 "errors": _sanitized_indexing_errors(results), 

4003 } 

4004 has_failures = results["failed"] > 0 

4005 status = "failed" if has_failures else "completed" 

4006 message = ( 

4007 f"Completed: {results['successful']} indexed, " 

4008 f"{results['failed']} failed, {results['skipped']} skipped; " 

4009 f"durable vector store: {durable_documents} documents, " 

4010 f"{durable_chunks} chunks" 

4011 ) 

4012 _update_task_status( 

4013 username, 

4014 db_password, 

4015 task_id, 

4016 status=status, 

4017 progress_current=total, 

4018 progress_message=message, 

4019 error_message=( 

4020 f"{results['failed']} document(s) failed to index. " 

4021 "The collection was reconciled to the durable vector store." 

4022 if has_failures 

4023 else None 

4024 ), 

4025 result_metadata=task_result, 

4026 ) 

4027 logger.info( 

4028 f"Background indexing task {task_id} completed: {results}" 

4029 ) 

4030 

4031 except Exception as e: 

4032 logger.exception(f"Background indexing task {task_id} failed") 

4033 _update_task_status( 

4034 username, 

4035 db_password, 

4036 task_id, 

4037 status="failed", 

4038 # Scrub creds/keys: this is later returned to the client by the 

4039 # index-status endpoint; full trace stays in server logs above. 

4040 error_message=sanitize_error_message(str(e)), 

4041 ) 

4042 

4043 

4044def _is_database_locked_error(exc: BaseException) -> bool: 

4045 """Return True if ``exc`` looks like a SQLite ``database is locked`` error. 

4046 

4047 Background indexing can hit transient ``database is locked`` errors when 

4048 SQLite is contended; those are recoverable via a short backoff retry. 

4049 Anything else (connection failure, integrity error, programming mistake) 

4050 should surface immediately so the outer exception handler marks the task 

4051 as failed and sets ``completed_at`` for reaping. 

4052 """ 

4053 return "database is locked" in str(exc).lower() 

4054 

4055 

4056@retry( 

4057 stop=stop_after_attempt(5), 

4058 wait=wait_exponential(multiplier=0.05), 

4059 retry=retry_if_exception(_is_database_locked_error), 

4060) 

4061def _do_update_task_status( 

4062 username: str, 

4063 db_password: str, 

4064 task_id: str, 

4065 status: str = None, 

4066 progress_current: int = None, 

4067 progress_total: int = None, 

4068 progress_message: str = None, 

4069 error_message: str = None, 

4070 result_metadata: dict = None, 

4071): 

4072 """Perform the task-status update under a tenacity-managed retry loop. 

4073 

4074 Only SQLite ``database is locked`` errors are retried (up to 5 attempts 

4075 with 0.05s exponential backoff); any other exception escapes so the 

4076 outer caller can log it without retrying. See PR #5235 review comment 

4077 3669857779. 

4078 """ 

4079 from ...database.session_context import get_user_db_session 

4080 

4081 with get_user_db_session(username, db_password) as db_session: 

4082 task = db_session.query(TaskMetadata).filter_by(task_id=task_id).first() 

4083 if task: 

4084 # Preserve terminal states: once cancelled or failed, only 

4085 # updates confirming that same state are allowed. 

4086 if task.status in ("cancelled", "failed") and status != task.status: 

4087 return 

4088 if status == "completed" and task.status != "processing": 4088 ↛ 4089line 4088 didn't jump to line 4089 because the condition on line 4088 was never true

4089 return 

4090 if status is not None: 

4091 task.status = status 

4092 # Set completed_at for ALL terminal statuses so 

4093 # ``cleanup_old_tasks`` (which filters on 

4094 # ``status in ["completed", "failed", "cancelled"] AND 

4095 # completed_at < cutoff_date``) can reap them. 

4096 # Previously only "completed" set the timestamp, 

4097 # leaving failed/cancelled rows permanent. 

4098 if status in ("completed", "failed", "cancelled"): 4098 ↛ 4100line 4098 didn't jump to line 4100 because the condition on line 4098 was always true

4099 task.completed_at = datetime.now(UTC) 

4100 if progress_current is not None: 

4101 task.progress_current = progress_current 

4102 if progress_total is not None: 

4103 task.progress_total = progress_total 

4104 if progress_message is not None: 

4105 task.progress_message = progress_message 

4106 if error_message is not None: 

4107 task.error_message = error_message 

4108 if result_metadata is not None: 

4109 metadata = dict(task.metadata_json or {}) 

4110 metadata["result"] = result_metadata 

4111 task.metadata_json = metadata 

4112 db_session.commit() 

4113 

4114 

4115def _update_task_status( 

4116 username: str, 

4117 db_password: str, 

4118 task_id: str, 

4119 status: str = None, 

4120 progress_current: int = None, 

4121 progress_total: int = None, 

4122 progress_message: str = None, 

4123 error_message: str = None, 

4124 result_metadata: dict = None, 

4125): 

4126 """Update task metadata in the database.""" 

4127 try: 

4128 _do_update_task_status( 

4129 username, 

4130 db_password, 

4131 task_id, 

4132 status=status, 

4133 progress_current=progress_current, 

4134 progress_total=progress_total, 

4135 progress_message=progress_message, 

4136 error_message=error_message, 

4137 result_metadata=result_metadata, 

4138 ) 

4139 except Exception: 

4140 logger.exception(f"Failed to update task status for {task_id}") 

4141 

4142 

4143def _is_task_cancelled(username: str, db_password: str, task_id: str) -> bool: 

4144 """Check if a task has been cancelled.""" 

4145 from ...database.session_context import get_user_db_session 

4146 

4147 try: 

4148 with get_user_db_session(username, db_password) as db_session: 

4149 task = ( 

4150 db_session.query(TaskMetadata) 

4151 .filter_by(task_id=task_id) 

4152 .first() 

4153 ) 

4154 return task and task.status == "cancelled" 

4155 except Exception: 

4156 logger.warning( 

4157 "Could not check cancellation status for task {}", task_id 

4158 ) 

4159 return False 

4160 

4161 

4162@router.post("/api/collections/{collection_id}/index/start") 

4163async def start_background_index( 

4164 request: Request, 

4165 collection_id, 

4166 username: Annotated[str, Depends(require_auth)], 

4167): 

4168 """Start background indexing for a collection.""" 

4169 from ...database.session_passwords import session_password_store 

4170 

4171 session_id = request.session.get("session_id") 

4172 

4173 # Get password for thread access 

4174 db_password = None 

4175 if session_id: 

4176 db_password = session_password_store.get_session_password( 

4177 username, session_id 

4178 ) 

4179 

4180 # Parse request body. `await request.json()` raising on malformed bytes 

4181 # is left to propagate to the app's registered json.JSONDecodeError -> 

4182 # 400 handler (no local except here to shadow it). A truthy non-dict 

4183 # body (bare string/int/list) is guarded explicitly below — `or {}` 

4184 # alone only catches FALSY bodies (null, [], ""), letting a truthy 

4185 # non-dict reach `.get()` and raise AttributeError -> 500. 

4186 data = await request.json() 

4187 if not isinstance(data, dict): 

4188 return json_body_error("success", "Request body must be valid JSON") 

4189 force_reindex = data.get("force_reindex", False) 

4190 if not isinstance(force_reindex, bool): 

4191 return JSONResponse( 

4192 { 

4193 "success": False, 

4194 "error": "force_reindex must be a boolean", 

4195 }, 

4196 status_code=400, 

4197 ) 

4198 

4199 # The lock acquisition and SQLCipher session open below are blocking — 

4200 # run the whole check-and-create + thread spawn in the threadpool so a 

4201 # contended lock or a cold-engine PBKDF2 derivation can't stall the 

4202 # event loop (contextvars propagate through to_thread, so the 

4203 # copy_context() for the worker still sees this request's user). 

4204 return await run_db_sync( 

4205 _start_background_index_sync, 

4206 collection_id, 

4207 username, 

4208 db_password, 

4209 force_reindex, 

4210 ) 

4211 

4212 

4213def _start_background_index_sync( 

4214 collection_id, username, db_password, force_reindex 

4215): 

4216 from ...database.session_context import get_user_db_session 

4217 

4218 # Serialize check-and-create per (user, collection) to close the TOCTOU 

4219 # window between scanning for an in-progress task and inserting a new 

4220 # row. Two concurrent threads would otherwise both see no match and 

4221 # both spawn an indexer on the same collection, racing FAISS writes. 

4222 # Single-worker scope only (matches the rest of this module's locking 

4223 # model; multi-worker deployments already documented as unsupported 

4224 # for this kind of coordination). 

4225 lock_key = (username, str(collection_id)) 

4226 lock = _start_bg_index_locks.setdefault(lock_key, threading.Lock()) 

4227 

4228 try: 

4229 with lock, get_user_db_session(username, db_password) as db_session: 

4230 # Scan ALL in-progress indexing tasks for one that matches this 

4231 # collection_id. The old query did `.first()` and then checked 

4232 # — so it missed collision when another collection's task 

4233 # happened to sort first, and also raced when two requests for 

4234 # the same collection arrived concurrently. Scanning all rows 

4235 # is fine: `TaskMetadata.status == "processing"` is bounded by 

4236 # the number of in-flight indexes (small). 

4237 in_progress = ( 

4238 db_session.query(TaskMetadata) 

4239 .filter( 

4240 TaskMetadata.task_type == "indexing", 

4241 TaskMetadata.status == "processing", 

4242 ) 

4243 .all() 

4244 ) 

4245 

4246 for existing_task in in_progress: 

4247 metadata = existing_task.metadata_json or {} 

4248 if metadata.get("collection_id") == collection_id: 

4249 return JSONResponse( 

4250 { 

4251 "success": False, 

4252 "error": "Indexing is already in progress for this collection", 

4253 "task_id": existing_task.task_id, 

4254 }, 

4255 status_code=409, 

4256 ) 

4257 

4258 # Create new task 

4259 task_id = str(uuid.uuid4()) 

4260 task = TaskMetadata( 

4261 task_id=task_id, 

4262 status="processing", 

4263 task_type="indexing", 

4264 created_at=datetime.now(UTC), 

4265 started_at=datetime.now(UTC), 

4266 progress_current=0, 

4267 progress_total=0, 

4268 progress_message="Starting indexing...", 

4269 metadata_json={ 

4270 "collection_id": collection_id, 

4271 "force_reindex": force_reindex, 

4272 }, 

4273 ) 

4274 db_session.add(task) 

4275 db_session.commit() 

4276 

4277 # Resolve per-job parallel fan-out here (request-scoped 

4278 # session) and forward into the worker — the worker itself 

4279 # runs on a background thread where the no-arg 

4280 # ``get_settings_manager()`` is unsafe (#3453). 

4281 try: 

4282 _bg_settings = get_settings_manager(db_session, username) 

4283 _bg_max_workers = int( 

4284 _bg_settings.get_setting( 

4285 "rag.indexing_max_parallel_docs", 4 

4286 ) 

4287 ) 

4288 except Exception: 

4289 _bg_max_workers = 4 

4290 _bg_max_workers = max(1, min(_bg_max_workers, 16)) 

4291 

4292 # Start background thread. Copy the request's contextvars so 

4293 # any service code inside the worker that calls 

4294 # `get_current_username()` sees the actual user instead of None. 

4295 _bg_ctx = contextvars.copy_context() 

4296 

4297 def _ctx_worker(): 

4298 _bg_ctx.run( 

4299 _background_index_worker, 

4300 task_id, 

4301 collection_id, 

4302 username, 

4303 db_password, 

4304 force_reindex, 

4305 _bg_max_workers, 

4306 ) 

4307 

4308 thread = threading.Thread(target=_ctx_worker, daemon=True) 

4309 thread.start() 

4310 

4311 logger.info( 

4312 f"Started background indexing task {task_id} for collection {collection_id}" 

4313 ) 

4314 

4315 return { 

4316 "success": True, 

4317 "task_id": task_id, 

4318 "message": "Indexing started in background", 

4319 } 

4320 

4321 except Exception: 

4322 logger.exception("Failed to start background indexing") 

4323 return JSONResponse( 

4324 { 

4325 "success": False, 

4326 "error": "Failed to start indexing. Please try again.", 

4327 }, 

4328 status_code=500, 

4329 ) 

4330 

4331 

4332@router.get("/api/collections/{collection_id}/index/status") 

4333@limiter.exempt 

4334def get_index_status( 

4335 request: Request, 

4336 collection_id, 

4337 username: Annotated[str, Depends(require_auth)], 

4338): 

4339 """Get the current indexing status for a collection.""" 

4340 from ...database.session_context import get_user_db_session 

4341 from ...database.session_passwords import session_password_store 

4342 

4343 session_id = request.session.get("session_id") 

4344 

4345 db_password = None 

4346 if session_id: 

4347 db_password = session_password_store.get_session_password( 

4348 username, session_id 

4349 ) 

4350 

4351 try: 

4352 with get_user_db_session(username, db_password) as db_session: 

4353 # Find the most recent indexing task FOR THIS COLLECTION. 

4354 # 

4355 # collection_id is stored inside the metadata_json JSON column, so 

4356 # we can't portably filter on it in SQL (SQLite/SQLCipher JSON 

4357 # support varies). Instead, scan recent indexing tasks newest-first 

4358 # and return the first whose metadata.collection_id matches. This 

4359 # is scoped per collection: a newer indexing task for a DIFFERENT 

4360 # collection no longer makes this one falsely report "idle". 

4361 # 

4362 # This endpoint is polled every ~2s during a reindex and the 

4363 # indexing-task table is never pruned, so an unbounded .all() would 

4364 # materialize the entire history on every poll (and task_type / 

4365 # created_at are unindexed). Bound the scan to the newest N tasks. 

4366 # The task we're looking for was just created by the reindex that 

4367 # started this poll, so it sits near the top; N=200 is large enough 

4368 # that concurrent reindexes of other collections can't push it out 

4369 # of the window before it terminates. Trade-off: if more than ~200 

4370 # newer indexing tasks for OTHER collections appear while this one 

4371 # is still in-flight, the scoped lookup falls back to "idle". 

4372 recent_task_scan_limit = 200 

4373 recent_tasks = ( 

4374 db_session.query(TaskMetadata) 

4375 .filter(TaskMetadata.task_type == "indexing") 

4376 .order_by(TaskMetadata.created_at.desc()) 

4377 .limit(recent_task_scan_limit) 

4378 .all() 

4379 ) 

4380 

4381 task = None 

4382 for candidate in recent_tasks: 

4383 metadata = candidate.metadata_json or {} 

4384 if metadata.get("collection_id") == collection_id: 

4385 task = candidate 

4386 break 

4387 

4388 if not task: 

4389 return { 

4390 "status": "idle", 

4391 "collection_id": collection_id, 

4392 "message": "No indexing task for this collection", 

4393 } 

4394 

4395 return { 

4396 "task_id": task.task_id, 

4397 "collection_id": collection_id, 

4398 "status": task.status, 

4399 "progress_current": task.progress_current or 0, 

4400 "progress_total": task.progress_total or 0, 

4401 "progress_message": task.progress_message, 

4402 "error_message": task.error_message, 

4403 "result": (task.metadata_json or {}).get("result"), 

4404 "created_at": task.created_at.isoformat() 

4405 if task.created_at 

4406 else None, 

4407 "completed_at": task.completed_at.isoformat() 

4408 if task.completed_at 

4409 else None, 

4410 } 

4411 

4412 except Exception: 

4413 logger.exception("Failed to get index status") 

4414 return JSONResponse( 

4415 { 

4416 "status": "error", 

4417 "error": "Failed to get indexing status. Please try again.", 

4418 }, 

4419 status_code=500, 

4420 ) 

4421 

4422 

4423@router.post("/api/collections/{collection_id}/index/cancel") 

4424def cancel_indexing( 

4425 request: Request, 

4426 collection_id, 

4427 username: Annotated[str, Depends(require_auth)], 

4428): 

4429 """Cancel an active indexing task for a collection.""" 

4430 from ...database.session_context import get_user_db_session 

4431 from ...database.session_passwords import session_password_store 

4432 

4433 session_id = request.session.get("session_id") 

4434 

4435 db_password = None 

4436 if session_id: 

4437 db_password = session_password_store.get_session_password( 

4438 username, session_id 

4439 ) 

4440 

4441 try: 

4442 # Signal active process-local SSE generator(s) if present 

4443 sse_cancelled = False 

4444 with _active_sse_indexers_lock: 

4445 sse_events = _active_sse_indexers.get((username, collection_id)) 

4446 if sse_events: 

4447 for sse_event in sse_events: 

4448 sse_event.set() 

4449 sse_cancelled = True 

4450 logger.info( 

4451 f"Signalled active SSE indexing event(s) for collection {collection_id}" 

4452 ) 

4453 

4454 with get_user_db_session(username, db_password) as db_session: 

4455 # Find active indexing task for this collection 

4456 task = ( 

4457 db_session.query(TaskMetadata) 

4458 .filter( 

4459 TaskMetadata.task_type == "indexing", 

4460 TaskMetadata.status == "processing", 

4461 ) 

4462 .first() 

4463 ) 

4464 

4465 matched_task = None 

4466 if task: 

4467 metadata = task.metadata_json or {} 

4468 if metadata.get("collection_id") == collection_id: 

4469 matched_task = task 

4470 

4471 if not matched_task and not sse_cancelled: 

4472 if task: 

4473 return JSONResponse( 

4474 { 

4475 "success": False, 

4476 "error": "No active indexing task for this collection", 

4477 }, 

4478 status_code=404, 

4479 ) 

4480 return JSONResponse( 

4481 { 

4482 "success": False, 

4483 "error": "No active indexing task found", 

4484 }, 

4485 status_code=404, 

4486 ) 

4487 

4488 if matched_task: 

4489 # This endpoint must surface a failed cancellation write. 

4490 # Background workers use the best-effort wrapper, but returning 

4491 # success here after it swallowed an error would mislead the 

4492 # caller while the task remains active. 

4493 task_id_to_cancel = matched_task.task_id 

4494 _do_update_task_status( 

4495 username, 

4496 db_password, 

4497 task_id_to_cancel, 

4498 status="cancelled", 

4499 progress_message="Cancellation requested...", 

4500 ) 

4501 

4502 logger.info( 

4503 f"Cancelled indexing task {task_id_to_cancel} for collection {collection_id}" 

4504 ) 

4505 

4506 return { 

4507 "success": True, 

4508 "message": "Cancellation requested", 

4509 "task_id": matched_task.task_id if matched_task else None, 

4510 } 

4511 

4512 except Exception: 

4513 logger.exception("Failed to cancel indexing") 

4514 return JSONResponse( 

4515 { 

4516 "success": False, 

4517 "error": "Failed to cancel indexing. Please try again.", 

4518 }, 

4519 status_code=500, 

4520 ) 

4521 

4522 

4523# Research History Semantic Search Routes have been moved to 

4524# web/routers/library_search.py