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

1468 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 Future, 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_message, 

67) 

68from ...utilities.db_utils import get_settings_manager 

69from ...utilities.resource_utils import safe_close 

70from ...research_library.services.library_rag_service import LibraryRAGService 

71from ...settings.exceptions import SettingsValueError 

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 ..dependencies.json_body import json_body_error, read_json_dict 

91from .notes import _log_value_preview 

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_EMBEDDING_TEST_SENTENCE = ( 

108 "This is a test sentence to verify the embedding model is working " 

109 "correctly." 

110) 

111 

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

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

114# Process-local cancellation and admission require a single worker process. 

115# Multiple workers need shared admission and cancellation coordination; 

116# sticky sessions alone do not exclude two devices indexing the same collection. 

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

118_active_sse_indexers_lock = threading.Lock() 

119# A cancelled task can still have workers draining. Keep admission closed 

120# until its worker exits, independently of the task's persisted status. 

121_active_background_indexers: Dict[Tuple[str, str], str] = {} 

122 

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

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

125# fast if the dependency is ever removed. 

126 

127 

128def _agent_enabled_default_on(collection) -> bool: 

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

130 

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

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

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

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

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

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

137 """ 

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

139 

140 

141def _is_protected_collection(collection) -> bool: 

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

143 

144 Single source of truth is PROTECTED_COLLECTION_TYPES in the deletion 

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

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

147 hide destructive affordances instead of offering an action the server 

148 categorically refuses with a 409. 

149 """ 

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

151 PROTECTED_COLLECTION_TYPES, 

152 ) 

153 

154 return collection.collection_type in PROTECTED_COLLECTION_TYPES 

155 

156 

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

158# 

159# ThreadPoolExecutor's internal _work_queue is unbounded. Without 

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

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

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

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

164_auto_index_executor: ThreadPoolExecutor | None = None 

165_auto_index_executor_lock = threading.Lock() 

166 

167# Per-(user, collection) locks guarding admission for all collection indexers. 

168# Keyed by tuple so two users indexing different 

169# collections don't serialize against each other. 

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

171 

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

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

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

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

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

177# more headroom. 

178_MAX_PENDING_AUTO_INDEX_JOBS = 100 

179_pending_auto_index_jobs = 0 

180_pending_auto_index_lock = threading.Lock() 

181 

182 

183def _get_auto_index_executor() -> ThreadPoolExecutor: 

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

185 global _auto_index_executor 

186 with _auto_index_executor_lock: 

187 if _auto_index_executor is None: 

188 _auto_index_executor = ThreadPoolExecutor( 

189 max_workers=4, 

190 thread_name_prefix="auto_index_", 

191 ) 

192 return _auto_index_executor 

193 

194 

195def _try_reserve_auto_index_slot() -> bool: 

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

197 

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

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

200 blocks all submissions. 

201 """ 

202 global _pending_auto_index_jobs 

203 with _pending_auto_index_lock: 

204 if _pending_auto_index_jobs >= _MAX_PENDING_AUTO_INDEX_JOBS: 

205 return False 

206 _pending_auto_index_jobs += 1 

207 return True 

208 

209 

210def _release_auto_index_slot() -> None: 

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

212 global _pending_auto_index_jobs 

213 with _pending_auto_index_lock: 

214 _pending_auto_index_jobs = max(0, _pending_auto_index_jobs - 1) 

215 

216 

217def _shutdown_auto_index_executor() -> None: 

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

219 

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

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

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

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

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

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

226 """ 

227 global _auto_index_executor, _pending_auto_index_jobs 

228 with _auto_index_executor_lock: 

229 executor = _auto_index_executor 

230 _auto_index_executor = None 

231 if executor is not None: 

232 executor.shutdown(wait=True) 

233 with _pending_auto_index_lock: 

234 _pending_auto_index_jobs = 0 

235 

236 

237atexit.register(_shutdown_auto_index_executor) 

238 

239 

240def get_rag_service( 

241 request: Request, 

242 username: str, 

243 collection_id: Optional[str] = None, 

244 use_defaults: bool = False, 

245) -> LibraryRAGService: 

246 """ 

247 Get RAG service instance with appropriate settings. 

248 

249 Args: 

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

251 username: The authenticated username. 

252 collection_id: Optional collection UUID to load stored settings from 

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

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

255 default embedding model is picked up. 

256 """ 

257 from ...research_library.services.rag_service_factory import ( 

258 get_rag_service as _get_rag_service, 

259 ) 

260 from ...database.session_passwords import session_password_store 

261 

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

263 db_password = None 

264 if session_id: 

265 db_password = session_password_store.get_session_password( 

266 username, session_id 

267 ) 

268 if not db_password: 

269 db_password = session_password_store.get_any_session_password(username) 

270 return _get_rag_service( 

271 username, 

272 collection_id, 

273 use_defaults=use_defaults, 

274 db_password=db_password, 

275 ) 

276 

277 

278def _get_text_separators(settings): 

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

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

281 text_separators = settings.get_setting( 

282 "local_search_text_separators", 

283 DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS_JSON, 

284 ) 

285 

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

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

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

289 if isinstance(text_separators, str): 

290 try: 

291 text_separators = json.loads(text_separators) 

292 except json.JSONDecodeError: 

293 logger.warning( 

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

295 text_separators, 

296 ) 

297 text_separators = DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS 

298 

299 return text_separators 

300 

301 

302def _parse_configured_text_separators(value): 

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

304 

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

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

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

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

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

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

311 """ 

312 separators = value 

313 if isinstance(value, str): 

314 try: 

315 separators = json.loads(value) 

316 except json.JSONDecodeError: 

317 return None 

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

319 isinstance(separator, str) for separator in separators 

320 ): 

321 return None 

322 return separators 

323 

324 

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

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

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

328# 

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

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

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

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

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

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

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

336_INTERNAL_MODULE_PREFIX = "local_deep_research" 

337 

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

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

340_UPSTREAM_MODULE_PREFIXES = ( 

341 "openai", 

342 "httpx", 

343 "requests", 

344 "urllib3", 

345 "anthropic", 

346) 

347 

348# Upstream exception classes that mean "the request never reached the 

349# provider, or never got an answer" as opposed to "the provider answered and 

350# refused". Told apart by class NAME across the MRO so no provider SDK has to 

351# be imported here, and matched only inside the upstream branch below — a 

352# builtins ``ConnectionError`` is still default-denied. The name is used to 

353# pick between two fixed messages; it is not part of either one. 

354_CONNECTION_FAILURE_CLASS_NAMES = frozenset( 

355 { 

356 "APIConnectionError", # openai (APITimeoutError subclasses it) 

357 "ConnectError", # httpx 

358 "ConnectionError", # requests.exceptions 

359 "ConnectTimeout", # httpx 

360 "ConnectTimeoutError", # urllib3 

361 "NewConnectionError", # urllib3 

362 "ProxyError", # httpx / requests / urllib3 

363 } 

364) 

365 

366 

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

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

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

370 

371 

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

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

374 

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

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

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

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

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

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

381 embedding model" suggestion. 

382 

383 Exception text never crosses the HTTP boundary here. The exception's 

384 module and class are used only to SELECT one of the fixed messages below; 

385 neither is interpolated into the result, and the full detail stays in the 

386 server-side log (``logger.exception`` at the call site). 

387 

388 Why not simply scrub the text and echo it, which is what 

389 :func:`~...security.log_sanitizer.sanitize_error_for_client` is for 

390 elsewhere in the tree: that function removes credential *shapes* plus 

391 control characters, and caps the length. It does not remove server 

392 filesystem paths, SQL text or dependency internals — and those are 

393 exactly what the exceptions reachable from this ``try`` block carry. The 

394 block opens the user's SQLCipher database and constructs an embedding 

395 manager, so a ``sqlalchemy.exc.OperationalError`` (driver text plus 

396 ``[SQL: ...]`` and ``[parameters: ...]``), a ``builtins.OSError`` 

397 (absolute server path and OS account name) or a provider response body 

398 are all in range. ``sanitize_error_for_client`` remains the right tool 

399 for a message an author already knows to be safe; it is not a filter that 

400 makes an arbitrary exception safe, which is why this function does not 

401 use it. 

402 """ 

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

404 

405 if _module_matches(type_module, _INTERNAL_MODULE_PREFIX): 

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

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

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

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

410 # attach. 

411 return ( 

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

413 "internal LDR error. This is a bug in LDR, " 

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

415 "the server logs." 

416 ) 

417 

418 if any( 

419 _module_matches(type_module, prefix) 

420 for prefix in _UPSTREAM_MODULE_PREFIXES 

421 ): 

422 if _CONNECTION_FAILURE_CLASS_NAMES.intersection( 

423 klass.__name__ for klass in type(exc).__mro__ 

424 ): 

425 # Nothing was rejected — the connection never completed. Saying 

426 # "the provider rejected the request" here sends the user to 

427 # check credentials for what is almost always a wrong host, a 

428 # stopped Ollama, or a firewall. 

429 return ( 

430 f"Embedding test failed for model '{model}'. Could not " 

431 "connect to the provider; check the URL and that it is " 

432 "reachable. The full error is in the server logs." 

433 ) 

434 return ( 

435 f"Embedding test failed for model '{model}'. The request to the " 

436 "provider failed. Check its URL, credentials, and model name; " 

437 "the full error is in the server logs." 

438 ) 

439 

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

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

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

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

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

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

446 # touched by the credential-shape scrubber, so no exception-derived detail 

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

448 # the call site). 

449 # 

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

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

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

453 # guidance #4208 removed. 

454 return ( 

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

456 "error. Check the provider URL and model name; the full error is " 

457 "in the server logs." 

458 ) 

459 

460 

461# Config API Routes 

462 

463 

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

465def get_supported_formats( 

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

467): 

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

469 

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

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

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

473 """ 

474 from ...document_loaders import get_supported_extensions 

475 

476 extensions = get_supported_extensions() 

477 # Sort extensions for consistent display 

478 extensions = sorted(extensions) 

479 

480 return { 

481 "extensions": extensions, 

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

483 "count": len(extensions), 

484 } 

485 

486 

487# Page Routes 

488 

489 

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

491def embedding_settings_page( 

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

493): 

494 """Render the Embedding Settings page.""" 

495 return templates.TemplateResponse( 

496 request=request, 

497 name="pages/embedding_settings.html", 

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

499 ) 

500 

501 

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

503def view_document_chunks( 

504 request: Request, 

505 document_id, 

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

507): 

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

509 from ..utils.path_ids import validated_uuid_path_param 

510 from ...database.session_context import get_user_db_session 

511 from ...database.models.library import DocumentChunk 

512 

513 document_id = validated_uuid_path_param(document_id, "Document") 

514 

515 with get_user_db_session(username) as db_session: 

516 # Get document info 

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

518 

519 if not document: 

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

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

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

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

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

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

526 

527 # Get all chunks for this document 

528 chunks = ( 

529 db_session.query(DocumentChunk) 

530 .filter(DocumentChunk.source_id == document_id) 

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

532 .all() 

533 ) 

534 

535 # Group chunks by collection 

536 chunks_by_collection = {} 

537 for chunk in chunks: 

538 coll_name = chunk.collection_name 

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

540 # Get collection display name 

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

542 collection = ( 

543 db_session.query(Collection) 

544 .filter_by(id=collection_id) 

545 .first() 

546 ) 

547 chunks_by_collection[coll_name] = { 

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

549 "id": collection_id, 

550 "chunks": [], 

551 } 

552 

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

554 { 

555 "id": chunk.id, 

556 "index": chunk.chunk_index, 

557 "text": chunk.chunk_text, 

558 "word_count": chunk.word_count, 

559 "start_char": chunk.start_char, 

560 "end_char": chunk.end_char, 

561 "embedding_model": chunk.embedding_model, 

562 "embedding_model_type": chunk.embedding_model_type.value 

563 if chunk.embedding_model_type 

564 else None, 

565 "embedding_dimension": chunk.embedding_dimension, 

566 "created_at": chunk.created_at, 

567 } 

568 ) 

569 

570 return templates.TemplateResponse( 

571 request=request, 

572 name="pages/document_chunks.html", 

573 context={ 

574 "request": request, 

575 "document": document, 

576 "chunks_by_collection": chunks_by_collection, 

577 "total_chunks": len(chunks), 

578 }, 

579 ) 

580 

581 

582@router.get("/collections") 

583def collections_page( 

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

585): 

586 """Render the Collections page.""" 

587 return templates.TemplateResponse( 

588 request=request, 

589 name="pages/collections.html", 

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

591 ) 

592 

593 

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

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

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

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

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

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

600def collection_create_page( 

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

602): 

603 """Render the Create Collection page.""" 

604 return templates.TemplateResponse( 

605 request=request, 

606 name="pages/collection_create.html", 

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

608 ) 

609 

610 

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

612 """Return ``collection_id``'s canonical UUID form if well-formed, else 404. 

613 

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

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

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

617 

618 This was the first fix for a stored-reflection XSS: collection_upload.html 

619 interpolated this value into an inline `onclick` handler, and 

620 HTML-escaping is NOT sufficient there -- the browser entity-decodes 

621 attribute content before treating it as JS, so an escaped quote reopens 

622 the string. That template now passes the ID as a data attribute read by a 

623 script listener instead. This check stays as the second layer: validating 

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

625 breaking out, regardless of the context it is interpolated into, which 

626 also covers collection_details.html and anything added later. 

627 

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

629 parameterised ones, so it never reaches this.) 

630 

631 The return value is the *canonical* form of the id (lowercase, 

632 hyphenated, no wrapper) rather than the verbatim input, so alias 

633 spellings of the same UUID — for example an ``urn:uuid:`` prefix, 

634 ``{...}`` braces, hyphen-free hex, or uppercase hex — come back in 

635 the same shape stored on disk, which is what makes "well-formed 

636 UUID string" above actually true of the return value. ``uuid.UUID`` 

637 tolerates a few more, such as ``_`` digit separators; all are 

638 canonicalised here. Length is the binding reject: a padded input 

639 that pushes the total past 32 characters fails the parser and 404s; 

640 shorter padded inputs (e.g. leading/trailing whitespace) are 

641 accepted by ``int(..., 16)`` and canonicalise with zero-padding, 

642 and CPython's base-16 parser also accepts Unicode digit 

643 characters (`"𝟏"` / Arabic-Indic digits), so this validator is 

644 NOT a strict whitelist of ASCII hex — the canonical output is 

645 pure ASCII hex, which is what makes it safe to interpolate in 

646 any template context (#6602; the shared helper 

647 ``web/utils/path_ids.py::validated_uuid_path_param`` has the same 

648 shape, this local twin stays in ``rag.py`` so the route-table 

649 parity scanner keeps seeing its 404). 

650 """ 

651 try: 

652 canonical = str(uuid.UUID(str(collection_id))) 

653 except (ValueError, AttributeError, TypeError): 

654 logger.warning( 

655 f"Rejected malformed collection_id: {_log_value_preview(collection_id)}" 

656 ) 

657 # `from None` mirrors `validated_uuid_path_param`: the only 

658 # caller of this guard sees a clean 404 detail, and the 

659 # `uuid.UUID` parse-failure traceback adds nothing the 

660 # `_log_value_preview` line above doesn't already carry. 

661 raise HTTPException( 

662 status_code=404, detail="Collection not found" 

663 ) from None 

664 return canonical 

665 

666 

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

668def collection_details_page( 

669 request: Request, 

670 collection_id, 

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

672): 

673 """Render the Collection Details page.""" 

674 collection_id = _validated_collection_id(collection_id) 

675 return templates.TemplateResponse( 

676 request=request, 

677 name="pages/collection_details.html", 

678 context={ 

679 "active_page": "collections", 

680 "collection_id": collection_id, 

681 }, 

682 ) 

683 

684 

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

686def collection_upload_page( 

687 request: Request, 

688 collection_id, 

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

690): 

691 """Render the Collection Upload page.""" 

692 collection_id = _validated_collection_id(collection_id) 

693 

694 from ...database.session_context import get_user_db_session 

695 from ...utilities.db_utils import get_settings_manager 

696 

697 # Get the upload PDF storage setting 

698 with get_user_db_session(username) as db_session: 

699 settings = get_settings_manager(db_session, username) 

700 upload_pdf_storage = settings.get_setting( 

701 "research_library.upload_pdf_storage", "none" 

702 ) 

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

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

705 upload_pdf_storage = "none" 

706 

707 return templates.TemplateResponse( 

708 request=request, 

709 name="pages/collection_upload.html", 

710 context={ 

711 "active_page": "collections", 

712 "collection_id": collection_id, 

713 "collection_name": None, 

714 "upload_pdf_storage": upload_pdf_storage, 

715 }, 

716 ) 

717 

718 

719# API Routes 

720 

721 

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

723def get_current_settings( 

724 request: Request, 

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

726): 

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

728 from ...database.session_context import get_user_db_session 

729 

730 try: 

731 with get_user_db_session(username) as db_session: 

732 settings = get_settings_manager(db_session, username) 

733 

734 return { 

735 "success": True, 

736 "settings": { 

737 "embedding_provider": settings.get_setting( 

738 "local_search_embedding_provider", 

739 DEFAULT_LOCAL_SEARCH_PROVIDER, 

740 ), 

741 "embedding_model": settings.get_setting( 

742 "local_search_embedding_model", 

743 DEFAULT_LOCAL_SEARCH_MODEL, 

744 ), 

745 "chunk_size": settings.get_setting( 

746 "local_search_chunk_size", 

747 DEFAULT_LOCAL_SEARCH_CHUNK_SIZE, 

748 ), 

749 "chunk_overlap": settings.get_setting( 

750 "local_search_chunk_overlap", 

751 DEFAULT_LOCAL_SEARCH_CHUNK_OVERLAP, 

752 ), 

753 "splitter_type": settings.get_setting( 

754 "local_search_splitter_type", 

755 DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE, 

756 ), 

757 "text_separators": _get_text_separators(settings), 

758 "distance_metric": settings.get_setting( 

759 "local_search_distance_metric", 

760 DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC, 

761 ), 

762 "normalize_vectors": settings.get_setting( 

763 "local_search_normalize_vectors", 

764 DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS, 

765 ), 

766 "index_type": settings.get_setting( 

767 "local_search_index_type", 

768 DEFAULT_LOCAL_SEARCH_INDEX_TYPE, 

769 ), 

770 }, 

771 } 

772 except Exception as e: 

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

774 

775 

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

777async def test_embedding( 

778 request: Request, 

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

780): 

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

782 from ...database.session_context import get_user_db_session 

783 from ...utilities.db_utils import get_settings_manager 

784 

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

786 # malformed/empty body would otherwise raise UnboundLocalError from 

787 # inside the handler's own error path. 

788 provider = None 

789 model = None 

790 try: 

791 data = await request.json() 

792 if not isinstance(data, dict): 

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

794 provider = data.get("provider") 

795 model = data.get("model") 

796 

797 if not provider or not model: 

798 return JSONResponse( 

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

800 status_code=400, 

801 ) 

802 

803 from ...embeddings.embeddings_config import get_embeddings 

804 

805 logger.info( 

806 "Testing embedding with provider={}, model={}", 

807 _log_value_preview(provider), 

808 _log_value_preview(model), 

809 ) 

810 

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

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

813 # thread, never on the uvicorn event loop. 

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

815 with get_user_db_session(username) as db_session: 

816 settings = get_settings_manager(db_session, username) 

817 settings_snapshot = ( 

818 settings.get_all_settings() 

819 if hasattr(settings, "get_all_settings") 

820 else {} 

821 ) 

822 start = time.time() 

823 # get_embeddings (not get_embedding_function) so the owning 

824 # instance is reachable here for cleanup: the wrapped callable 

825 # from get_embedding_function discards it, leaking the 

826 # per-instance httpx client(s) built for this one-shot test. 

827 embeddings = get_embeddings( 

828 provider=provider, 

829 model=model, 

830 settings_snapshot=settings_snapshot, 

831 ) 

832 try: 

833 emb = embeddings.embed_documents([_EMBEDDING_TEST_SENTENCE])[0] 

834 finally: 

835 from ...utilities.llm_utils import _close_base_llm 

836 

837 try: 

838 _close_base_llm(embeddings) 

839 except Exception: 

840 logger.warning("Failed to close test embedding resources") 

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

842 

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

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

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

846 embedding, response_time_ms = await run_db_sync(_run_embedding_test) 

847 

848 # Get embedding dimension 

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

850 

851 return { 

852 "success": True, 

853 "dimension": dimension, 

854 "response_time_ms": response_time_ms, 

855 "provider": provider, 

856 "model": model, 

857 } 

858 

859 except json.JSONDecodeError: 

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

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

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

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

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

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

866 except Exception as e: 

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

868 from ...security.egress.policy import PolicyDeniedError 

869 

870 if isinstance(e, PolicyDeniedError): 

871 reason = e.decision.reason 

872 if reason == "embeddings_model_not_curated": 

873 return JSONResponse( 

874 { 

875 "success": False, 

876 "error": f"Sentence Transformers model '{model}' is not in LDR's vetted model list. Choose one of the listed models, or use Ollama for custom locally managed embedding models. Direct arbitrary Hugging Face downloads are disabled.", 

877 "reason_code": reason, 

878 }, 

879 status_code=400, 

880 ) 

881 if reason == "embeddings_cache_revision_unknown": 

882 return JSONResponse( 

883 { 

884 "success": False, 

885 "error": f"The existing Sentence Transformers cache for '{model}' does not identify a single immutable model revision, so a new download is refused: it could change the model behind an existing index. To start over, delete that model's cache directory (the 'models--...' folder under the Hugging Face cache, by default ~/.cache/huggingface/hub) and run this test again under Public only — do this only if no collection was indexed with it. To keep an existing index, put a trusted copy of the model under the application's models directory and select it by that name instead, or select a different model.", 

886 "reason_code": reason, 

887 }, 

888 status_code=400, 

889 ) 

890 if reason == "embeddings_model_not_cached": 890 ↛ 899line 890 didn't jump to line 899 because the condition on line 890 was always true

891 return JSONResponse( 

892 { 

893 "success": False, 

894 "error": f"Sentence Transformers model '{model}' has not been downloaded yet. Set Egress Scope to Public only, or use Adaptive with a public primary and no private collection selected. If 'Require local embeddings' is enabled, turn it off for this download, then run this test again. After the download completes, you can return to your preferred restrictive scope.", 

895 "reason_code": reason, 

896 }, 

897 status_code=400, 

898 ) 

899 return JSONResponse( 

900 { 

901 "success": False, 

902 "error": f"Embedding provider '{provider}' refused by egress policy ({reason}). Disable 'Require local embeddings' or pick a local provider.", 

903 "reason_code": reason, 

904 }, 

905 status_code=400, 

906 ) 

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

908 user_message = _format_test_embedding_error(e, model) 

909 # The formatter uses exception type metadata only to select a fixed 

910 # message. Raw exception text stays in the server log above. 

911 return JSONResponse( 

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

913 ) 

914 

915 

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

917def get_available_models( 

918 request: Request, 

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

920): 

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

922 from ...database.session_context import get_user_db_session 

923 from ...utilities.db_utils import get_settings_manager 

924 

925 try: 

926 from ...embeddings.embeddings_config import _get_provider_classes 

927 from ...security.egress.policy import ( 

928 Decision, 

929 PolicyDeniedError, 

930 ) 

931 

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

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

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

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

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

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

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

939 # admitting a cloud embedder under a permissive fallback. 

940 with get_user_db_session(username) as db_session: 

941 settings = get_settings_manager(db_session, username) 

942 try: 

943 settings_snapshot = ( 

944 settings.get_all_settings(strict=True) 

945 if hasattr(settings, "get_all_settings") 

946 else {} 

947 ) 

948 except PolicyDeniedError: 

949 raise 

950 except Exception as exc: 

951 raise PolicyDeniedError( 

952 Decision(False, "settings_unavailable"), 

953 target="available_rag_models", 

954 ) from exc 

955 

956 # Get provider classes 

957 provider_classes = _get_provider_classes() 

958 

959 # Egress policy: availability checks and model discovery can open 

960 # connections to a provider. Under an effective 

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

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

963 # embeddings_config.get_embeddings(). Fail closed before either 

964 # provider call if the policy can't evaluate. 

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

966 try: 

967 from ...security.egress.policy import ( 

968 context_from_snapshot, 

969 evaluate_embeddings, 

970 resolve_run_primary_engine, 

971 ) 

972 

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

974 # resolves PRIVATE_ONLY (forcing local embeddings) and this 

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

976 ctx = context_from_snapshot( 

977 settings_snapshot, 

978 resolve_run_primary_engine(settings_snapshot), 

979 username=username, 

980 ) 

981 if not ctx.require_local_embeddings: 

982 return True 

983 decision = evaluate_embeddings( 

984 provider_key, ctx, settings_snapshot=settings_snapshot 

985 ) 

986 if not decision.allowed: 

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

988 "skipping embeddings model-list probe " 

989 "(local-only egress posture)", 

990 provider=provider_key, 

991 reason=decision.reason, 

992 ) 

993 return decision.allowed 

994 except Exception: 

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

996 # probe under a local-only posture. 

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

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

999 provider=provider_key, 

1000 exc_info=True, 

1001 ) 

1002 return False 

1003 

1004 # Provider display names 

1005 provider_labels = { 

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

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

1008 "openai": "OpenAI API", 

1009 } 

1010 

1011 # Get provider options and models by looping through providers 

1012 provider_options = [] 

1013 providers = {} 

1014 

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

1016 policy_allowed = _embeddings_model_fetch_allowed(provider_key) 

1017 available = policy_allowed and provider_class.is_available( 

1018 settings_snapshot 

1019 ) 

1020 

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

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

1023 provider_options.append( 

1024 { 

1025 "value": provider_key, 

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

1027 "available": available, 

1028 "policy_allowed": policy_allowed, 

1029 } 

1030 ) 

1031 

1032 # Availability is checked only after the policy permits probing. 

1033 if available: 

1034 models = provider_class.get_available_models(settings_snapshot) 

1035 providers[provider_key] = [ 

1036 { 

1037 "value": m["value"], 

1038 "label": m["label"], 

1039 "provider": provider_key, 

1040 **( 

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

1042 if "is_embedding" in m 

1043 else {} 

1044 ), 

1045 } 

1046 for m in models 

1047 ] 

1048 else: 

1049 providers[provider_key] = [] 

1050 

1051 return { 

1052 "success": True, 

1053 "provider_options": provider_options, 

1054 "providers": providers, 

1055 } 

1056 

1057 except Exception as e: 

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

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

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

1061 

1062 if isinstance(e, _PDE): 

1063 reason = e.decision.reason 

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

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

1066 "available-models refused by egress policy", 

1067 reason=reason, 

1068 target=e.target, 

1069 ) 

1070 return JSONResponse( 

1071 { 

1072 "success": False, 

1073 "error": ( 

1074 "Settings are currently unavailable; cannot safely " 

1075 "enumerate embedding models." 

1076 ) 

1077 if reason == "settings_unavailable" 

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

1079 }, 

1080 status_code=status_code, 

1081 ) 

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

1083 

1084 

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

1086def get_index_info( 

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

1088): 

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

1090 from ...database.library_init import get_default_library_id 

1091 

1092 try: 

1093 # Get collection_id from request or use default Library collection 

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

1095 if not collection_id: 

1096 collection_id = get_default_library_id(username) 

1097 

1098 logger.info( 

1099 f"Getting RAG index info for collection_id: {_log_value_preview(collection_id)}" 

1100 ) 

1101 

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

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

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

1105 # hits it on each load. 

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

1107 info = rag_service.get_current_index_info(collection_id) 

1108 

1109 if info is None: 

1110 logger.info( 

1111 f"No RAG index found for collection_id: {_log_value_preview(collection_id)}" 

1112 ) 

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

1114 

1115 logger.info( 

1116 f"Found RAG index for collection_id: {_log_value_preview(collection_id)}" 

1117 ) 

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

1119 except Exception as e: 

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

1121 

1122 

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

1124def get_rag_stats( 

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

1126): 

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

1128 from ...database.library_init import get_default_library_id 

1129 

1130 try: 

1131 # Get collection_id from request or use default Library collection 

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

1133 if not collection_id: 

1134 collection_id = get_default_library_id(username) 

1135 

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

1137 stats = rag_service.get_rag_stats(collection_id) 

1138 

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

1140 except Exception as e: 

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

1142 

1143 

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

1145async def index_document( 

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

1147): 

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

1149 from ...database.library_init import get_default_library_id 

1150 

1151 text_doc_id = None 

1152 try: 

1153 data = await request.json() 

1154 if not isinstance(data, dict): 

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

1156 text_doc_id = data.get("text_doc_id") 

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

1158 collection_id = data.get("collection_id") 

1159 

1160 if not isinstance(force_reindex, bool): 

1161 return JSONResponse( 

1162 { 

1163 "success": False, 

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

1165 }, 

1166 status_code=400, 

1167 ) 

1168 

1169 if not text_doc_id: 

1170 return JSONResponse( 

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

1172 status_code=400, 

1173 ) 

1174 

1175 if not collection_id: 

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

1177 collection_id = await run_db_sync(get_default_library_id, username) 

1178 

1179 def _index_sync(): 

1180 with get_rag_service( 

1181 request, username, collection_id 

1182 ) as rag_service: 

1183 return rag_service.index_document( 

1184 text_doc_id, collection_id, force_reindex 

1185 ) 

1186 

1187 result = await run_db_sync(_index_sync) 

1188 

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

1190 return JSONResponse( 

1191 { 

1192 "success": False, 

1193 "error": _client_indexing_error(result.get("error")), 

1194 }, 

1195 status_code=400, 

1196 ) 

1197 

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

1199 except json.JSONDecodeError: 

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

1201 except Exception as e: 

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

1203 

1204 

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

1206async def remove_document( 

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

1208): 

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

1210 from ...database.library_init import get_default_library_id 

1211 

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

1213 # block interpolates text_doc_id into its error message. 

1214 text_doc_id = None 

1215 try: 

1216 data = await request.json() 

1217 if not isinstance(data, dict): 

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

1219 text_doc_id = data.get("text_doc_id") 

1220 collection_id = data.get("collection_id") 

1221 

1222 if not text_doc_id: 

1223 return JSONResponse( 

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

1225 status_code=400, 

1226 ) 

1227 

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

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

1230 collection_id = await run_db_sync(get_default_library_id, username) 

1231 

1232 def _remove_sync(): 

1233 with get_rag_service( 

1234 request, username, collection_id 

1235 ) as rag_service: 

1236 return rag_service.remove_document_from_rag( 

1237 text_doc_id, collection_id 

1238 ) 

1239 

1240 result = await run_db_sync(_remove_sync) 

1241 

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

1243 return JSONResponse( 

1244 { 

1245 "success": False, 

1246 "error": _client_indexing_error(result.get("error")), 

1247 }, 

1248 status_code=400, 

1249 ) 

1250 

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

1252 except json.JSONDecodeError: 

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

1254 except Exception as e: 

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

1256 

1257 

1258@router.post("/api/rag/index-all") 

1259def index_all( 

1260 request: Request, 

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

1262): 

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

1264 

1265 POST, not GET, because it mutates: it resolves the caller's collection, 

1266 writes embedding metadata, and runs the embedding/index pipeline over 

1267 every document in it (``?force_reindex=true`` stages and replaces 

1268 vectors that already exist). CSRFMiddleware keys enforcement on the 

1269 method, so declaring it here is what puts it behind the token check — 

1270 a GET would be outside the middleware entirely and startable from 

1271 same-site markup riding the victim's session cookie. Progress still 

1272 streams as Server-Sent Events; the caller is a fetch client, which can 

1273 send both the X-CSRFToken header and a POST, so nothing about the 

1274 streaming response changes. Query parameters (``collection_id``, 

1275 ``force_reindex``) are unchanged. 

1276 """ 

1277 from ...database.session_context import get_user_db_session 

1278 from ...database.library_init import get_default_library_id 

1279 from ...utilities.db_utils import get_settings_manager 

1280 

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

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

1283 # documents. 

1284 force_reindex = ( 

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

1286 ) 

1287 

1288 # Get collection_id from request or use default Library collection 

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

1290 if not collection_id: 

1291 collection_id = get_default_library_id(username) 

1292 

1293 if force_reindex: 

1294 # A replacement is one transaction for the complete collection. Use 

1295 # the heartbeat-capable stream instead of publishing batch by batch. 

1296 return index_collection(request, collection_id, username) 

1297 

1298 logger.info( 

1299 f"Starting index-all for collection_id: {_log_value_preview(collection_id)}, force_reindex: {force_reindex}" 

1300 ) 

1301 

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

1303 # use_defaults=force_reindex mirrors index_collection / the background 

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

1305 rag_service = get_rag_service( 

1306 request, username, collection_id, use_defaults=force_reindex 

1307 ) 

1308 

1309 # Import before building the SSE generator. Importing inside its try 

1310 # would leave the exception handler unbound if this import failed. 

1311 # Keep it below module scope because it pulls in the deletion package. 

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

1313 RemoteVectorStoreDropError, 

1314 remote_drop_failure_message, 

1315 ) 

1316 

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

1318 with get_user_db_session(username) as db_session: 

1319 settings = get_settings_manager(db_session, username) 

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

1321 try: 

1322 _max_workers = int( 

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

1324 ) 

1325 except SettingsValueError: 

1326 raise 

1327 except Exception: 

1328 _max_workers = 4 

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

1330 

1331 def generate(): 

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

1333 _sse_cancel = threading.Event() 

1334 try: 

1335 if not _register_sse_indexer( 1335 ↛ 1338line 1335 didn't jump to line 1338 because the condition on line 1335 was never true

1336 username, collection_id, None, _sse_cancel 

1337 ): 

1338 yield f"data: {json.dumps({'type': 'error', 'error': 'Indexing is already in progress for this collection'})}\n\n" 

1339 return 

1340 # Send initial status 

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

1342 

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

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

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

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

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

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

1349 collection_found = True 

1350 doc_info = [] 

1351 with get_user_db_session(username) as db_session: 

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

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

1354 # single-collection SSE route and the background worker 

1355 # (previously it stored no embedding_dimension and never reset 

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

1357 collection = ( 

1358 db_session.query(Collection) 

1359 .filter_by(id=collection_id) 

1360 .first() 

1361 ) 

1362 

1363 if not collection: 

1364 collection_found = False 

1365 else: 

1366 # Ordinary indexing records its initial configuration. 

1367 # Forced rebuilds publish metadata with the new generation. 

1368 if collection.embedding_model is None and not force_reindex: 

1369 _store_collection_embedding_metadata( 

1370 collection, rag_service 

1371 ) 

1372 db_session.commit() 

1373 

1374 doc_info = [ 

1375 (doc.id, doc.title) 

1376 for _dc, doc in _query_documents_to_index( 

1377 db_session, collection_id, force_reindex 

1378 ) 

1379 ] 

1380 

1381 if not collection_found: 

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

1383 return 

1384 

1385 if not doc_info: 

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

1387 return 

1388 

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

1390 total = len(doc_info) 

1391 

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

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

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

1395 # batch_size and _max_workers are read from settings before the 

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

1397 processed = 0 

1398 

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

1400 batch = doc_info[i : i + batch_size] 

1401 

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

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

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

1405 # `processed` counter below keeps the SSE payload 

1406 # monotonically increasing so the UI percent stays sane. 

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

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

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

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

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

1412 # worker outlives the helper return — see 

1413 # ``index_documents_parallel`` for the rationale. 

1414 parallel_result = rag_service.index_documents_parallel( 

1415 batch, 

1416 collection_id, 

1417 force_reindex=force_reindex, 

1418 max_workers=_max_workers, 

1419 is_cancelled=_sse_cancel.is_set, 

1420 ) 

1421 batch_results = parallel_result["results"] 

1422 

1423 for doc_id, title in batch: 

1424 processed += 1 

1425 result = batch_results[doc_id] 

1426 

1427 # Send progress update 

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

1429 

1430 if result["status"] == "success": 

1431 results["successful"] += 1 

1432 elif result["status"] in ("skipped", "cleared"): 

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

1434 results["skipped"] += 1 

1435 else: 

1436 results["failed"] += 1 

1437 results["errors"].append( 

1438 { 

1439 "doc_id": doc_id, 

1440 "title": title, 

1441 "error": _client_indexing_error( 

1442 result.get("error") 

1443 ), 

1444 } 

1445 ) 

1446 

1447 # Send completion status 

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

1449 

1450 # Log final status for debugging 

1451 logger.info( 

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

1453 ) 

1454 

1455 except RemoteVectorStoreDropError as exc: 

1456 # Caught ahead of the catch-all below: this is an actionable 

1457 # vector-store failure, not an internal error. Force-reindex rolls 

1458 # back, keeping the RAGIndex rows (the remote collections' only 

1459 # handle); the message says whether a retry can help and whether 

1460 # some collections were already dropped (#5765). Logged from the 

1461 # error's sanitized fields, with no traceback (a traceback would 

1462 # add frame state; the backend's error text can carry secrets). 

1463 drop_detail = exc.log_detail() 

1464 logger.warning( 

1465 "Force-reindex aborted: could not drop the vector-store " 

1466 f"collection before rebuilding it: {drop_detail}" 

1467 ) 

1468 drop_error = { 

1469 "type": "error", 

1470 "error": remote_drop_failure_message( 

1471 exc, "indexing was aborted" 

1472 ), 

1473 } 

1474 yield f"data: {json.dumps(drop_error)}\n\n" 

1475 except Exception: 

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

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

1478 finally: 

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

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

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

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

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

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

1485 # 

1486 # Signal cancellation BEFORE closing: the helper has already 

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

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

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

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

1491 _sse_cancel.set() 

1492 _unregister_sse_indexer(username, collection_id, _sse_cancel) 

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

1494 

1495 return WorkerCleanupStreamingResponse( 

1496 generate(), 

1497 media_type="text/event-stream", 

1498 headers={ 

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

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

1501 }, 

1502 ) 

1503 

1504 

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

1506async def configure_rag( 

1507 request: Request, 

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

1509): 

1510 """ 

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

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

1513 """ 

1514 from ...database.session_context import get_user_db_session 

1515 from ...utilities.db_utils import get_settings_manager 

1516 

1517 try: 

1518 data = await request.json() 

1519 if not isinstance(data, dict): 

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

1521 embedding_model = data.get("embedding_model") 

1522 embedding_provider = data.get("embedding_provider") 

1523 chunk_size = data.get("chunk_size") 

1524 chunk_overlap = data.get("chunk_overlap") 

1525 collection_id = data.get("collection_id") 

1526 

1527 # Get new advanced settings (with defaults) 

1528 splitter_type = data.get( 

1529 "splitter_type", DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE 

1530 ) 

1531 text_separators = data.get( 

1532 "text_separators", DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS 

1533 ) 

1534 distance_metric = data.get( 

1535 "distance_metric", DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC 

1536 ) 

1537 normalize_vectors = data.get( 

1538 "normalize_vectors", DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS 

1539 ) 

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

1541 

1542 if ( 

1543 not embedding_model 

1544 or not embedding_provider 

1545 or chunk_size is None 

1546 or chunk_overlap is None 

1547 ): 

1548 return JSONResponse( 

1549 { 

1550 "success": False, 

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

1552 }, 

1553 status_code=400, 

1554 ) 

1555 

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

1557 _RAG_CHUNK_SIZE_MIN <= chunk_size <= _RAG_CHUNK_SIZE_MAX 

1558 ): 

1559 return JSONResponse( 

1560 { 

1561 "success": False, 

1562 "error": ( 

1563 "chunk_size must be an integer between " 

1564 f"{_RAG_CHUNK_SIZE_MIN} and {_RAG_CHUNK_SIZE_MAX}" 

1565 ), 

1566 }, 

1567 status_code=400, 

1568 ) 

1569 

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

1571 _RAG_CHUNK_OVERLAP_MIN <= chunk_overlap <= _RAG_CHUNK_OVERLAP_MAX 

1572 ): 

1573 return JSONResponse( 

1574 { 

1575 "success": False, 

1576 "error": ( 

1577 "chunk_overlap must be an integer between " 

1578 f"{_RAG_CHUNK_OVERLAP_MIN} and " 

1579 f"{_RAG_CHUNK_OVERLAP_MAX}" 

1580 ), 

1581 }, 

1582 status_code=400, 

1583 ) 

1584 

1585 if chunk_overlap > chunk_size: 

1586 return JSONResponse( 

1587 { 

1588 "success": False, 

1589 "error": ( 

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

1591 ), 

1592 }, 

1593 status_code=400, 

1594 ) 

1595 

1596 if not isinstance(normalize_vectors, bool): 

1597 return JSONResponse( 

1598 { 

1599 "success": False, 

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

1601 }, 

1602 status_code=400, 

1603 ) 

1604 

1605 # The local_search_text_separators setting is registered with 

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

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

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

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

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

1611 # the chunker. 

1612 text_separators = await asyncio.to_thread( 

1613 _parse_configured_text_separators, text_separators 

1614 ) 

1615 if text_separators is None: 

1616 return JSONResponse( 

1617 { 

1618 "success": False, 

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

1620 }, 

1621 status_code=400, 

1622 ) 

1623 

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

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

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

1627 # slow index-create path. 

1628 def _persist_configuration(): 

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

1630 collection's index in ONE database transaction. 

1631 

1632 Settings and the selected index must land atomically: a 

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

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

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

1636 exactly what would let a subsequent search silently run 

1637 against a stale/inconsistent configuration. 

1638 

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

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

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

1642 """ 

1643 with get_user_db_session(username) as db_session: 

1644 settings = get_settings_manager(db_session, username) 

1645 

1646 requested_settings = ( 

1647 ("local_search_embedding_model", embedding_model), 

1648 ("local_search_embedding_provider", embedding_provider), 

1649 ("local_search_chunk_size", int(chunk_size)), 

1650 ("local_search_chunk_overlap", int(chunk_overlap)), 

1651 ("local_search_splitter_type", splitter_type), 

1652 ("local_search_text_separators", text_separators), 

1653 ("local_search_distance_metric", distance_metric), 

1654 ( 

1655 "local_search_normalize_vectors", 

1656 normalize_vectors, 

1657 ), 

1658 ("local_search_index_type", index_type), 

1659 ) 

1660 requested_setting_keys = [ 

1661 key for key, _value in requested_settings 

1662 ] 

1663 

1664 if settings.settings_locked: 

1665 return JSONResponse( 

1666 { 

1667 "success": False, 

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

1669 }, 

1670 status_code=403, 

1671 ) 

1672 

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

1674 # overwritten through a settings-mutation endpoint. Refuse 

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

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

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

1678 # inconsistent configuration. 

1679 environment_locked_keys = [ 

1680 key 

1681 for key, _value in requested_settings 

1682 if check_env_setting(key) is not None 

1683 ] 

1684 if environment_locked_keys: 

1685 return JSONResponse( 

1686 { 

1687 "success": False, 

1688 "error": ( 

1689 "Settings " 

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

1691 "are environment-locked" 

1692 ), 

1693 }, 

1694 status_code=403, 

1695 ) 

1696 

1697 for key, value in requested_settings: 

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

1699 db_session.rollback() 

1700 return JSONResponse( 

1701 { 

1702 "success": False, 

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

1704 }, 

1705 status_code=500, 

1706 ) 

1707 

1708 index_hash = None 

1709 if collection_id: 

1710 with LibraryRAGService( 

1711 username=username, 

1712 embedding_model=embedding_model, 

1713 embedding_provider=embedding_provider, 

1714 chunk_size=int(chunk_size), 

1715 chunk_overlap=int(chunk_overlap), 

1716 splitter_type=splitter_type, 

1717 text_separators=text_separators, 

1718 distance_metric=distance_metric, 

1719 normalize_vectors=normalize_vectors, 

1720 index_type=index_type, 

1721 ) as new_rag_service: 

1722 rag_index = new_rag_service._get_or_create_rag_index( 

1723 collection_id, db_session=db_session, commit=False 

1724 ) 

1725 index_hash = rag_index.index_hash 

1726 

1727 db_session.commit() 

1728 settings.emit_settings_changed_after_commit( 

1729 requested_setting_keys 

1730 ) 

1731 return index_hash 

1732 

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

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

1735 result = await run_db_sync(_persist_configuration) 

1736 if isinstance(result, JSONResponse): 

1737 return result 

1738 index_hash = result 

1739 

1740 if collection_id: 

1741 return { 

1742 "success": True, 

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

1744 "index_hash": index_hash, 

1745 } 

1746 

1747 # Just saving default settings without updating a specific collection 

1748 return { 

1749 "success": True, 

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

1751 } 

1752 

1753 except json.JSONDecodeError: 

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

1755 except Exception as e: 

1756 return handle_api_error("configuring RAG", e) 

1757 

1758 

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

1760def get_documents( 

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

1762): 

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

1764 

1765 Query parameters: 

1766 page (int): 1-indexed page number, clamped to 1-10000 (default 1; 

1767 non-numeric input falls back to 1). 

1768 per_page (int): rows per page, clamped to 1-100 (default 50; 

1769 non-numeric input falls back to 50). 

1770 """ 

1771 from ...database.session_context import get_user_db_session 

1772 from ...database.library_init import get_default_library_id 

1773 

1774 try: 

1775 # Get pagination parameters 

1776 try: 

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

1778 except (TypeError, ValueError): 

1779 page = 1 

1780 try: 

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

1782 except (TypeError, ValueError): 

1783 per_page = 50 

1784 page = max(1, page) 

1785 filter_type = request.query_params.get( 

1786 "filter", "all" 

1787 ) # all, indexed, unindexed 

1788 

1789 # Validate pagination parameters 

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

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

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

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

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

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

1796 per_page = min(max(1, per_page), 100) 

1797 

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

1799 from ...database.thread_local_session import cleanup_current_thread 

1800 

1801 cleanup_current_thread() 

1802 

1803 # Get collection_id from request or use default Library collection 

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

1805 if not collection_id: 

1806 collection_id = get_default_library_id(username) 

1807 

1808 logger.info( 

1809 f"Getting documents for collection_id: {_log_value_preview(collection_id)}, filter: {_log_value_preview(filter_type)}, page: {page}" 

1810 ) 

1811 

1812 with get_user_db_session(username) as db_session: 

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

1814 db_session.expire_all() 

1815 

1816 # Import RagDocumentStatus model 

1817 from ...database.models.library import RagDocumentStatus 

1818 

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

1820 # LEFT JOIN with rag_document_status to check indexed status 

1821 query = ( 

1822 db_session.query( 

1823 Document, DocumentCollection, RagDocumentStatus 

1824 ) 

1825 .join( 

1826 DocumentCollection, 

1827 (DocumentCollection.document_id == Document.id) 

1828 & (DocumentCollection.collection_id == collection_id), 

1829 ) 

1830 .outerjoin( 

1831 RagDocumentStatus, 

1832 (RagDocumentStatus.document_id == Document.id) 

1833 & (RagDocumentStatus.collection_id == collection_id), 

1834 ) 

1835 ) 

1836 

1837 logger.opt(lazy=True).debug( 

1838 "Base query for collection {}: {}", 

1839 lambda: _log_value_preview(collection_id), 

1840 lambda: query, 

1841 ) 

1842 

1843 # Apply filters based on rag_document_status existence 

1844 if filter_type == "indexed": 

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

1846 elif filter_type == "unindexed": 

1847 # Documents in collection but not indexed yet 

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

1849 

1850 # Get total count before pagination 

1851 total_count = query.count() 

1852 logger.info( 

1853 f"Found {total_count} total documents for collection {_log_value_preview(collection_id)} with filter {_log_value_preview(filter_type)}" 

1854 ) 

1855 

1856 # Apply pagination 

1857 results = ( 

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

1859 .limit(per_page) 

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

1861 .all() 

1862 ) 

1863 

1864 documents = [ 

1865 { 

1866 "id": doc.id, 

1867 "title": doc.title, 

1868 "original_url": doc.original_url, 

1869 "rag_indexed": rag_status is not None, 

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

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

1872 if doc.created_at 

1873 else None, 

1874 } 

1875 for doc, doc_collection, rag_status in results 

1876 ] 

1877 

1878 # Debug logging to help diagnose indexing status issues 

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

1880 

1881 # Additional debug: check rag_document_status for this collection 

1882 all_indexed_statuses = ( 

1883 db_session.query(RagDocumentStatus) 

1884 .filter_by(collection_id=collection_id) 

1885 .all() 

1886 ) 

1887 logger.info( 

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

1889 ) 

1890 

1891 logger.info( 

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

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

1894 ) 

1895 

1896 return { 

1897 "success": True, 

1898 "documents": documents, 

1899 "pagination": { 

1900 "page": page, 

1901 "per_page": per_page, 

1902 "total": total_count, 

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

1904 }, 

1905 } 

1906 except Exception as e: 

1907 return handle_api_error("getting documents", e) 

1908 

1909 

1910# Collection Management Routes 

1911 

1912 

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

1914def get_collections( 

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

1916): 

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

1918 from ...database.session_context import get_user_db_session 

1919 

1920 try: 

1921 with get_user_db_session(username) as db_session: 

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

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

1924 

1925 # Canonical durable count. Reconciliation maintains these rows from 

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

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

1928 # truth. 

1929 collection_counts = { 

1930 collection_id: ( 

1931 int(document_count or 0), 

1932 int(indexed_count or 0), 

1933 ) 

1934 for collection_id, document_count, indexed_count in ( 

1935 db_session.query( 

1936 DocumentCollection.collection_id, 

1937 func.count(DocumentCollection.document_id), 

1938 func.count( 

1939 case( 

1940 ( 

1941 DocumentCollection.indexed.is_(True), 

1942 DocumentCollection.document_id, 

1943 ), 

1944 else_=None, 

1945 ) 

1946 ), 

1947 ) 

1948 .group_by(DocumentCollection.collection_id) 

1949 .all() 

1950 ) 

1951 } 

1952 

1953 result = [] 

1954 for coll in collections: 

1955 document_count, indexed_document_count = collection_counts.get( 

1956 coll.id, 

1957 (0, 0), 

1958 ) 

1959 collection_data = { 

1960 "id": coll.id, 

1961 "name": coll.name, 

1962 "description": coll.description, 

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

1964 if coll.created_at 

1965 else None, 

1966 "collection_type": coll.collection_type, 

1967 "is_default": coll.is_default 

1968 if hasattr(coll, "is_default") 

1969 else False, 

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

1971 "agent_enabled": _agent_enabled_default_on(coll), 

1972 "document_count": document_count, 

1973 "indexed_document_count": indexed_document_count, 

1974 "folder_count": len(coll.linked_folders) 

1975 if hasattr(coll, "linked_folders") 

1976 else 0, 

1977 } 

1978 

1979 # Include embedding metadata if available 

1980 if coll.embedding_model: 

1981 collection_data["embedding"] = { 

1982 "model": coll.embedding_model, 

1983 "provider": coll.embedding_model_type.value 

1984 if coll.embedding_model_type 

1985 else None, 

1986 "dimension": coll.embedding_dimension, 

1987 "chunk_size": coll.chunk_size, 

1988 "chunk_overlap": coll.chunk_overlap, 

1989 } 

1990 else: 

1991 collection_data["embedding"] = None 

1992 

1993 result.append(collection_data) 

1994 

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

1996 except Exception as e: 

1997 return handle_api_error("getting collections", e) 

1998 

1999 

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

2001async def create_collection( 

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

2003): 

2004 """Create a new document collection.""" 

2005 data, body_error = await read_json_dict( 

2006 request, "success", "Request body must be valid JSON" 

2007 ) 

2008 if body_error is not None: 

2009 return body_error 

2010 return await run_db_sync(_create_collection_sync, data, username) 

2011 

2012 

2013def _create_collection_sync(data, username): 

2014 from ...database.session_context import get_user_db_session 

2015 

2016 try: 

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

2018 if not isinstance(name, str): 

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

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

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

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

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 collection_type = data.get("type", "user_uploads") 

2038 if not isinstance(collection_type, str): 

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

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

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

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

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

2044 return JSONResponse( 

2045 { 

2046 "success": False, 

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

2048 }, 

2049 status_code=400, 

2050 ) 

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

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

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

2054 # would be undeletable under PROTECTED_COLLECTION_TYPES and could 

2055 # nondeterministically win _get_or_create_notes_collection's 

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

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

2058 if collection_type not in allowed_types: 

2059 return JSONResponse( 

2060 { 

2061 "success": False, 

2062 "error": ( 

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

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

2065 ), 

2066 }, 

2067 status_code=400, 

2068 ) 

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

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

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

2072 if not isinstance(is_public, bool): 

2073 return JSONResponse( 

2074 { 

2075 "success": False, 

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

2077 }, 

2078 status_code=400, 

2079 ) 

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

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

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

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

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

2085 if agent_enabled_raw is not None and not isinstance( 

2086 agent_enabled_raw, bool 

2087 ): 

2088 return JSONResponse( 

2089 { 

2090 "success": False, 

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

2092 }, 

2093 status_code=400, 

2094 ) 

2095 agent_enabled = True if agent_enabled_raw is None else agent_enabled_raw 

2096 

2097 if not name: 

2098 return JSONResponse( 

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

2100 ) 

2101 

2102 with get_user_db_session(username) as db_session: 

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

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

2105 

2106 if existing: 

2107 return JSONResponse( 

2108 { 

2109 "success": False, 

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

2111 }, 

2112 status_code=400, 

2113 ) 

2114 

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

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

2117 collection = Collection( 

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

2119 name=name, 

2120 description=description, 

2121 collection_type=collection_type, 

2122 is_public=is_public, 

2123 agent_enabled=agent_enabled, 

2124 ) 

2125 

2126 db_session.add(collection) 

2127 db_session.commit() 

2128 

2129 from ...web_search_engines.search_engines_config import ( 

2130 invalidate_collection_engines_cache, 

2131 ) 

2132 

2133 invalidate_collection_engines_cache(username) 

2134 

2135 return { 

2136 "success": True, 

2137 "collection": { 

2138 "id": collection.id, 

2139 "name": collection.name, 

2140 "description": collection.description, 

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

2142 if collection.created_at 

2143 else None, 

2144 "collection_type": collection.collection_type, 

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

2146 "agent_enabled": _agent_enabled_default_on(collection), 

2147 }, 

2148 } 

2149 except Exception as e: 

2150 return handle_api_error("creating collection", e) 

2151 

2152 

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

2154async def update_collection( 

2155 request: Request, 

2156 collection_id, 

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

2158): 

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

2160 data, body_error = await read_json_dict( 

2161 request, "success", "Request body must be valid JSON" 

2162 ) 

2163 if body_error is not None: 

2164 return body_error 

2165 return await run_db_sync( 

2166 _update_collection_sync, data, collection_id, username 

2167 ) 

2168 

2169 

2170def _update_collection_sync(data, collection_id, username): 

2171 from ...database.session_context import get_user_db_session 

2172 

2173 try: 

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

2175 if not isinstance(name, str): 

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

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

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

2179 return JSONResponse( 

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

2181 status_code=400, 

2182 ) 

2183 name = name.strip() 

2184 

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

2186 if not isinstance(description, str): 

2187 return JSONResponse( 

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

2189 status_code=400, 

2190 ) 

2191 description = description.strip() 

2192 

2193 is_public = data.get("is_public") 

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

2195 return JSONResponse( 

2196 { 

2197 "success": False, 

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

2199 }, 

2200 status_code=400, 

2201 ) 

2202 

2203 agent_enabled = data.get("agent_enabled") 

2204 if ( 

2205 "agent_enabled" in data 

2206 and agent_enabled is not None 

2207 and not isinstance(agent_enabled, bool) 

2208 ): 

2209 return JSONResponse( 

2210 { 

2211 "success": False, 

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

2213 }, 

2214 status_code=400, 

2215 ) 

2216 

2217 with get_user_db_session(username) as db_session: 

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

2219 collection = ( 

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

2221 ) 

2222 

2223 if not collection: 

2224 return JSONResponse( 

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

2226 status_code=404, 

2227 ) 

2228 

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

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

2231 # subsystems. Renaming them via this generic endpoint 

2232 # bypasses their intended invariants and confuses every UI 

2233 # surface that lists collections. Mirrors PROTECTED_COLLECTION_TYPES 

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

2235 # description) are locked — the is_public / agent_enabled 

2236 # toggles below are deliberate per-collection settings that 

2237 # must keep working for system collections too. 

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

2239 PROTECTED_COLLECTION_TYPES, 

2240 ) 

2241 

2242 if collection.collection_type in PROTECTED_COLLECTION_TYPES and ( 

2243 name or "description" in data 

2244 ): 

2245 return JSONResponse( 

2246 { 

2247 "success": False, 

2248 "error": ( 

2249 f"Cannot rename or redescribe system " 

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

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

2252 ), 

2253 }, 

2254 status_code=409, 

2255 ) 

2256 

2257 if name: 

2258 # Check if new name conflicts with existing collection 

2259 existing = ( 

2260 db_session.query(Collection) 

2261 .filter( 

2262 Collection.name == name, 

2263 Collection.id != collection_id, 

2264 ) 

2265 .first() 

2266 ) 

2267 

2268 if existing: 

2269 return JSONResponse( 

2270 { 

2271 "success": False, 

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

2273 }, 

2274 status_code=400, 

2275 ) 

2276 

2277 collection.name = name 

2278 

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

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

2281 if "description" in data: 

2282 collection.description = description 

2283 

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

2285 if "is_public" in data: 

2286 collection.is_public = is_public 

2287 

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

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

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

2291 if "agent_enabled" in data: 

2292 collection.agent_enabled = ( 

2293 True if agent_enabled is None else agent_enabled 

2294 ) 

2295 

2296 db_session.commit() 

2297 

2298 from ...web_search_engines.search_engines_config import ( 

2299 invalidate_collection_engines_cache, 

2300 ) 

2301 

2302 invalidate_collection_engines_cache(username) 

2303 

2304 return { 

2305 "success": True, 

2306 "collection": { 

2307 "id": collection.id, 

2308 "name": collection.name, 

2309 "description": collection.description, 

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

2311 if collection.created_at 

2312 else None, 

2313 "collection_type": collection.collection_type, 

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

2315 "agent_enabled": _agent_enabled_default_on(collection), 

2316 }, 

2317 } 

2318 except Exception as e: 

2319 return handle_api_error("updating collection", e) 

2320 

2321 

2322def _try_pdf_upgrade( 

2323 *, 

2324 db_session, 

2325 document, 

2326 file_content: bytes, 

2327 filename: str, 

2328 pdf_storage: str, 

2329 pdf_storage_manager, 

2330) -> bool: 

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

2332 

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

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

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

2336 """ 

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

2338 return False 

2339 

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

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

2342 # these bytes are ever buffered. 

2343 # Filename sanitization already happens via sanitize_filename() in 

2344 # upload_to_collection() before this helper is invoked. 

2345 # See PR #3145 review for details. 

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

2347 logger.debug( 

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

2349 filename, 

2350 ) 

2351 return False 

2352 

2353 try: 

2354 return bool( 

2355 pdf_storage_manager.upgrade_to_pdf( 

2356 document=document, 

2357 pdf_content=file_content, 

2358 session=db_session, 

2359 ) 

2360 ) 

2361 except Exception: 

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

2363 return False 

2364 

2365 

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

2367@upload_rate_limit_user 

2368@upload_rate_limit_ip 

2369async def upload_to_collection( 

2370 request: Request, 

2371 collection_id, 

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

2373): 

2374 """Upload files to a collection. 

2375 

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

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

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

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

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

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

2382 """ 

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

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

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

2386 # upload and the filter below silently dropped all files 

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

2388 from starlette.datastructures import UploadFile 

2389 

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

2391 # 

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

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

2394 # FileUploadValidator.MAX_FILE_SIZE / MAX_FILES_PER_REQUEST to the 

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

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

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

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

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

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

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

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

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

2404 # route indexes arbitrary document types via document_loaders, not 

2405 # just PDFs. 

2406 from ...security import FileUploadValidator 

2407 

2408 MAX_FILE_SIZE = FileUploadValidator.MAX_FILE_SIZE 

2409 MAX_FILES_PER_UPLOAD = FileUploadValidator.MAX_FILES_PER_REQUEST 

2410 MAX_TOTAL_UPLOAD_SIZE = MAX_FILES_PER_UPLOAD * MAX_FILE_SIZE 

2411 

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

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

2414 if content_length: 

2415 try: 

2416 cl = int(content_length) 

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

2418 return JSONResponse( 

2419 { 

2420 "success": False, 

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

2422 }, 

2423 status_code=413, 

2424 ) 

2425 except ValueError: 

2426 pass 

2427 

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

2429 # consumed once per request. 

2430 form = await request.form() 

2431 upload_files: list[UploadFile] = [ 

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

2433 ] 

2434 if not upload_files: 

2435 return JSONResponse( 

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

2437 status_code=400, 

2438 ) 

2439 if len(upload_files) > MAX_FILES_PER_UPLOAD: 

2440 return JSONResponse( 

2441 { 

2442 "success": False, 

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

2444 }, 

2445 status_code=400, 

2446 ) 

2447 

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

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

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

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

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

2453 pdf_storage_form_value = form.get("pdf_storage") 

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

2455 for uf in upload_files: 

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

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

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

2459 if not uf.filename: 

2460 files_data.append( 

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

2462 ) 

2463 continue 

2464 # Per-file size guard before reading into memory. On the pinned 

2465 # Starlette range (1.3.x), UploadFile.size is the byte count 

2466 # request.form() actually spooled for this part, not a 

2467 # client-declared value, so it is never None here. 

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

2469 files_data.append( 

2470 { 

2471 "filename": uf.filename, 

2472 "content": None, 

2473 "oversized": True, 

2474 } 

2475 ) 

2476 continue 

2477 content = await uf.read() 

2478 files_data.append( 

2479 { 

2480 "filename": uf.filename, 

2481 "content": content, 

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

2483 } 

2484 ) 

2485 

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

2487 return await run_db_sync( 

2488 _upload_to_collection_sync, 

2489 files_data, 

2490 pdf_storage_form_value, 

2491 collection_id, 

2492 username, 

2493 session_id, 

2494 MAX_FILE_SIZE, 

2495 ) 

2496 

2497 

2498def _upload_to_collection_sync( 

2499 files_data, 

2500 pdf_storage_form_value, 

2501 collection_id, 

2502 username, 

2503 session_id, 

2504 MAX_FILE_SIZE, 

2505): 

2506 from ...database.session_context import ( 

2507 get_user_db_session, 

2508 ) 

2509 from ...utilities.db_utils import get_settings_manager 

2510 from ...security import sanitize_filename, UnsafeFilenameError 

2511 import hashlib 

2512 import uuid 

2513 from ...research_library.services.pdf_storage_manager import ( 

2514 PDFStorageManager, 

2515 resolve_pdf_storage_mode, 

2516 ) 

2517 

2518 try: 

2519 with get_user_db_session(username) as db_session: 

2520 settings = get_settings_manager(db_session, username) 

2521 

2522 # Verify collection exists in this user's database 

2523 collection = ( 

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

2525 ) 

2526 

2527 if not collection: 

2528 return JSONResponse( 

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

2530 status_code=404, 

2531 ) 

2532 

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

2534 default_pdf_storage = settings.get_setting( 

2535 "research_library.upload_pdf_storage", "none" 

2536 ) 

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

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

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

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

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

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

2543 pdf_storage = ( 

2544 default_pdf_storage 

2545 if pdf_storage_form_value is None 

2546 else pdf_storage_form_value 

2547 ) 

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

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

2550 # Filesystem storage is not allowed for user uploads 

2551 pdf_storage = "none" 

2552 

2553 # Initialize PDF storage manager if storing PDFs in database 

2554 pdf_storage_manager = None 

2555 if pdf_storage == "database": 

2556 library_root = settings.get_setting( 

2557 "research_library.storage_path", 

2558 str(get_library_directory()), 

2559 ) 

2560 library_root = str( 

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

2562 .expanduser() 

2563 .resolve() 

2564 ) 

2565 shared_library = settings.get_setting( 

2566 "research_library.shared_library", False 

2567 ) 

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

2569 # PDFStorageManager construction sites (library.py's 

2570 # view_pdf_page, DownloadService.__init__, zotero 

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

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

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

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

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

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

2577 # 

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

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

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

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

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

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

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

2585 # to pass `pdf_storage` straight through. 

2586 pdf_storage_manager = PDFStorageManager( 

2587 library_root=apply_user_subdir( 

2588 Path(library_root), username, shared_library 

2589 ), 

2590 storage_mode=resolve_pdf_storage_mode(pdf_storage), 

2591 ) 

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

2593 else: 

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

2595 

2596 uploaded_files = [] 

2597 errors = [] 

2598 

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

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

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

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

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

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

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

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

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

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

2609 # document kept from the first occurrence. 

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

2611 

2612 for fd in files_data: 

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

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

2615 if not fd["filename"]: 

2616 continue 

2617 

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

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

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

2621 # having held the bytes in memory. 

2622 if fd["oversized"]: 

2623 # Sanitize BEFORE echoing. Flask sanitized first and only 

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

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

2626 # multipart filename — attacker-chosen bytes — straight 

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

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

2629 # property this handler should depend on. Every other 

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

2631 try: 

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

2633 except UnsafeFilenameError: 

2634 safe_name = "rejected" 

2635 errors.append( 

2636 { 

2637 "filename": safe_name, 

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

2639 } 

2640 ) 

2641 continue 

2642 

2643 try: 

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

2645 except UnsafeFilenameError: 

2646 errors.append( 

2647 { 

2648 "filename": "rejected", 

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

2650 } 

2651 ) 

2652 continue 

2653 

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

2655 # earlier successes (and seen_hashes) stay consistent with 

2656 # the outer transaction. Commit the savepoint on every 

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

2658 # so we never leave nested SAVEPOINTs stacked open across 

2659 # the batch (same pattern as research_sources_service). 

2660 sp = None 

2661 try: 

2662 sp = db_session.begin_nested() 

2663 

2664 # Size limits were already enforced in the async wrapper 

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

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

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

2668 file_content = fd["content"] 

2669 # Calculate file hash for deduplication 

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

2671 

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

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

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

2675 # occurrence's filename and pretend it was a 

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

2677 # occurrence already linked the matching Document to 

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

2679 # Document hash lookup and link check here would be 

2680 # misleading at best. Still allow PDF upgrade when 

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

2682 # is a PDF with database storage enabled. 

2683 if file_hash in seen_hashes: 

2684 logger.info( 

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

2686 filename, 

2687 file_hash, 

2688 ) 

2689 target_doc = seen_hashes[file_hash] 

2690 pdf_upgraded = _try_pdf_upgrade( 

2691 db_session=db_session, 

2692 document=target_doc, 

2693 file_content=file_content, 

2694 filename=filename, 

2695 pdf_storage=pdf_storage, 

2696 pdf_storage_manager=pdf_storage_manager, 

2697 ) 

2698 sp.commit() 

2699 uploaded_files.append( 

2700 { 

2701 "filename": filename, 

2702 "status": "duplicate_in_batch", 

2703 "id": target_doc.id, 

2704 "pdf_upgraded": pdf_upgraded, 

2705 } 

2706 ) 

2707 continue 

2708 

2709 # Check if document already exists 

2710 existing_doc = ( 

2711 db_session.query(Document) 

2712 .filter_by(document_hash=file_hash) 

2713 .first() 

2714 ) 

2715 

2716 if existing_doc: 

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

2718 pdf_upgraded = _try_pdf_upgrade( 

2719 db_session=db_session, 

2720 document=existing_doc, 

2721 file_content=file_content, 

2722 filename=filename, 

2723 pdf_storage=pdf_storage, 

2724 pdf_storage_manager=pdf_storage_manager, 

2725 ) 

2726 

2727 # Check if already in collection 

2728 existing_link = ( 

2729 db_session.query(DocumentCollection) 

2730 .filter_by( 

2731 document_id=existing_doc.id, 

2732 collection_id=collection_id, 

2733 ) 

2734 .first() 

2735 ) 

2736 

2737 if not existing_link: 

2738 ensure_in_collection( 

2739 db_session, existing_doc.id, collection_id 

2740 ) 

2741 status = "added_to_collection" 

2742 if pdf_upgraded: 

2743 status = "added_to_collection_pdf_upgraded" 

2744 sp.commit() 

2745 uploaded_files.append( 

2746 { 

2747 # Report the file the user actually 

2748 # uploaded, not the existing doc's 

2749 # filename. Otherwise the user sees 

2750 # one filename repeated under both 

2751 # "added to collection" and 

2752 # "new uploads" (or here under 

2753 # "already in collection") and 

2754 # can't tell which file was which. 

2755 "filename": filename, 

2756 "status": status, 

2757 "id": existing_doc.id, 

2758 "pdf_upgraded": pdf_upgraded, 

2759 } 

2760 ) 

2761 seen_hashes[file_hash] = existing_doc 

2762 else: 

2763 status = "already_in_collection" 

2764 if pdf_upgraded: 

2765 status = "pdf_upgraded" 

2766 sp.commit() 

2767 uploaded_files.append( 

2768 { 

2769 "filename": filename, 

2770 "status": status, 

2771 "id": existing_doc.id, 

2772 "pdf_upgraded": pdf_upgraded, 

2773 } 

2774 ) 

2775 seen_hashes[file_hash] = existing_doc 

2776 else: 

2777 # Create new document 

2778 from ...document_loaders import ( 

2779 extract_text_from_bytes, 

2780 is_extension_supported, 

2781 ) 

2782 

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

2784 

2785 # Validate extension is supported before extraction 

2786 if not is_extension_supported(file_extension): 

2787 sp.commit() 

2788 errors.append( 

2789 { 

2790 "filename": filename, 

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

2792 } 

2793 ) 

2794 continue 

2795 

2796 # Use file_type without leading dot for storage 

2797 file_type = ( 

2798 file_extension[1:] 

2799 if file_extension.startswith(".") 

2800 else file_extension 

2801 ) 

2802 

2803 # Parsing can import large libraries or spend minutes 

2804 # extracting a document. Release this read snapshot and 

2805 # any earlier file's write lock before that work. A 

2806 # background writer may otherwise make the snapshot 

2807 # impossible to upgrade (SQLITE_BUSY_SNAPSHOT). 

2808 sp.commit() 

2809 sp = None 

2810 db_session.commit() 

2811 extracted_text = extract_text_from_bytes( 

2812 file_content, file_extension, filename 

2813 ) 

2814 # Keep write failures isolated to this file after the 

2815 # parser returns, using a fresh database snapshot. 

2816 sp = db_session.begin_nested() 

2817 

2818 # Clean the extracted text to remove surrogate characters 

2819 if extracted_text: 

2820 from ...text_processing import remove_surrogates 

2821 

2822 extracted_text = remove_surrogates(extracted_text) 

2823 

2824 if not extracted_text: 

2825 sp.commit() 

2826 errors.append( 

2827 { 

2828 "filename": filename, 

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

2830 } 

2831 ) 

2832 logger.warning( 

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

2834 ) 

2835 continue 

2836 

2837 # Get or create the user_upload source type 

2838 logger.info( 

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

2840 ) 

2841 source_type = ( 

2842 db_session.query(SourceType) 

2843 .filter_by(name="user_upload") 

2844 .first() 

2845 ) 

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

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

2848 source_type = SourceType( 

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

2850 name="user_upload", 

2851 display_name="User Upload", 

2852 description="Documents uploaded by users", 

2853 icon="fas fa-upload", 

2854 ) 

2855 db_session.add(source_type) 

2856 db_session.flush() 

2857 logger.info( 

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

2859 ) 

2860 else: 

2861 logger.info( 

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

2863 ) 

2864 

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

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

2867 doc_id = str(uuid.uuid4()) 

2868 logger.info( 

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

2870 ) 

2871 

2872 # Determine storage mode and file_path 

2873 store_pdf_in_db = ( 

2874 pdf_storage == "database" 

2875 and file_type == "pdf" 

2876 and pdf_storage_manager is not None 

2877 ) 

2878 

2879 new_doc = Document( 

2880 id=doc_id, 

2881 source_type_id=source_type.id, 

2882 filename=filename, 

2883 document_hash=file_hash, 

2884 file_size=len(file_content), 

2885 file_type=file_type, 

2886 text_content=extracted_text, # Always store extracted text 

2887 file_path=None 

2888 if store_pdf_in_db 

2889 else FILE_PATH_TEXT_ONLY, 

2890 storage_mode="database" 

2891 if store_pdf_in_db 

2892 else "none", 

2893 ) 

2894 db_session.add(new_doc) 

2895 db_session.flush() # Get the ID 

2896 logger.info( 

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

2898 ) 

2899 

2900 # Store PDF in encrypted database if requested 

2901 pdf_stored = False 

2902 if store_pdf_in_db: 

2903 try: 

2904 pdf_storage_manager.save_pdf( 

2905 pdf_content=file_content, 

2906 document=new_doc, 

2907 session=db_session, 

2908 filename=filename, 

2909 ) 

2910 pdf_stored = True 

2911 logger.info( 

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

2913 ) 

2914 except Exception: 

2915 logger.exception( 

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

2917 ) 

2918 # Continue without PDF storage - text is still saved 

2919 

2920 # Add to collection 

2921 ensure_in_collection( 

2922 db_session, new_doc.id, collection_id 

2923 ) 

2924 

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

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

2927 # not leave nested SAVEPOINTs stacked open). 

2928 sp.commit() 

2929 

2930 uploaded_files.append( 

2931 { 

2932 "filename": filename, 

2933 "status": "uploaded", 

2934 "id": new_doc.id, 

2935 "text_length": len(extracted_text), 

2936 "pdf_stored": pdf_stored, 

2937 } 

2938 ) 

2939 seen_hashes[file_hash] = new_doc 

2940 

2941 except Exception: 

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

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

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

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

2946 errors.append( 

2947 { 

2948 "filename": filename, 

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

2950 } 

2951 ) 

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

2953 if sp is not None: 

2954 try: 

2955 sp.rollback() 

2956 except Exception: 

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

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

2959 ) 

2960 

2961 db_session.commit() 

2962 

2963 # Trigger auto-indexing for successfully uploaded documents 

2964 document_ids = [ 

2965 f["id"] 

2966 for f in uploaded_files 

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

2968 ] 

2969 if document_ids: 

2970 from ...database.session_passwords import session_password_store 

2971 

2972 # session_id was captured by the async wrapper before to_thread 

2973 db_password = session_password_store.get_session_password( 

2974 username, session_id 

2975 ) 

2976 if db_password: 

2977 trigger_auto_index( 

2978 document_ids, collection_id, username, db_password 

2979 ) 

2980 

2981 return { 

2982 "success": True, 

2983 "uploaded": uploaded_files, 

2984 "errors": errors, 

2985 "summary": { 

2986 "total": len(files_data), 

2987 "successful": len(uploaded_files), 

2988 "failed": len(errors), 

2989 }, 

2990 } 

2991 

2992 except Exception as e: 

2993 return handle_api_error("uploading files", e) 

2994 

2995 

2996# Research History Semantic Search Routes have been moved to 

2997# web/routers/library_search.py 

2998 

2999 

3000def _collection_rebuild_info(collection, index): 

3001 """Report durable rebuild state, independently of expiring task rows.""" 

3002 pending = collection.rebuild_pending_since if collection else None 

3003 updated = index.last_updated_at if index else None 

3004 return { 

3005 "rebuild_pending_since": pending.isoformat() 

3006 if isinstance(pending, datetime) 

3007 else None, 

3008 "has_published_index": index is not None, 

3009 "index_last_updated_at": updated.isoformat() 

3010 if isinstance(updated, datetime) 

3011 else None, 

3012 } 

3013 

3014 

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

3016def get_collection_documents( 

3017 request: Request, 

3018 collection_id, 

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

3020): 

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

3022 from ...database.session_context import get_user_db_session 

3023 

3024 try: 

3025 with get_user_db_session(username) as db_session: 

3026 # Verify collection exists in this user's database 

3027 collection = ( 

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

3029 ) 

3030 

3031 if not collection: 

3032 return JSONResponse( 

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

3034 status_code=404, 

3035 ) 

3036 

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

3038 note_source_type = ( 

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

3040 ) 

3041 note_source_type_id = ( 

3042 note_source_type.id if note_source_type else None 

3043 ) 

3044 

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

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

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

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

3049 has_text_col = ( 

3050 Document.text_content.isnot(None) 

3051 & (Document.text_content != "") 

3052 ).label("has_text_db") 

3053 doc_links = ( 

3054 db_session.query(DocumentCollection, Document, has_text_col) 

3055 .join(Document) 

3056 .options(defer(Document.text_content)) 

3057 .filter(DocumentCollection.collection_id == collection_id) 

3058 .all() 

3059 ) 

3060 

3061 documents = [] 

3062 for link, doc, has_text_db in doc_links: 

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

3064 if ( 

3065 note_source_type_id 

3066 and doc.source_type_id == note_source_type_id 

3067 ): 

3068 continue 

3069 # Check if PDF file is stored 

3070 has_pdf = bool( 

3071 doc.file_path and doc.file_path not in FILE_PATH_SENTINELS 

3072 ) 

3073 has_text_db = bool(has_text_db) 

3074 

3075 # Use title if available, otherwise filename 

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

3077 

3078 # Get source type name 

3079 source_type_name = ( 

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

3081 ) 

3082 

3083 # Check if document is in other collections 

3084 other_collections_count = ( 

3085 db_session.query(DocumentCollection) 

3086 .filter( 

3087 DocumentCollection.document_id == doc.id, 

3088 DocumentCollection.collection_id != collection_id, 

3089 ) 

3090 .count() 

3091 ) 

3092 

3093 documents.append( 

3094 { 

3095 "id": doc.id, 

3096 "filename": display_title, 

3097 "title": display_title, 

3098 "file_type": doc.file_type, 

3099 "file_size": doc.file_size, 

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

3101 if doc.created_at 

3102 else None, 

3103 "indexed": link.indexed, 

3104 "chunk_count": link.chunk_count, 

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

3106 if link.last_indexed_at 

3107 else None, 

3108 "has_pdf": has_pdf, 

3109 "has_text_db": has_text_db, 

3110 "source_type": source_type_name, 

3111 "in_other_collections": other_collections_count > 0, 

3112 "other_collections_count": other_collections_count, 

3113 } 

3114 ) 

3115 

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

3117 notes = [] 

3118 if note_source_type: 

3119 note_links = ( 

3120 db_session.query(DocumentCollection, Document) 

3121 .join(Document) 

3122 .filter( 

3123 DocumentCollection.collection_id == collection_id, 

3124 Document.source_type_id == note_source_type.id, 

3125 ) 

3126 .all() 

3127 ) 

3128 for link, note in note_links: 

3129 content = note.text_content or "" 

3130 notes.append( 

3131 { 

3132 "id": note.id, 

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

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

3135 if len(content) > 200 

3136 else content, 

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

3138 "pinned": note.favorite, 

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

3140 if note.created_at 

3141 else None, 

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

3143 if note.updated_at 

3144 else None, 

3145 "indexed": link.indexed, 

3146 "chunk_count": link.chunk_count, 

3147 "source_type": "note", 

3148 } 

3149 ) 

3150 

3151 # Get index file size if available 

3152 index_file_size = None 

3153 index_file_size_bytes = None 

3154 collection_name = f"collection_{collection_id}" 

3155 rag_index = ( 

3156 db_session.query(RAGIndex) 

3157 .filter_by(collection_name=collection_name, is_current=True) 

3158 .first() 

3159 ) 

3160 if rag_index and rag_index.index_path: 

3161 from pathlib import Path 

3162 

3163 index_path = Path(rag_index.index_path) 

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

3165 size_bytes = index_path.stat().st_size 

3166 index_file_size_bytes = size_bytes 

3167 # Format as human-readable 

3168 if size_bytes < 1024: 

3169 index_file_size = f"{size_bytes} B" 

3170 elif size_bytes < 1024 * 1024: 

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

3172 else: 

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

3174 

3175 return { 

3176 "success": True, 

3177 "collection": { 

3178 **_collection_rebuild_info(collection, rag_index), 

3179 "id": collection.id, 

3180 "name": collection.name, 

3181 "description": collection.description, 

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

3183 "agent_enabled": _agent_enabled_default_on(collection), 

3184 "embedding_model": collection.embedding_model, 

3185 "embedding_model_type": collection.embedding_model_type.value 

3186 if collection.embedding_model_type 

3187 else None, 

3188 "embedding_dimension": collection.embedding_dimension, 

3189 "chunk_size": collection.chunk_size, 

3190 "chunk_overlap": collection.chunk_overlap, 

3191 # Advanced settings 

3192 "splitter_type": collection.splitter_type, 

3193 "distance_metric": collection.distance_metric, 

3194 "index_type": collection.index_type, 

3195 "normalize_vectors": collection.normalize_vectors, 

3196 # Index file info 

3197 "index_file_size": index_file_size, 

3198 "index_file_size_bytes": index_file_size_bytes, 

3199 "collection_type": collection.collection_type, 

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

3201 # drift from PROTECTED_COLLECTION_TYPES: the details page 

3202 # hides its Delete button for system collections, whose 

3203 # deletion the service refuses with a 409 anyway. 

3204 "is_protected": _is_protected_collection(collection), 

3205 }, 

3206 "documents": documents, 

3207 "notes": notes, 

3208 } 

3209 

3210 except Exception as e: 

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

3212 

3213 

3214# ============================================================================= 

3215# Shared collection-indexing helpers 

3216# 

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

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

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

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

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

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

3223# TaskMetadata) and so keep their own loops. 

3224# ============================================================================= 

3225 

3226 

3227def _indexing_conflict(db_session, username, collection_id): 

3228 """Check while holding the collection's admission lock.""" 

3229 key = (username, str(collection_id)) 

3230 conflict = { 

3231 "success": False, 

3232 "error": "Indexing is already in progress for this collection", 

3233 } 

3234 with _active_sse_indexers_lock: 

3235 if _active_sse_indexers.get(key): 

3236 return conflict 

3237 task_id = _active_background_indexers.get(key) 

3238 if task_id is not None: 

3239 return {**conflict, "task_id": task_id} 

3240 

3241 in_progress = ( 

3242 db_session.query(TaskMetadata) 

3243 .filter( 

3244 TaskMetadata.task_type == "indexing", 

3245 TaskMetadata.status == "processing", 

3246 ) 

3247 .all() 

3248 ) 

3249 for task in in_progress: 

3250 if (task.metadata_json or {}).get("collection_id") == collection_id: 

3251 return {**conflict, "task_id": task.task_id} 

3252 return None 

3253 

3254 

3255def _register_sse_indexer(username, collection_id, db_password, cancel_event): 

3256 """Admit an SSE worker atomically with SSE and background requests.""" 

3257 from ...database.session_context import get_user_db_session 

3258 

3259 key = (username, str(collection_id)) 

3260 lock = _start_bg_index_locks.setdefault(key, threading.Lock()) 

3261 with lock, get_user_db_session(username, db_password) as db_session: 

3262 if _indexing_conflict(db_session, username, collection_id): 

3263 return False 

3264 with _active_sse_indexers_lock: 

3265 _active_sse_indexers.setdefault(key, set()).add(cancel_event) 

3266 return True 

3267 

3268 

3269def _unregister_sse_indexer(username, collection_id, cancel_event): 

3270 """Release admission only once every worker belonging to this stream stopped.""" 

3271 key = (username, str(collection_id)) 

3272 with _active_sse_indexers_lock: 

3273 events = _active_sse_indexers.get(key) 

3274 if events is not None: 

3275 events.discard(cancel_event) 

3276 if not events: 

3277 _active_sse_indexers.pop(key, None) 

3278 

3279 

3280def _unregister_background_indexer(username, collection_id, task_id): 

3281 key = (username, str(collection_id)) 

3282 with _active_sse_indexers_lock: 

3283 if _active_background_indexers.get(key) == task_id: 3283 ↛ exitline 3283 didn't jump to the function exit

3284 _active_background_indexers.pop(key, None) 

3285 

3286 

3287def _store_collection_embedding_metadata(collection, rag_service): 

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

3289 

3290 Run on first incremental index. Forced rebuilds publish their metadata 

3291 together with the replacement index. Includes the embedding dimension, 

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

3293 it for the RAGIndex row). Previously this read 

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

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

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

3297 not commit — the caller owns the transaction. 

3298 """ 

3299 embedding_dim = None 

3300 try: 

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

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

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

3304 test_embedding = rag_service.embedding_manager.embeddings.embed_query( 

3305 "test" 

3306 ) 

3307 embedding_dim = len(test_embedding) 

3308 except Exception: 

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

3310 

3311 collection.embedding_model = rag_service.embedding_model 

3312 collection.embedding_model_type = EmbeddingProvider( 

3313 rag_service.embedding_provider 

3314 ) 

3315 collection.embedding_dimension = embedding_dim 

3316 collection.chunk_size = rag_service.chunk_size 

3317 collection.chunk_overlap = rag_service.chunk_overlap 

3318 # Advanced settings 

3319 collection.splitter_type = rag_service.splitter_type 

3320 collection.text_separators = rag_service.text_separators 

3321 collection.distance_metric = rag_service.distance_metric 

3322 # Ensure normalize_vectors is a proper boolean for the database 

3323 collection.normalize_vectors = bool(rag_service.normalize_vectors) 

3324 collection.index_type = rag_service.index_type 

3325 logger.info( 

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

3327 ) 

3328 

3329 

3330def _query_documents_to_index(db_session, collection_id, force_reindex): 

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

3332 

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

3334 """ 

3335 query = ( 

3336 db_session.query(DocumentCollection, Document) 

3337 .join(Document) 

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

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

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

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

3342 .options(defer(Document.text_content)) 

3343 .filter(DocumentCollection.collection_id == collection_id) 

3344 ) 

3345 

3346 if not force_reindex: 

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

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

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

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

3351 # SQLAlchemy requires explicit comparison: `== False` or `== True` 

3352 # This has been fixed multiple times - DO NOT change it back to `not`! 

3353 query = query.filter(DocumentCollection.indexed == False) # noqa: E712 

3354 

3355 return query.all() 

3356 

3357 

3358@router.post("/api/collections/{collection_id}/index") 

3359def index_collection( 

3360 request: Request, 

3361 collection_id, 

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

3363): 

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

3365 

3366 POST, not GET, because it mutates — same reasoning as index-all: 

3367 a first index writes embedding metadata, and it runs the 

3368 embedding/index pipeline over every document in the collection 

3369 (``?force_reindex=true`` stages a complete replacement and publishes 

3370 it in one transaction, keeping the existing vectors until then), and 

3371 CSRFMiddleware keys enforcement on the method, so the verb is what 

3372 puts it behind the token check. A GET would sit outside the 

3373 middleware entirely and be startable from same-site markup riding 

3374 the victim's session cookie. Nothing in the shipped frontend called 

3375 it — the history page's Index button POSTs COLLECTION_INDEX_START. 

3376 Progress still streams as SSE; a fetch client can send both a POST 

3377 and the header. Query parameters are unchanged.""" 

3378 from ...database.session_context import get_user_db_session 

3379 from ...database.session_passwords import session_password_store 

3380 

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

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

3383 # documents. 

3384 force_reindex = ( 

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

3386 ) 

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

3388 

3389 logger.info(f"Starting index_collection, force_reindex={force_reindex}") 

3390 

3391 # Get password for thread access to encrypted database 

3392 db_password = None 

3393 if session_id: 

3394 db_password = session_password_store.get_session_password( 

3395 username, session_id 

3396 ) 

3397 

3398 # Create RAG service — on force reindex use current default model 

3399 rag_service = get_rag_service( 

3400 request, username, collection_id, use_defaults=force_reindex 

3401 ) 

3402 logger.info( 

3403 f"RAG service created: provider={rag_service.embedding_provider}" 

3404 ) 

3405 

3406 # Import before building the SSE generator. Importing inside its try 

3407 # would leave the exception handler unbound if this import failed. 

3408 # Keep it below module scope because it pulls in the deletion package. 

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

3410 RemoteVectorStoreDropError, 

3411 remote_drop_failure_message, 

3412 ) 

3413 

3414 # Resolve the parallel worker bound before the generator runs — the 

3415 # parallel helper must never touch SettingsManager from a worker thread. 

3416 with get_user_db_session(username, db_password) as _settings_session: 

3417 _settings = get_settings_manager(_settings_session, username) 

3418 try: 

3419 _max_workers = int( 

3420 _settings.get_setting("rag.indexing_max_parallel_docs", 4) 

3421 ) 

3422 except SettingsValueError: 

3423 raise 

3424 except Exception: 

3425 _max_workers = 4 

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

3427 

3428 def generate(): 

3429 """Generator for SSE progress updates.""" 

3430 logger.info("SSE generator started") 

3431 _sse_cancel = threading.Event() 

3432 worker_thread = None 

3433 worker_finished: Future[None] = Future() 

3434 try: 

3435 if not _register_sse_indexer( 

3436 username, collection_id, db_password, _sse_cancel 

3437 ): 

3438 yield f"data: {json.dumps({'type': 'error', 'error': 'Indexing is already in progress for this collection'})}\n\n" 

3439 return 

3440 # All setup DB work happens in a SHORT-LIVED session; the per-doc 

3441 # ids/filenames are snapshotted into plain tuples so the long 

3442 # indexing loop below (which yields progress + heartbeats) holds NO 

3443 # session across a yield — see download_bulk for the Starlette/anyio 

3444 # thread-affinity rationale. index_document opens its own session 

3445 # per document in a worker thread and commits its own writes, so the 

3446 # old outer db_session.commit()/flush() was vestigial. 

3447 collection_found = True 

3448 collection_name = None 

3449 doc_info = [] 

3450 with get_user_db_session(username, db_password) as db_session: 

3451 # Verify collection exists in this user's database 

3452 collection = ( 

3453 db_session.query(Collection) 

3454 .filter_by(id=collection_id) 

3455 .first() 

3456 ) 

3457 

3458 if not collection: 

3459 collection_found = False 

3460 else: 

3461 collection_name = collection.name 

3462 # Keep the published configuration during a rebuild. 

3463 # Its replacement is committed with the complete new index. 

3464 if collection.embedding_model is None and not force_reindex: 

3465 _store_collection_embedding_metadata( 

3466 collection, rag_service 

3467 ) 

3468 db_session.commit() 

3469 

3470 # Snapshot (id, display name) for each document to index. 

3471 doc_info = [ 

3472 (doc.id, doc.filename or doc.title or "Unknown") 

3473 for _link, doc in _query_documents_to_index( 

3474 db_session, collection_id, force_reindex 

3475 ) 

3476 ] 

3477 

3478 if not collection_found: 

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

3480 return 

3481 

3482 if not doc_info and not force_reindex: 

3483 logger.info("No documents to index in collection") 

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

3485 return 

3486 

3487 total = len(doc_info) 

3488 logger.info(f"Found {total} documents to index") 

3489 results = { 

3490 "successful": 0, 

3491 "skipped": 0, 

3492 "failed": 0, 

3493 "errors": [], 

3494 } 

3495 

3496 yield f"data: {json.dumps({'type': 'start', 'message': f'Indexing {total} documents in collection: {collection_name}'})}\n\n" 

3497 

3498 # Fan out indexing across a bounded worker pool. We run the 

3499 # pool in a background thread so the SSE generator's main 

3500 # thread can still yield heartbeats during long embedding 

3501 # round-trips (matters for nginx / browser SSE timeout 

3502 # behaviour). The progress_callback fired by the parallel 

3503 # helper pushes per-document events into ``progress_queue``, 

3504 # which the generator drains as they arrive. 

3505 # 

3506 # ``_max_workers`` is resolved from settings before the 

3507 # generator starts (closure capture) so the parallel helper 

3508 # never touches SettingsManager from inside a worker. 

3509 progress_queue: queue.Queue = queue.Queue() 

3510 _SENTINEL_COMPLETE = object() 

3511 _SENTINEL_ERROR = object() 

3512 

3513 def _on_progress( 

3514 completed: int, 

3515 total: int, 

3516 title: str, 

3517 status: str, 

3518 ) -> None: 

3519 progress_queue.put( 

3520 ( 

3521 "progress", 

3522 completed, 

3523 total, 

3524 title, 

3525 status, 

3526 ) 

3527 ) 

3528 

3529 def _run_parallel(): 

3530 try: 

3531 # ``is_cancelled`` polls the SSE-disconnect event 

3532 # so the helper can exit early between completions 

3533 # when the client has gone away; the helper's 

3534 # ``pool.shutdown(wait=True, ...)`` still drains 

3535 # in-flight workers before returning so no worker 

3536 # outlives the helper return — see ``index_documents_parallel`` for the 

3537 # rationale. 

3538 index_documents = ( 

3539 rag_service.rebuild_collection 

3540 if force_reindex 

3541 else rag_service.index_documents_parallel 

3542 ) 

3543 aggregate = index_documents( 

3544 doc_info, 

3545 collection_id, 

3546 max_workers=_max_workers, 

3547 progress_callback=_on_progress, 

3548 is_cancelled=_sse_cancel.is_set, 

3549 ) 

3550 progress_queue.put((_SENTINEL_COMPLETE, aggregate)) 

3551 except Exception as exc: 

3552 logger.exception( 

3553 "Parallel indexing in SSE generator crashed" 

3554 ) 

3555 progress_queue.put((_SENTINEL_ERROR, exc)) 

3556 finally: 

3557 # Best-effort thread-local DB session cleanup so a 

3558 # long-running worker batch doesn't accumulate 

3559 # scoped sessions (#3194). 

3560 try: 

3561 from ...database.thread_local_session import ( 

3562 cleanup_current_thread, 

3563 ) 

3564 

3565 cleanup_current_thread() 

3566 except Exception: 

3567 logger.debug( 

3568 "best-effort thread-local DB session cleanup", 

3569 exc_info=True, 

3570 ) 

3571 finally: 

3572 # All indexing writes and thread-local cleanup have 

3573 # finished. A disconnected stream can release its 

3574 # admission and service here without another thread. 

3575 worker_finished.set_result(None) 

3576 

3577 # Copy the request's contextvars so username-dependent 

3578 # fallbacks (e.g. log attribution) inside the worker resolve 

3579 # to the authenticated user rather than None. 

3580 _par_ctx = contextvars.copy_context() 

3581 worker_thread = threading.Thread( 

3582 target=_par_ctx.run, 

3583 args=(_run_parallel,), 

3584 daemon=True, 

3585 name="index-collection-parallel", 

3586 ) 

3587 worker_thread.start() 

3588 

3589 # Drain the queue: yield progress events as they arrive, 

3590 # yield heartbeat comments on idle to keep SSE proxies 

3591 # from timing out, exit on completion / error sentinels. 

3592 heartbeat_interval = 5 # seconds 

3593 _aggregate = None 

3594 while True: 

3595 try: 

3596 item = progress_queue.get(timeout=heartbeat_interval) 

3597 except queue.Empty: 

3598 # No event in 5s. Heartbeat unless the worker 

3599 # died without signalling (would hang forever). 

3600 if worker_thread.is_alive(): 

3601 yield f": heartbeat {total}\n\n" 

3602 continue 

3603 logger.exception( 

3604 "Indexer worker exited without completion " 

3605 "signal; aborting SSE" 

3606 ) 

3607 yield f"data: {json.dumps({'type': 'error', 'error': 'Indexer stopped unexpectedly'})}\n\n" 

3608 return 

3609 

3610 if item[0] is _SENTINEL_COMPLETE: 

3611 _aggregate = item[1] 

3612 break 

3613 if item[0] is _SENTINEL_ERROR: 

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

3615 return 

3616 

3617 _kind, completed, total, title, status = item 

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

3619 

3620 if status == "error": 

3621 # Per-doc error surfaced so the browser can show 

3622 # failed documents inline. Scrub creds/keys here 

3623 # (the full trace stays in server logs above). 

3624 # Look the error up from the aggregate's errors 

3625 # list — at this point _aggregate is None 

3626 # (indexer still running), so we accept a brief 

3627 # imprecision on the exact error text and emit 

3628 # a generic doc_error; the final ``complete`` 

3629 # event below will list precise errors. 

3630 yield f"data: {json.dumps({'type': 'doc_error', 'filename': title, 'error': 'Indexing failed'})}\n\n" 

3631 

3632 if _aggregate is not None: 3632 ↛ 3652line 3632 didn't jump to line 3652 because the condition on line 3632 was always true

3633 results["successful"] = _aggregate["successful"] 

3634 results["skipped"] = _aggregate["skipped"] 

3635 results["failed"] = _aggregate["failed"] 

3636 # Keep document identity for the UI, but never put an 

3637 # extension's arbitrary error detail in the SSE event. 

3638 results["errors"] = [ 

3639 { 

3640 "filename": err.get("title"), 

3641 "error": _client_indexing_error(err.get("error")), 

3642 } 

3643 for err in _aggregate.get("errors", []) 

3644 ] 

3645 logger.info( 

3646 f"Indexing complete: " 

3647 f"{results['successful']} successful, " 

3648 f"{results['failed']} failed, " 

3649 f"{results['skipped']} skipped" 

3650 ) 

3651 

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

3653 logger.info("SSE generator finished successfully") 

3654 

3655 except RemoteVectorStoreDropError as exc: 

3656 # Caught ahead of the catch-all below: this is an actionable 

3657 # vector-store failure, not an internal error. Force-reindex rolls 

3658 # back, keeping the RAGIndex rows (the remote collections' only 

3659 # handle); the message says whether a retry can help and whether 

3660 # some collections were already dropped (#5765). Logged from the 

3661 # error's sanitized fields, with no traceback (a traceback would 

3662 # add frame state; the backend's error text can carry secrets). 

3663 drop_detail = exc.log_detail() 

3664 logger.warning( 

3665 "Force-reindex aborted: could not drop the vector-store " 

3666 f"collection before rebuilding it: {drop_detail}" 

3667 ) 

3668 drop_error = { 

3669 "type": "error", 

3670 "error": remote_drop_failure_message( 

3671 exc, "indexing was aborted" 

3672 ), 

3673 } 

3674 yield f"data: {json.dumps(drop_error)}\n\n" 

3675 except Exception: 

3676 logger.exception("Error in collection indexing") 

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

3678 finally: 

3679 # Signal cancellation immediately, but retain admission until 

3680 # every in-flight writer has stopped, including deferred drains. 

3681 _sse_cancel.set() 

3682 # On client disconnect this finally can run on the event loop 

3683 # thread (generator close), so the drain must be bounded: give 

3684 # in-flight workers a short grace period, then hand the 

3685 # remaining cleanup to the existing worker's completion callback. 

3686 # _sse_cancel is already set, so the parallel helper admits no 

3687 # new work and terminates once in-flight docs finish. 

3688 if worker_thread is not None and worker_thread.is_alive(): 

3689 worker_thread.join(timeout=5.0) 

3690 if worker_thread is not None and worker_thread.is_alive(): 

3691 logger.info( 

3692 "Indexer still draining after SSE close; deferring " 

3693 "cleanup until the worker finishes" 

3694 ) 

3695 

3696 def _close_after_worker(_finished): 

3697 _unregister_sse_indexer( 

3698 username, collection_id, _sse_cancel 

3699 ) 

3700 safe_close( 

3701 rag_service, 

3702 "rag_service (index-collection SSE, deferred)", 

3703 ) 

3704 

3705 # Future invokes the callback exactly once, even if the 

3706 # worker finished between is_alive() and registration. 

3707 # Starting a separate drain thread could fail and strand 

3708 # admission after the indexing worker has already exited. 

3709 worker_finished.add_done_callback(_close_after_worker) 

3710 else: 

3711 _unregister_sse_indexer(username, collection_id, _sse_cancel) 

3712 safe_close(rag_service, "rag_service (index-collection SSE)") 

3713 

3714 response = WorkerCleanupStreamingResponse( 

3715 generate(), media_type="text/event-stream" 

3716 ) 

3717 # Prevent buffering for proper SSE streaming 

3718 response.headers["Cache-Control"] = "no-cache, no-transform" 

3719 response.headers["Connection"] = "keep-alive" 

3720 response.headers["X-Accel-Buffering"] = "no" 

3721 return response 

3722 

3723 

3724# ============================================================================= 

3725# Background Indexing Endpoints 

3726# ============================================================================= 

3727 

3728 

3729def _get_rag_service_for_thread( 

3730 collection_id: str, 

3731 username: str, 

3732 db_password: str, 

3733 use_defaults: bool = False, 

3734) -> LibraryRAGService: 

3735 """ 

3736 Create RAG service for use in background threads (no Flask context). 

3737 

3738 Delegates settings resolution to the shared rag_service_factory, then 

3739 propagates db_password to the embedding manager for thread-safe DB access. 

3740 """ 

3741 from ...research_library.services.rag_service_factory import ( 

3742 get_rag_service as _get_rag_service, 

3743 ) 

3744 

3745 service = _get_rag_service( 

3746 username, 

3747 collection_id, 

3748 use_defaults=use_defaults, 

3749 db_password=db_password, 

3750 ) 

3751 # The factory passes db_password to LibraryRAGService, but __init__ stores 

3752 # it in the backing field (_db_password) without propagating to sub-managers. 

3753 # Re-assign via the property setter to propagate to embedding_manager and 

3754 # integrity_manager, which need it for thread-safe session access. 

3755 service.db_password = db_password 

3756 return service 

3757 

3758 

3759def trigger_auto_index( 

3760 document_ids: list[str], 

3761 collection_id: str, 

3762 username: str, 

3763 db_password: str, 

3764) -> None: 

3765 """ 

3766 Trigger automatic RAG indexing for documents if auto-indexing is enabled. 

3767 

3768 This function checks the auto_index_enabled setting and spawns a background 

3769 thread to index the specified documents. It does not block the caller. 

3770 

3771 Args: 

3772 document_ids: List of document IDs to index 

3773 collection_id: The collection to index into 

3774 username: The username for database access 

3775 db_password: The user's database password for thread-safe access 

3776 """ 

3777 from ...database.session_context import get_user_db_session 

3778 

3779 if not document_ids: 

3780 logger.debug("No documents to auto-index") 

3781 return 

3782 

3783 # Check if auto-indexing is enabled 

3784 try: 

3785 with get_user_db_session(username, db_password) as db_session: 

3786 settings = SettingsManager(db_session) 

3787 auto_index_enabled = settings.get_bool_setting( 

3788 "research_library.auto_index_enabled", True 

3789 ) 

3790 

3791 if not auto_index_enabled: 

3792 logger.debug("Auto-indexing is disabled, skipping") 

3793 return 

3794 

3795 try: 

3796 _auto_max_workers = int( 

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

3798 ) 

3799 except SettingsValueError: 

3800 raise 

3801 except Exception: 

3802 _auto_max_workers = 4 

3803 _auto_max_workers = max(1, min(_auto_max_workers, 16)) 

3804 except Exception: 

3805 logger.exception( 

3806 "Failed to check auto-index setting, skipping auto-index" 

3807 ) 

3808 return 

3809 

3810 # Reserve a queue slot before submission. If the indexing queue is 

3811 # saturated (too many uploads in flight), drop this auto-index job 

3812 # rather than letting the executor's unbounded internal queue grow 

3813 # without bound. The user can reindex manually later via the UI. 

3814 if not _try_reserve_auto_index_slot(): 

3815 logger.warning( 

3816 "Auto-index queue saturated ({}+ jobs pending); dropping " 

3817 "auto-index for {} document(s) in collection {}. " 

3818 "Documents are uploaded; trigger a manual reindex if needed.", 

3819 _MAX_PENDING_AUTO_INDEX_JOBS, 

3820 len(document_ids), 

3821 collection_id, 

3822 ) 

3823 return 

3824 

3825 logger.info( 

3826 f"Auto-indexing {len(document_ids)} documents in collection {collection_id}" 

3827 ) 

3828 

3829 # Release the reserved slot EXACTLY ONCE per submission. CPython's 

3830 # ThreadPoolExecutor.submit() enqueues the work item BEFORE it tries to 

3831 # spin up a worker thread; if thread-start raises (e.g. 

3832 # RuntimeError("can't start new thread")), the item is already queued, so 

3833 # a live worker can run _wrapped_worker (releasing in its finally) AND the 

3834 # except block below also fires. A per-call lock + flag makes the release 

3835 # idempotent so that double-fire only releases once — otherwise the 

3836 # counter under-counts in-flight jobs and erodes the OOM bound (the 

3837 # max(0, ...) floor would silently hide the drift). 

3838 _release_lock = threading.Lock() 

3839 _slot_released = {"done": False} 

3840 

3841 def _release_slot_once(): 

3842 with _release_lock: 

3843 if _slot_released["done"]: 

3844 return 

3845 _slot_released["done"] = True 

3846 _release_auto_index_slot() 

3847 

3848 def _wrapped_worker(*args, **kwargs): 

3849 try: 

3850 _auto_index_documents_worker(*args, **kwargs) 

3851 finally: 

3852 _release_slot_once() 

3853 

3854 # Submit to thread pool (bounded concurrency, prevents thread proliferation). 

3855 # Build the executor inside the try too: if _get_auto_index_executor() 

3856 # itself fails (OS thread/mutex exhaustion), the slot must still be 

3857 # released — otherwise a reserved slot leaks permanently. 

3858 try: 

3859 executor = _get_auto_index_executor() 

3860 executor.submit( 

3861 _wrapped_worker, 

3862 document_ids, 

3863 collection_id, 

3864 username, 

3865 db_password, 

3866 _auto_max_workers, 

3867 ) 

3868 except Exception: 

3869 # If submit itself fails (executor shutting down, OOM, etc.), the 

3870 # wrapped worker may never run, so release the slot here. The release 

3871 # is idempotent: if the work item was already enqueued and a worker 

3872 # ran (and released) before thread-start failed, this is a no-op. Do 

3873 # NOT re-raise: the upload has already been committed by the caller, 

3874 # so propagating would turn a successful upload into a 500 (and prompt 

3875 # the client to retry, creating duplicates). Auto-indexing is simply 

3876 # skipped; the user can trigger a manual reindex later via the UI. 

3877 _release_slot_once() 

3878 logger.exception( 

3879 "Failed to submit auto-index job for {} document(s) in " 

3880 "collection {}; upload succeeded, auto-indexing skipped. " 

3881 "Trigger a manual reindex if needed.", 

3882 len(document_ids), 

3883 collection_id, 

3884 ) 

3885 

3886 

3887@thread_cleanup 

3888def _auto_index_documents_worker( 

3889 document_ids: list[str], 

3890 collection_id: str, 

3891 username: str, 

3892 db_password: str, 

3893 max_workers: int = 4, 

3894) -> None: 

3895 """ 

3896 Background worker to index documents automatically. 

3897 

3898 This is a simpler worker than _background_index_worker - it doesn't track 

3899 progress via TaskMetadata since it's meant to be a lightweight auto-indexing 

3900 operation. 

3901 

3902 Documents inside one call are indexed concurrently via 

3903 :meth:`LibraryRAGService.index_documents_parallel`. The outer 

3904 ``_auto_index_executor`` already parallelises separate jobs at the upload 

3905 layer (one job per upload batch) and we additionally fan out inside each 

3906 job, so a burst of uploads doesn't serialise within a single upload's 

3907 document set. 

3908 

3909 The ``max_workers`` parameter is resolved on the caller (which owns 

3910 the Flask request context and therefore a real 

3911 :func:`get_settings_manager` call). Background-thread workers must 

3912 NOT call ``get_settings_manager()`` without an explicit 

3913 ``db_session=`` — see pre-commit hook 

3914 ``check-settings-manager-thread-safety`` and issue #3453. 

3915 """ 

3916 

3917 try: 

3918 # Create RAG service (thread-safe, no Flask context needed) 

3919 with _get_rag_service_for_thread( 

3920 collection_id, username, db_password 

3921 ) as rag_service: 

3922 # The auto-indexer doesn't surface per-doc titles to a user, 

3923 # so we stub them with empty strings — the parallel helper 

3924 # only reads titles for progress reporting. 

3925 doc_info = [(doc_id, "") for doc_id in document_ids] 

3926 aggregate = rag_service.index_documents_parallel( 

3927 doc_info, 

3928 collection_id, 

3929 force_reindex=False, 

3930 max_workers=max(1, min(int(max_workers), 16)), 

3931 ) 

3932 logger.info( 

3933 f"Auto-indexing complete: {aggregate['successful']}" 

3934 f"/{len(document_ids)} documents indexed " 

3935 f"(skipped={aggregate['skipped']}, " 

3936 f"failed={aggregate['failed']})" 

3937 ) 

3938 

3939 except Exception: 

3940 logger.exception("Auto-indexing worker failed") 

3941 

3942 

3943# Fixed per-document guidance that LibraryRAGService returns as a result's 

3944# "error". An exact match reaches the client; _client_indexing_error replaces 

3945# any other text. TestIndexingErrorAllowlistMatchesService in 

3946# tests/security/test_library_rag_security_fastapi.py checks this set, and 

3947# _EMPTY_VECTOR_STORE_REASON below, against the text the service builds. 

3948_SAFE_INDEXING_ERRORS = frozenset( 

3949 { 

3950 "Document not found", 

3951 "Document not found in collection", 

3952 "Document has no text content", 

3953 "Document deleted during indexing", 

3954 "Document changed; retry indexing", 

3955 "Collection configuration changed; retry indexing", 

3956 } 

3957) 

3958_EMPTY_VECTOR_STORE_REASON = ( 

3959 "live vector store reported zero ids while DocumentChunk rows exist; " 

3960 "refusing to clear indexed flags" 

3961) 

3962_COLLECTION_NOT_FOUND = "Collection not found" 

3963_REMOTE_DROP_OUTCOME = "indexing was aborted" 

3964# Appended when a cancel arrived after a rebuild began publishing. 

3965_LATE_CANCEL_NOTE = ( 

3966 "The cancel request arrived after the rebuild began publishing, " 

3967 "so the new index is in use." 

3968) 

3969# Codes for ``metadata_json["failure"]``. The indexing worker records one next 

3970# to an ``error_message`` it built only from fixed phrases and counts, and the 

3971# status endpoint rebuilds that message from the record instead of serving the 

3972# stored text. 

3973_FAILURE_COLLECTION_NOT_FOUND = "collection_not_found" 

3974_FAILURE_REMOTE_DROP = "remote_vector_store_drop" 

3975 

3976 

3977def _client_indexing_error(error: Any) -> str: 

3978 """Return only messages known to be safe for indexing clients. 

3979 

3980 Service extensions and exception handlers can supply arbitrary error text. 

3981 Credential-shape scrubbing does not remove paths, SQL, or provider URLs. 

3982 """ 

3983 if error is None or (isinstance(error, str) and not error): 

3984 return "Indexing failed" 

3985 if isinstance(error, str) and error in _SAFE_INDEXING_ERRORS: 

3986 return error 

3987 return "Indexing failed. Check the server logs for details." 

3988 

3989 

3990def _client_reconciliation_reason(reason: Any) -> str: 

3991 """Preserve the known durable-state warning, never arbitrary detail.""" 

3992 if isinstance(reason, str) and reason == _EMPTY_VECTOR_STORE_REASON: 

3993 return _EMPTY_VECTOR_STORE_REASON 

3994 return ( 

3995 "Could not verify the vector store. Check the server logs for details." 

3996 ) 

3997 

3998 

3999def _reconciliation_skipped_message(reason: str) -> str: 

4000 """Task error for a skipped reconciliation, given a client-safe reason. 

4001 

4002 The worker stores it and the status endpoint rebuilds it, both through 

4003 this function, so the two texts cannot drift apart. 

4004 """ 

4005 return ( 

4006 f"Reconciliation skipped: {reason.rstrip('.')}. Indexed flags " 

4007 "preserved; verify the FAISS index before retrying." 

4008 ) 

4009 

4010 

4011def _reconciled_failures_message(failed: int) -> str: 

4012 """Task error when documents failed and reconciliation then completed.""" 

4013 return ( 

4014 f"{failed} document(s) failed to index. The collection was " 

4015 "reconciled to the durable vector store." 

4016 ) 

4017 

4018 

4019def _remote_drop_failure(exc: Any) -> dict: 

4020 """The failure record for a force-reindex whose remote drop failed. 

4021 

4022 It keeps only the two fields ``remote_drop_failure_message`` reads: a 

4023 flag and a count. The error's own text is never recorded. A count that is 

4024 not a non-negative int is stored as None, so the status endpoint withholds 

4025 the message rather than rebuild it from a bad value. 

4026 """ 

4027 dropped = exc.dropped 

4028 return { 

4029 "code": _FAILURE_REMOTE_DROP, 

4030 "retryable": bool(exc.retryable), 

4031 "dropped": dropped if type(dropped) is int and dropped >= 0 else None, 

4032 } 

4033 

4034 

4035def _client_task_failure_message(failure: Any) -> Optional[str]: 

4036 """Rebuild the client text for a worker failure record. 

4037 

4038 Returns None for anything else: no record, an unknown code, or a field of 

4039 the wrong type. The caller then withholds the stored message. 

4040 """ 

4041 if not isinstance(failure, dict): 

4042 return None 

4043 code = failure.get("code") 

4044 if code == _FAILURE_COLLECTION_NOT_FOUND: 

4045 return _COLLECTION_NOT_FOUND 

4046 if code == _FAILURE_REMOTE_DROP: 

4047 retryable = failure.get("retryable") 

4048 dropped = failure.get("dropped") 

4049 if type(retryable) is bool and type(dropped) is int and dropped >= 0: 

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

4051 RemoteVectorStoreDropError, 

4052 remote_drop_failure_message, 

4053 ) 

4054 

4055 # The same template the worker used, fed only the recorded 

4056 # flag and count. 

4057 return remote_drop_failure_message( 

4058 RemoteVectorStoreDropError( 

4059 "recorded remote-drop failure", 

4060 retryable=retryable, 

4061 dropped=dropped, 

4062 ), 

4063 _REMOTE_DROP_OUTCOME, 

4064 ) 

4065 return None 

4066 

4067 

4068def _sanitized_indexing_errors(results: dict, limit: int = 50) -> list: 

4069 """Return a bounded list of client-safe per-document errors.""" 

4070 errors = results.get("errors") 

4071 if not isinstance(errors, list): 

4072 return [] 

4073 return [ 

4074 { 

4075 "doc_id": item.get("doc_id"), 

4076 "title": item.get("title"), 

4077 "error": _client_indexing_error(item.get("error")), 

4078 } 

4079 for item in errors[: max(0, limit)] 

4080 if isinstance(item, dict) 

4081 ] 

4082 

4083 

4084def _client_indexing_summary(results: dict) -> dict: 

4085 """Keep only the non-negative integer counts and the ``cancelled`` flag. 

4086 

4087 Every other key is dropped. 

4088 """ 

4089 summary = {} 

4090 for key in ("successful", "skipped", "failed", "total"): 

4091 value = results.get(key) 

4092 if type(value) is int and value >= 0: 

4093 summary[key] = value 

4094 if type(results.get("cancelled")) is bool: 4094 ↛ 4095line 4094 didn't jump to line 4095 because the condition on line 4094 was never true

4095 summary["cancelled"] = results["cancelled"] 

4096 return summary 

4097 

4098 

4099def _client_indexing_result(result: Any) -> Optional[dict]: 

4100 """Filter old and new task rows at the HTTP boundary.""" 

4101 if not isinstance(result, dict): 

4102 return None 

4103 safe = _client_indexing_summary(result) 

4104 for key in ( 

4105 "durable_indexed_documents", 

4106 "durable_indexed_chunks", 

4107 "live_vectors", 

4108 "orphan_vectors", 

4109 ): 

4110 value = result.get(key) 

4111 if type(value) is int and value >= 0: 

4112 safe[key] = value 

4113 if result.get("reconciliation_skipped") is True: 

4114 safe["reconciliation_skipped"] = True 

4115 safe["reconciliation_reason"] = _client_reconciliation_reason( 

4116 result.get("reconciliation_reason") 

4117 ) 

4118 if "errors" in result: 4118 ↛ 4120line 4118 didn't jump to line 4120 because the condition on line 4118 was always true

4119 safe["errors"] = _sanitized_indexing_errors(result) 

4120 return safe 

4121 

4122 

4123def _client_indexing_task_error( 

4124 message: Any, result: Optional[dict], failure: Any = None 

4125) -> Optional[str]: 

4126 """Rebuild known task errors rather than trusting persisted text. 

4127 

4128 The client text comes from the worker's failure record (``failure``) or 

4129 from the result counts (``result``, already filtered by 

4130 ``_client_indexing_result``). Any other stored message gets the generic 

4131 text. That includes rows written before these rules, whose text can hold 

4132 exception detail. 

4133 

4134 A stored message may be served unchanged only through an exact match on 

4135 text that this module writes to an indexing task row. 

4136 ``TestIndexingTaskErrorPassThroughs`` in 

4137 tests/security/test_library_rag_security_fastapi.py fails on a match 

4138 that nothing writes, and on fixed text written without a failure record 

4139 that is neither matched nor listed there as withheld. 

4140 """ 

4141 if message is None: 

4142 return None 

4143 rebuilt = _client_task_failure_message(failure) 

4144 if rebuilt is not None: 

4145 return rebuilt 

4146 if message == "Failed to start indexing. Please try again.": 

4147 return message 

4148 if result is not None: 

4149 if result.get("reconciliation_skipped") is True: 

4150 return _reconciliation_skipped_message( 

4151 _client_reconciliation_reason( 

4152 result.get("reconciliation_reason") 

4153 ) 

4154 ) 

4155 failed = result.get("failed") 

4156 if type(failed) is int and failed > 0: 4156 ↛ 4160line 4156 didn't jump to line 4160 because the condition on line 4156 was always true

4157 if "durable_indexed_documents" in result: 

4158 return _reconciled_failures_message(failed) 

4159 return f"{failed} document(s) failed to index. Check the server logs for details." 

4160 return "Indexing failed. Check the server logs for details." 

4161 

4162 

4163@thread_cleanup 

4164def _background_index_worker( 

4165 task_id: str, 

4166 collection_id: str, 

4167 username: str, 

4168 db_password: str, 

4169 force_reindex: bool, 

4170 max_workers: int = 4, 

4171 settings_snapshot: Optional[dict] = None, 

4172): 

4173 """ 

4174 Background worker thread for indexing documents. 

4175 Updates TaskMetadata with progress and checks for cancellation. 

4176 

4177 The ``max_workers`` parameter bounds the per-job fan-out into 

4178 :meth:`LibraryRAGService.index_documents_parallel`. It must be 

4179 resolved on the route (which has Flask app context), not inside 

4180 this worker — background threads cannot call 

4181 :func:`get_settings_manager` safely (#3453, pre-commit hook 

4182 ``check-settings-manager-thread-safety``). 

4183 

4184 ``settings_snapshot`` remains accepted for caller compatibility. Staged 

4185 rebuilds currently require exclusively local-file vector-store providers; 

4186 remote providers need a separate atomic publication protocol. 

4187 """ 

4188 from ...database.session_context import get_user_db_session 

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

4190 RemoteVectorStoreDropError, 

4191 remote_drop_failure_message, 

4192 ) 

4193 

4194 try: 

4195 # Create RAG service (thread-safe, no Flask context needed) 

4196 with _get_rag_service_for_thread( 

4197 collection_id, username, db_password, use_defaults=force_reindex 

4198 ) as rag_service: 

4199 with get_user_db_session(username, db_password) as db_session: 

4200 # Get collection 

4201 collection = ( 

4202 db_session.query(Collection) 

4203 .filter_by(id=collection_id) 

4204 .first() 

4205 ) 

4206 

4207 if not collection: 

4208 _update_task_status( 

4209 username, 

4210 db_password, 

4211 task_id, 

4212 status="failed", 

4213 error_message=_COLLECTION_NOT_FOUND, 

4214 failure={"code": _FAILURE_COLLECTION_NOT_FOUND}, 

4215 ) 

4216 return 

4217 

4218 # A rebuild retains the published configuration until its 

4219 # complete replacement can be committed atomically. 

4220 if collection.embedding_model is None and not force_reindex: 

4221 _store_collection_embedding_metadata( 

4222 collection, rag_service 

4223 ) 

4224 db_session.commit() 

4225 

4226 # Keep only scalar values after the setup scope exits. The 

4227 # parallel coordinator owns its per-document write scopes. 

4228 doc_info = [ 

4229 (doc.id, doc.filename or doc.title or "Unknown") 

4230 for _link, doc in _query_documents_to_index( 

4231 db_session, collection_id, force_reindex 

4232 ) 

4233 ] 

4234 

4235 if not doc_info and not force_reindex: 

4236 _update_task_status( 

4237 username, 

4238 db_password, 

4239 task_id, 

4240 status="completed", 

4241 progress_message="No documents to index", 

4242 ) 

4243 return 

4244 

4245 total = len(doc_info) 

4246 results = {"successful": 0, "skipped": 0, "failed": 0} 

4247 

4248 # Update task with total count 

4249 _update_task_status( 

4250 username, 

4251 db_password, 

4252 task_id, 

4253 progress_total=total, 

4254 progress_message=f"Indexing {total} documents", 

4255 ) 

4256 

4257 # The caller (start_background_index, which owns the 

4258 # Flask request context) resolved ``max_workers`` from 

4259 # ``rag.indexing_max_parallel_docs`` and forwarded it 

4260 # here. Workers must NOT call ``get_settings_manager()`` 

4261 # themselves — no Flask app context, pre-commit 

4262 # ``check-settings-manager-thread-safety`` would reject, 

4263 # and #3453 documents the data-loss shape that produces. 

4264 _max_workers = max(1, min(int(max_workers), 16)) 

4265 

4266 def _on_progress( 

4267 completed: int, 

4268 total: int, 

4269 title: str, 

4270 status: str, 

4271 ) -> None: 

4272 _update_task_status( 

4273 username, 

4274 db_password, 

4275 task_id, 

4276 progress_current=completed, 

4277 progress_message=(f"Indexing {completed}/{total}: {title}"), 

4278 ) 

4279 

4280 def _is_cancelled() -> bool: 

4281 return _is_task_cancelled(username, db_password, task_id) 

4282 

4283 index_documents = ( 

4284 rag_service.rebuild_collection 

4285 if force_reindex 

4286 else rag_service.index_documents_parallel 

4287 ) 

4288 aggregate = index_documents( 

4289 doc_info, 

4290 collection_id, 

4291 max_workers=_max_workers, 

4292 progress_callback=_on_progress, 

4293 is_cancelled=_is_cancelled, 

4294 ) 

4295 

4296 results["successful"] = aggregate["successful"] 

4297 results["skipped"] = aggregate["skipped"] 

4298 results["failed"] = aggregate["failed"] 

4299 results["errors"] = aggregate["errors"] 

4300 

4301 if aggregate["cancelled"]: 

4302 completed = ( 

4303 aggregate["successful"] 

4304 + aggregate["skipped"] 

4305 + aggregate["failed"] 

4306 ) 

4307 _update_task_status( 

4308 username, 

4309 db_password, 

4310 task_id, 

4311 status="cancelled", 

4312 progress_message=( 

4313 f"Cancelled after {completed}/{total} documents" 

4314 ), 

4315 ) 

4316 logger.info( 

4317 f"Indexing task {task_id} was cancelled " 

4318 f"after {completed}/{total} documents" 

4319 ) 

4320 return 

4321 

4322 # Wrap reconciliation so a transient store/DB error does not 

4323 # leave the task in "processing" forever (the previous code 

4324 # let exceptions escape, so _update_task_status never ran and 

4325 # the UI polled indefinitely — see PR #5235 review comment 

4326 # 5085604502). On failure we still record a terminal status 

4327 # so cleanup_old_tasks can reap the row. 

4328 try: 

4329 reconciliation = rag_service.reconcile_collection_index( 

4330 collection_id 

4331 ) 

4332 except Exception as exc: 

4333 logger.exception( 

4334 f"Background indexing task {task_id}: reconciliation failed" 

4335 ) 

4336 reconciliation = { 

4337 "indexed_documents": results["successful"], 

4338 "indexed_chunks": 0, 

4339 "live_vectors": 0, 

4340 "orphan_vectors": 0, 

4341 "reconciliation_skipped": True, 

4342 "reconciliation_reason": _client_reconciliation_reason(exc), 

4343 } 

4344 if not isinstance(reconciliation, dict): 

4345 reconciliation = { 

4346 "indexed_documents": results["successful"], 

4347 "indexed_chunks": 0, 

4348 "live_vectors": 0, 

4349 "orphan_vectors": 0, 

4350 } 

4351 # The reconciler returns ``reconciliation_skipped=True`` when 

4352 # live_ids is empty but chunk rows exist (refuse-to-shrink 

4353 # guard). Surface this as a task failure so the user sees the 

4354 # durable-state inconsistency instead of a misleading 

4355 # "0 indexed" success. 

4356 if isinstance(reconciliation, dict) and reconciliation.get( 

4357 "reconciliation_skipped" 

4358 ): 

4359 reason = reconciliation.get("reconciliation_reason") or ( 

4360 reconciliation.get("reason") 

4361 or "stored vectors could not be loaded" 

4362 ) 

4363 sanitized_reason = _client_reconciliation_reason(reason) 

4364 logger.warning( 

4365 f"Background indexing task {task_id}: reconciliation " 

4366 f"skipped — {sanitize_error_message(str(reason))}" 

4367 ) 

4368 _update_task_status( 

4369 username, 

4370 db_password, 

4371 task_id, 

4372 status="failed", 

4373 progress_current=total, 

4374 progress_message=( 

4375 f"Completed: {results['successful']} indexed, " 

4376 f"{results['failed']} failed, " 

4377 f"{results['skipped']} skipped; reconciliation " 

4378 f"skipped: {sanitized_reason}" 

4379 ), 

4380 error_message=_reconciliation_skipped_message( 

4381 sanitized_reason 

4382 ), 

4383 result_metadata={ 

4384 **_client_indexing_summary(results), 

4385 # Omit durable_indexed_documents/chunks and 

4386 # live_vectors/orphan_vectors: true durable state is 

4387 # unverified on the skipped path, and the UI 

4388 # suppresses its "Durable vector store: ..." sentence 

4389 # when these keys are absent (``Number.isInteger`` 

4390 # on undefined returns false). Reporting zeros here 

4391 # would imply data loss where state is merely 

4392 # unverified — see PR #5235 review comment 

4393 # 5085604502. 

4394 "reconciliation_skipped": True, 

4395 "reconciliation_reason": sanitized_reason, 

4396 "errors": _sanitized_indexing_errors(results), 

4397 }, 

4398 published_rebuild=force_reindex, 

4399 ) 

4400 return 

4401 durable_documents = reconciliation["indexed_documents"] 

4402 durable_chunks = reconciliation["indexed_chunks"] 

4403 task_result = { 

4404 **_client_indexing_summary(results), 

4405 "durable_indexed_documents": durable_documents, 

4406 "durable_indexed_chunks": durable_chunks, 

4407 "live_vectors": reconciliation["live_vectors"], 

4408 "orphan_vectors": reconciliation["orphan_vectors"], 

4409 "errors": _sanitized_indexing_errors(results), 

4410 } 

4411 has_failures = results["failed"] > 0 

4412 status = "failed" if has_failures else "completed" 

4413 message = ( 

4414 f"Completed: {results['successful']} indexed, " 

4415 f"{results['failed']} failed, {results['skipped']} skipped; " 

4416 f"durable vector store: {durable_documents} documents, " 

4417 f"{durable_chunks} chunks" 

4418 ) 

4419 _update_task_status( 

4420 username, 

4421 db_password, 

4422 task_id, 

4423 status=status, 

4424 progress_current=total, 

4425 progress_message=message, 

4426 error_message=( 

4427 _reconciled_failures_message(results["failed"]) 

4428 if has_failures 

4429 else None 

4430 ), 

4431 result_metadata=task_result, 

4432 # A forced rebuild only gets here after it has published. 

4433 published_rebuild=force_reindex, 

4434 ) 

4435 logger.info( 

4436 f"Background indexing task {task_id} completed: {results}" 

4437 ) 

4438 

4439 except RemoteVectorStoreDropError as exc: 

4440 # Caught ahead of the catch-all below: this is an actionable 

4441 # vector-store failure, not an internal error (see index_all's 

4442 # handler), and logged the same way. 

4443 drop_detail = exc.log_detail() 

4444 logger.warning( 

4445 f"Background indexing task {task_id}: force-reindex aborted, " 

4446 f"could not drop the vector-store collection before rebuilding " 

4447 f"it: {drop_detail}" 

4448 ) 

4449 _update_task_status( 

4450 username, 

4451 db_password, 

4452 task_id, 

4453 status="failed", 

4454 error_message=remote_drop_failure_message( 

4455 exc, _REMOTE_DROP_OUTCOME 

4456 ), 

4457 # Lets the status endpoint rebuild this fixed-phrase message. 

4458 failure=_remote_drop_failure(exc), 

4459 ) 

4460 except Exception as e: 

4461 logger.exception(f"Background indexing task {task_id} failed") 

4462 _update_task_status( 

4463 username, 

4464 db_password, 

4465 task_id, 

4466 status="failed", 

4467 # Always the generic text for an exception, which is also what 

4468 # the status endpoint serves for a message it cannot rebuild. The 

4469 # full exception stays in the server log above. 

4470 error_message=_client_indexing_error(e), 

4471 ) 

4472 

4473 

4474def _is_database_locked_error(exc: BaseException) -> bool: 

4475 """Return True if ``exc`` looks like a SQLite ``database is locked`` error. 

4476 

4477 Background indexing can hit transient ``database is locked`` errors when 

4478 SQLite is contended; those are recoverable via a short backoff retry. 

4479 Anything else (connection failure, integrity error, programming mistake) 

4480 should surface immediately so the outer exception handler marks the task 

4481 as failed and sets ``completed_at`` for reaping. 

4482 """ 

4483 return "database is locked" in str(exc).lower() 

4484 

4485 

4486@retry( 

4487 stop=stop_after_attempt(5), 

4488 wait=wait_exponential(multiplier=0.05), 

4489 retry=retry_if_exception(_is_database_locked_error), 

4490) 

4491def _do_update_task_status( 

4492 username: str, 

4493 db_password: str, 

4494 task_id: str, 

4495 status: str = None, 

4496 progress_current: int = None, 

4497 progress_total: int = None, 

4498 progress_message: str = None, 

4499 error_message: str = None, 

4500 result_metadata: dict = None, 

4501 failure: Optional[dict] = None, 

4502 published_rebuild: bool = False, 

4503): 

4504 """Perform the task-status update under a tenacity-managed retry loop. 

4505 

4506 Only SQLite ``database is locked`` errors are retried (up to 5 attempts 

4507 with 0.05s exponential backoff); any other exception escapes so the 

4508 outer caller can log it without retrying. See PR #5235 review comment 

4509 3669857779. 

4510 

4511 ``result_metadata`` is stored as ``metadata_json["result"]`` and 

4512 ``failure`` as ``metadata_json["failure"]`` (see 

4513 ``_client_task_failure_message``). 

4514 

4515 ``published_rebuild`` marks the final update of a forced rebuild whose 

4516 replacement index has been committed. Only that update may replace 

4517 ``cancelled``: a cancel that arrives after publication starts cannot 

4518 stop it, and ``cancelled`` would report the previous index as kept. 

4519 """ 

4520 from ...database.session_context import get_user_db_session 

4521 

4522 with get_user_db_session(username, db_password) as db_session: 

4523 task_query = db_session.query(TaskMetadata).filter_by(task_id=task_id) 

4524 # Serialize the guard and update across cancellation and worker 

4525 # connections. A SELECT alone does not start SQLite's deferred write 

4526 # transaction, and a nested cancellation scope may cache an old row. 

4527 task_query.update( 

4528 {TaskMetadata.task_id: TaskMetadata.task_id}, 

4529 synchronize_session=False, 

4530 ) 

4531 task = task_query.first() 

4532 if task: 

4533 db_session.refresh(task) 

4534 if ( 

4535 published_rebuild 

4536 and task.status == "cancelled" 

4537 and status in ("completed", "failed") 

4538 ): 

4539 progress_message = " ".join( 

4540 filter(None, (progress_message, _LATE_CANCEL_NOTE)) 

4541 ) 

4542 # Preserve terminal states: once completed, cancelled or failed, only 

4543 # updates confirming that same state are allowed. 

4544 elif task.status in ("completed", "cancelled", "failed") and ( 

4545 status != task.status 

4546 ): 

4547 db_session.commit() 

4548 return 

4549 elif status == "completed" and task.status != "processing": 

4550 db_session.commit() 

4551 return 

4552 if status is not None: 

4553 task.status = status 

4554 # Set completed_at for ALL terminal statuses so 

4555 # ``cleanup_old_tasks`` (which filters on 

4556 # ``status in ["completed", "failed", "cancelled"] AND 

4557 # completed_at < cutoff_date``) can reap them. 

4558 # Previously only "completed" set the timestamp, 

4559 # leaving failed/cancelled rows permanent. 

4560 if status in ("completed", "failed", "cancelled"): 4560 ↛ 4562line 4560 didn't jump to line 4562 because the condition on line 4560 was always true

4561 task.completed_at = datetime.now(UTC) 

4562 if progress_current is not None: 

4563 task.progress_current = progress_current 

4564 if progress_total is not None: 

4565 task.progress_total = progress_total 

4566 if progress_message is not None: 

4567 task.progress_message = progress_message 

4568 if error_message is not None: 

4569 task.error_message = error_message 

4570 if result_metadata is not None or failure is not None: 

4571 metadata = dict(task.metadata_json or {}) 

4572 if result_metadata is not None: 

4573 metadata["result"] = result_metadata 

4574 if failure is not None: 

4575 metadata["failure"] = failure 

4576 task.metadata_json = metadata 

4577 db_session.commit() 

4578 

4579 

4580def _update_task_status( 

4581 username: str, 

4582 db_password: str, 

4583 task_id: str, 

4584 status: str = None, 

4585 progress_current: int = None, 

4586 progress_total: int = None, 

4587 progress_message: str = None, 

4588 error_message: str = None, 

4589 result_metadata: dict = None, 

4590 failure: Optional[dict] = None, 

4591 published_rebuild: bool = False, 

4592): 

4593 """Update task metadata in the database.""" 

4594 try: 

4595 _do_update_task_status( 

4596 username, 

4597 db_password, 

4598 task_id, 

4599 status=status, 

4600 progress_current=progress_current, 

4601 progress_total=progress_total, 

4602 progress_message=progress_message, 

4603 error_message=error_message, 

4604 result_metadata=result_metadata, 

4605 failure=failure, 

4606 published_rebuild=published_rebuild, 

4607 ) 

4608 except Exception: 

4609 logger.exception(f"Failed to update task status for {task_id}") 

4610 

4611 

4612def _is_task_cancelled(username: str, db_password: str, task_id: str) -> bool: 

4613 """Check if a task has been cancelled.""" 

4614 from ...database.session_context import get_user_db_session 

4615 

4616 try: 

4617 with get_user_db_session(username, db_password) as db_session: 

4618 task = ( 

4619 db_session.query(TaskMetadata) 

4620 .filter_by(task_id=task_id) 

4621 .first() 

4622 ) 

4623 return task and task.status == "cancelled" 

4624 except Exception: 

4625 logger.warning( 

4626 "Could not check cancellation status for task {}", task_id 

4627 ) 

4628 return False 

4629 

4630 

4631@router.post("/api/collections/{collection_id}/index/start") 

4632async def start_background_index( 

4633 request: Request, 

4634 collection_id, 

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

4636): 

4637 """Start background indexing for a collection.""" 

4638 from ...database.session_passwords import session_password_store 

4639 

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

4641 

4642 # Get password for thread access 

4643 db_password = None 

4644 if session_id: 

4645 db_password = session_password_store.get_session_password( 

4646 username, session_id 

4647 ) 

4648 

4649 # The shared parser rejects both malformed and non-object bodies before 

4650 # they reach the indexing controls below. 

4651 data, body_error = await read_json_dict( 

4652 request, "success", "Request body must be valid JSON" 

4653 ) 

4654 if body_error is not None: 

4655 return body_error 

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

4657 if not isinstance(force_reindex, bool): 

4658 return JSONResponse( 

4659 { 

4660 "success": False, 

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

4662 }, 

4663 status_code=400, 

4664 ) 

4665 

4666 # The lock acquisition and SQLCipher session open below are blocking — 

4667 # run the whole check-and-create + thread spawn in the threadpool so a 

4668 # contended lock or a cold-engine PBKDF2 derivation can't stall the 

4669 # event loop (contextvars propagate through to_thread, so the 

4670 # copy_context() for the worker still sees this request's user). 

4671 return await run_db_sync( 

4672 _start_background_index_sync, 

4673 collection_id, 

4674 username, 

4675 db_password, 

4676 force_reindex, 

4677 ) 

4678 

4679 

4680def _start_background_index_sync( 

4681 collection_id, username, db_password, force_reindex 

4682): 

4683 from ...database.session_context import get_user_db_session 

4684 

4685 # Serialize check-and-create per (user, collection) to close the TOCTOU 

4686 # window between scanning for an in-progress task and inserting a new 

4687 # row. Two concurrent threads would otherwise both see no match and 

4688 # both spawn an indexer on the same collection, racing FAISS writes. 

4689 # Single-worker scope only (matches the rest of this module's locking 

4690 # model; multi-worker deployments already documented as unsupported 

4691 # for this kind of coordination). 

4692 lock_key = (username, str(collection_id)) 

4693 lock = _start_bg_index_locks.setdefault(lock_key, threading.Lock()) 

4694 

4695 task_id = None 

4696 try: 

4697 with lock, get_user_db_session(username, db_password) as db_session: 

4698 conflict = _indexing_conflict(db_session, username, collection_id) 

4699 if conflict: 

4700 return JSONResponse(conflict, status_code=409) 

4701 

4702 # Preserve the caller's provider snapshot for force-reindex. 

4703 # Staged rebuilds currently refuse remote providers, but the 

4704 # background-worker interface still accepts this context. 

4705 # Captured here, on the request-scoped session, because 

4706 # the worker thread must not build one -- and BEFORE the task row 

4707 # is committed, so a failure cannot leave a stuck 'processing' 

4708 # task behind. Read strictly and not swallowed: a defaults-only 

4709 # snapshot (or None) would silently resolve the global default 

4710 # provider instead of the configured backend (see 

4711 # read_vector_cleanup_settings_snapshot). 

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

4713 read_vector_cleanup_settings_snapshot, 

4714 ) 

4715 

4716 _bg_settings_snapshot = ( 

4717 read_vector_cleanup_settings_snapshot( 

4718 get_settings_manager(db_session, username) 

4719 ) 

4720 if force_reindex 

4721 else None 

4722 ) 

4723 

4724 # Resolve per-job parallel fan-out before committing the task 

4725 # (request-scoped session) and forward into the worker, which 

4726 # runs on a background thread where the no-arg 

4727 # ``get_settings_manager()`` is unsafe (#3453). 

4728 try: 

4729 _bg_settings = get_settings_manager(db_session, username) 

4730 _bg_max_workers = int( 

4731 _bg_settings.get_setting( 

4732 "rag.indexing_max_parallel_docs", 4 

4733 ) 

4734 ) 

4735 except SettingsValueError: 

4736 raise 

4737 except Exception: 

4738 _bg_max_workers = 4 

4739 _bg_max_workers = max(1, min(_bg_max_workers, 16)) 

4740 

4741 # Create new task 

4742 task_id = str(uuid.uuid4()) 

4743 task = TaskMetadata( 

4744 task_id=task_id, 

4745 status="processing", 

4746 task_type="indexing", 

4747 created_at=datetime.now(UTC), 

4748 started_at=datetime.now(UTC), 

4749 progress_current=0, 

4750 progress_total=0, 

4751 progress_message="Starting collection rebuild..." 

4752 if force_reindex 

4753 else "Starting indexing...", 

4754 metadata_json={ 

4755 "collection_id": collection_id, 

4756 "force_reindex": force_reindex, 

4757 }, 

4758 ) 

4759 db_session.add(task) 

4760 db_session.commit() 

4761 with _active_sse_indexers_lock: 

4762 _active_background_indexers[lock_key] = task_id 

4763 

4764 # Start background thread. Copy the request's contextvars so 

4765 # any service code inside the worker that calls 

4766 # `get_current_username()` sees the actual user instead of None. 

4767 _bg_ctx = contextvars.copy_context() 

4768 

4769 def _ctx_worker(): 

4770 try: 

4771 _bg_ctx.run( 

4772 _background_index_worker, 

4773 task_id, 

4774 collection_id, 

4775 username, 

4776 db_password, 

4777 force_reindex, 

4778 _bg_max_workers, 

4779 settings_snapshot=_bg_settings_snapshot, 

4780 ) 

4781 finally: 

4782 _unregister_background_indexer(username, collection_id, task_id) 

4783 

4784 thread = threading.Thread(target=_ctx_worker, daemon=True) 

4785 thread.start() 

4786 

4787 logger.info( 

4788 f"Started background indexing task {task_id} for collection {_log_value_preview(collection_id)}" 

4789 ) 

4790 

4791 return { 

4792 "success": True, 

4793 "task_id": task_id, 

4794 "message": "Indexing started in background", 

4795 } 

4796 

4797 except Exception: 

4798 logger.exception("Failed to start background indexing") 

4799 if task_id is not None: 

4800 _update_task_status( 

4801 username, 

4802 db_password, 

4803 task_id, 

4804 status="failed", 

4805 error_message="Failed to start indexing. Please try again.", 

4806 ) 

4807 _unregister_background_indexer(username, collection_id, task_id) 

4808 return JSONResponse( 

4809 { 

4810 "success": False, 

4811 "error": "Failed to start indexing. Please try again.", 

4812 }, 

4813 status_code=500, 

4814 ) 

4815 

4816 

4817@router.get("/api/collections/{collection_id}/index/status") 

4818@limiter.exempt 

4819def get_index_status( 

4820 request: Request, 

4821 collection_id, 

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

4823): 

4824 """Get the current indexing status for a collection.""" 

4825 from ...database.session_context import get_user_db_session 

4826 from ...database.session_passwords import session_password_store 

4827 

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

4829 

4830 db_password = None 

4831 if session_id: 

4832 db_password = session_password_store.get_session_password( 

4833 username, session_id 

4834 ) 

4835 

4836 # Observe admission before opening the read transaction. If the worker 

4837 # finishes between these reads, report active for one extra poll rather 

4838 # than combine an old cancelled task snapshot with a now-inactive worker. 

4839 with _active_sse_indexers_lock: 

4840 active_task_id = _active_background_indexers.get( 

4841 (username, str(collection_id)) 

4842 ) 

4843 

4844 try: 

4845 with get_user_db_session(username, db_password) as db_session: 

4846 # Find the most recent indexing task FOR THIS COLLECTION. 

4847 # 

4848 # collection_id is stored inside the metadata_json JSON column, so 

4849 # we can't portably filter on it in SQL (SQLite/SQLCipher JSON 

4850 # support varies). Instead, scan recent indexing tasks newest-first 

4851 # and return the first whose metadata.collection_id matches. This 

4852 # is scoped per collection: a newer indexing task for a DIFFERENT 

4853 # collection no longer makes this one falsely report "idle". 

4854 # 

4855 # This endpoint is polled every ~2s during a reindex and the 

4856 # indexing-task table is never pruned, so an unbounded .all() would 

4857 # materialize the entire history on every poll (and task_type / 

4858 # created_at are unindexed). Bound the scan to the newest N tasks. 

4859 # The task we're looking for was just created by the reindex that 

4860 # started this poll, so it sits near the top; N=200 is large enough 

4861 # that concurrent reindexes of other collections can't push it out 

4862 # of the window before it terminates. Trade-off: if more than ~200 

4863 # newer indexing tasks for OTHER collections appear while this one 

4864 # is still in-flight, the scoped lookup falls back to "idle". 

4865 recent_task_scan_limit = 200 

4866 recent_tasks = ( 

4867 db_session.query(TaskMetadata) 

4868 .filter(TaskMetadata.task_type == "indexing") 

4869 .order_by(TaskMetadata.created_at.desc()) 

4870 .limit(recent_task_scan_limit) 

4871 .all() 

4872 ) 

4873 

4874 task = None 

4875 for candidate in recent_tasks: 

4876 metadata = candidate.metadata_json or {} 

4877 if metadata.get("collection_id") == collection_id: 

4878 task = candidate 

4879 break 

4880 

4881 collection = db_session.get(Collection, collection_id) 

4882 index = ( 

4883 db_session.query(RAGIndex) 

4884 .filter_by( 

4885 collection_name=f"collection_{collection_id}", 

4886 is_current=True, 

4887 ) 

4888 .first() 

4889 ) 

4890 rebuild_info = _collection_rebuild_info(collection, index) 

4891 

4892 if not task: 

4893 return { 

4894 **rebuild_info, 

4895 "status": "idle", 

4896 "collection_id": collection_id, 

4897 "message": "No indexing task for this collection", 

4898 } 

4899 

4900 task_metadata = task.metadata_json or {} 

4901 safe_result = _client_indexing_result(task_metadata.get("result")) 

4902 worker_active = active_task_id == task.task_id 

4903 return { 

4904 **rebuild_info, 

4905 "task_id": task.task_id, 

4906 "force_reindex": task_metadata.get("force_reindex") is True, 

4907 # A cancellation request can precede drain or an indivisible 

4908 # publication. Clients must observe the worker's final result. 

4909 "worker_active": worker_active, 

4910 "collection_id": collection_id, 

4911 "status": task.status, 

4912 "progress_current": task.progress_current or 0, 

4913 "progress_total": task.progress_total or 0, 

4914 "progress_message": ( 

4915 "Indexing failed" 

4916 if task.status == "failed" 

4917 else task.progress_message 

4918 ), 

4919 "error_message": _client_indexing_task_error( 

4920 task.error_message, 

4921 safe_result, 

4922 task_metadata.get("failure"), 

4923 ), 

4924 "result": safe_result, 

4925 "created_at": task.created_at.isoformat() 

4926 if task.created_at 

4927 else None, 

4928 "completed_at": task.completed_at.isoformat() 

4929 if task.completed_at 

4930 else None, 

4931 } 

4932 

4933 except Exception: 

4934 logger.exception("Failed to get index status") 

4935 return JSONResponse( 

4936 { 

4937 "status": "error", 

4938 "error": "Failed to get indexing status. Please try again.", 

4939 }, 

4940 status_code=500, 

4941 ) 

4942 

4943 

4944@router.post("/api/collections/{collection_id}/index/cancel") 

4945def cancel_indexing( 

4946 request: Request, 

4947 collection_id, 

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

4949): 

4950 """Cancel an active indexing task for a collection.""" 

4951 from ...database.session_context import get_user_db_session 

4952 from ...database.session_passwords import session_password_store 

4953 

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

4955 

4956 db_password = None 

4957 if session_id: 

4958 db_password = session_password_store.get_session_password( 

4959 username, session_id 

4960 ) 

4961 

4962 try: 

4963 # Signal active process-local SSE generator(s) if present 

4964 sse_cancelled = False 

4965 with _active_sse_indexers_lock: 

4966 sse_events = _active_sse_indexers.get((username, collection_id)) 

4967 if sse_events: 

4968 for sse_event in sse_events: 

4969 sse_event.set() 

4970 sse_cancelled = True 

4971 logger.info( 

4972 f"Signalled active SSE indexing event(s) for collection {collection_id}" 

4973 ) 

4974 

4975 with get_user_db_session(username, db_password) as db_session: 

4976 # Find active indexing task for this collection 

4977 tasks = ( 

4978 db_session.query(TaskMetadata) 

4979 .filter( 

4980 TaskMetadata.task_type == "indexing", 

4981 TaskMetadata.status == "processing", 

4982 ) 

4983 .all() 

4984 ) 

4985 

4986 # Another collection's earlier task must not hide this one. 

4987 # Match the same collection metadata used by index/start. 

4988 matched_task = next( 

4989 ( 

4990 task 

4991 for task in tasks 

4992 if (task.metadata_json or {}).get("collection_id") 

4993 == collection_id 

4994 ), 

4995 None, 

4996 ) 

4997 

4998 if not matched_task and not sse_cancelled: 

4999 if tasks: 

5000 return JSONResponse( 

5001 { 

5002 "success": False, 

5003 "error": "No active indexing task for this collection", 

5004 }, 

5005 status_code=404, 

5006 ) 

5007 return JSONResponse( 

5008 { 

5009 "success": False, 

5010 "error": "No active indexing task found", 

5011 }, 

5012 status_code=404, 

5013 ) 

5014 

5015 if matched_task: 

5016 # This endpoint must surface a failed cancellation write. 

5017 # Background workers use the best-effort wrapper, but returning 

5018 # success here after it swallowed an error would mislead the 

5019 # caller while the task remains active. 

5020 task_id_to_cancel = matched_task.task_id 

5021 _do_update_task_status( 

5022 username, 

5023 db_password, 

5024 task_id_to_cancel, 

5025 status="cancelled", 

5026 progress_message="Cancellation requested...", 

5027 ) 

5028 

5029 logger.info( 

5030 f"Requested cancellation of indexing task {task_id_to_cancel} for collection {collection_id}" 

5031 ) 

5032 

5033 return { 

5034 "success": True, 

5035 "message": "Cancellation requested", 

5036 "task_id": matched_task.task_id if matched_task else None, 

5037 } 

5038 

5039 except Exception: 

5040 logger.exception("Failed to cancel indexing") 

5041 return JSONResponse( 

5042 { 

5043 "success": False, 

5044 "error": "Failed to cancel indexing. Please try again.", 

5045 }, 

5046 status_code=500, 

5047 ) 

5048 

5049 

5050# Research History Semantic Search Routes have been moved to 

5051# web/routers/library_search.py