Coverage for src/local_deep_research/web/routes/unified_search_routes.py: 95%

135 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1""" 

2Unified Search Routes — "Search everything". 

3 

4One search surface across every entity stored as a ``Document`` row 

5(notes, research reports, library downloads, user uploads): 

6 

7* GET /library/search/ — the Search page 

8* GET /library/search/api/keyword — ILIKE over title + text_content 

9* GET /library/search/api/semantic — FAISS search over the system 

10 collections (default_library, research_history, notes) 

11 

12The two API legs return the SAME result-row contract so the page JS can 

13merge them mode-agnostically:: 

14 

15 {id, title, content_preview, source_type, url, similarity?} 

16 

17``url`` is computed server-side (see ``_document_url``) so every future 

18consumer links results consistently. 

19""" 

20 

21import math 

22 

23from flask import Blueprint, jsonify, request, session 

24 

25from ...research_library.utils import escape_like, handle_api_error 

26from ...security.rate_limiter import limiter, _get_api_user_key 

27from ..auth.decorators import login_required 

28from ..utils.templates import render_template_with_defaults 

29 

30# MAX_SEARCH_LEN (the user-query cap into ILIKE + the embedding call) is 

31# shared with notes_routes via _search_constants so the two route modules 

32# can't diverge. 

33from ._search_constants import MAX_SEARCH_LEN 

34 

35# Keyword-leg preview length: column-projected ``substr`` so the full 

36# text_content (which can be megabytes for extracted PDFs) is never 

37# hydrated into the response. 

38PREVIEW_LEN = 300 

39# Lead-in shown before the first content match when the preview window is 

40# anchored on it (see keyword_search). 

41PREVIEW_CONTEXT = 60 

42DEFAULT_LIMIT = 20 

43MAX_LIMIT = 50 

44DEFAULT_MIN_SIMILARITY = 0.25 

45 

46# Minimum query length, matching the page JS's UNIFIED_SEARCH_MIN_QUERY: 

47# below 2 chars the keyword leg is an expensive full-content scan for a 

48# near-meaningless match set, and the client never sends one anyway. 

49MIN_SEARCH_LEN = 2 

50 

51# System collections always searched by the semantic leg, keyed by 

52# ``Collection.collection_type``. These are lazily created elsewhere 

53# (library init / first note), so a missing one is SKIPPED rather than 

54# created — a read endpoint must not have write side effects. User 

55# collections join them whenever they have a current RAG index (see 

56# semantic_search) so the two legs cover the same corpus for anything 

57# the user actually indexed. 

58SEMANTIC_COLLECTION_TYPES = ("default_library", "research_history", "notes") 

59 

60# Per-user search budget — same shape as the notes_search bucket (cheap 

61# DB/FAISS lookups typed through a debounced search box). 

62_unified_search_limit = limiter.shared_limit( 

63 "60 per minute", scope="unified_search", key_func=_get_api_user_key 

64) 

65 

66unified_search_bp = Blueprint( 

67 "unified_search", __name__, url_prefix="/library/search" 

68) 

69 

70 

71def _document_url(source_type_name, document_id, research_id): 

72 """Canonical link for a search hit, by source type. 

73 

74 * ``note`` → the note editor. 

75 * ``research_report`` with a ``research_id`` → the research results 

76 page (the report Document is a search-index copy of that run). 

77 * everything else (uploads, downloads, report copies whose run is 

78 gone) → the library document details page. 

79 """ 

80 if source_type_name == "note": 

81 return f"/notes/{document_id}" 

82 if source_type_name == "research_report" and research_id: 

83 return f"/results/{research_id}" 

84 return f"/library/document/{document_id}" 

85 

86 

87def _validated_query_and_limit(): 

88 """Parse/validate the shared ``q`` and ``limit`` query params. 

89 

90 Returns ``(query, limit, error_response)`` where ``error_response`` 

91 is a ready ``(json, status)`` tuple when validation failed (and the 

92 other fields are None). 

93 """ 

94 q = request.args.get("q", "") 

95 if len(q) > MAX_SEARCH_LEN: 

96 return ( 

97 None, 

98 None, 

99 ( 

100 jsonify( 

101 { 

102 "success": False, 

103 "error": f"q exceeds maximum length ({MAX_SEARCH_LEN} chars)", 

104 } 

105 ), 

106 400, 

107 ), 

108 ) 

109 q = q.strip() 

110 if not q: 

111 return ( 

112 None, 

113 None, 

114 (jsonify({"success": False, "error": "Query is required"}), 400), 

115 ) 

116 if len(q) < MIN_SEARCH_LEN: 

117 return ( 

118 None, 

119 None, 

120 ( 

121 jsonify( 

122 { 

123 "success": False, 

124 "error": ( 

125 f"Query must be at least {MIN_SEARCH_LEN} " 

126 "characters" 

127 ), 

128 } 

129 ), 

130 400, 

131 ), 

132 ) 

133 try: 

134 limit = max( 

135 1, min(int(request.args.get("limit", DEFAULT_LIMIT)), MAX_LIMIT) 

136 ) 

137 except (ValueError, TypeError): 

138 return ( 

139 None, 

140 None, 

141 (jsonify({"success": False, "error": "Invalid limit"}), 400), 

142 ) 

143 return q, limit, None 

144 

145 

146# ============================================================================ 

147# Page Route 

148# ============================================================================ 

149 

150 

151@unified_search_bp.route("/") 

152@login_required 

153def unified_search_page(): 

154 """Render the unified Search page.""" 

155 return render_template_with_defaults( 

156 "pages/unified_search.html", active_page="unified-search" 

157 ) 

158 

159 

160# ============================================================================ 

161# API Routes 

162# ============================================================================ 

163 

164 

165@unified_search_bp.route("/api/keyword", methods=["GET"]) 

166@login_required 

167@_unified_search_limit 

168def keyword_search(): 

169 """Keyword leg: ILIKE over Document.title + text_content, ALL source types. 

170 

171 Query params: 

172 q: Search query (required, capped at MAX_SEARCH_LEN) 

173 limit: Maximum results (default 20, clamped 1..50) 

174 

175 Column-projected (id, title, 300-char preview, source-type name, 

176 updated_at, research_id) — never hydrates full text_content. 

177 """ 

178 username = session.get("username") 

179 if not username: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true

180 return jsonify({"success": False, "error": "Not authenticated"}), 401 

181 

182 try: 

183 q, limit, error = _validated_query_and_limit() 

184 if error: 

185 return error 

186 

187 from sqlalchemy import case, func, or_ 

188 

189 from ...database.models.library import Document, SourceType 

190 from ...database.session_context import get_user_db_session 

191 

192 pattern = f"%{escape_like(q)}%" 

193 # Center the preview on the first content match instead of always 

194 # slicing the head: for a content hit deep in a long document, the 

195 # first 300 chars usually don't contain the matched text at all, 

196 # so the card gave no clue why the document matched. instr() takes 

197 # the raw query (wildcard escaping only applies to LIKE), folded 

198 # with lower() on both sides exactly like ilike; 0 = title-only 

199 # match → head preview. PREVIEW_CONTEXT chars of lead-in keep the 

200 # match readable in situ. 

201 match_pos = func.instr(func.lower(Document.text_content), func.lower(q)) 

202 preview_start = case( 

203 (match_pos > PREVIEW_CONTEXT, match_pos - PREVIEW_CONTEXT), 

204 else_=1, 

205 ) 

206 with get_user_db_session(username) as db: 

207 rows = ( 

208 db.query( 

209 Document.id, 

210 Document.title, 

211 func.substr( 

212 Document.text_content, preview_start, PREVIEW_LEN 

213 ), 

214 SourceType.name, 

215 Document.updated_at, 

216 Document.research_id, 

217 match_pos, 

218 ) 

219 .join(SourceType, Document.source_type_id == SourceType.id) 

220 .filter( 

221 # Only surface completed documents, matching every 

222 # library listing/search path (library_service filters 

223 # status == 'completed' too). Without this, in-progress 

224 # or failed downloads — partial/empty text, or a stale 

225 # title — appear as hits that link to a document page 

226 # with no usable content. 

227 Document.status == "completed", 

228 or_( 

229 Document.title.ilike(pattern, escape="\\"), 

230 Document.text_content.ilike(pattern, escape="\\"), 

231 ), 

232 ) 

233 .order_by(Document.updated_at.desc()) 

234 .limit(limit) 

235 .all() 

236 ) 

237 

238 results = [] 

239 for ( 

240 doc_id, 

241 title, 

242 preview, 

243 source_type_name, 

244 updated_at, 

245 research_id, 

246 pos, 

247 ) in rows: 

248 preview = preview or "" 

249 # Mark the cut ONLY when the window truly opens mid-document, i.e. 

250 # preview_start = pos - PREVIEW_CONTEXT > 1, which is pos > 

251 # PREVIEW_CONTEXT + 1. At exactly pos == PREVIEW_CONTEXT + 1 the SQL 

252 # clamps preview_start to 1 (the document's first char), so the 

253 # preview isn't truncated and a leading "…" would be spurious. 

254 if pos and pos > PREVIEW_CONTEXT + 1: 

255 preview = "…" + preview 

256 results.append( 

257 { 

258 "id": doc_id, 

259 "title": title or "Untitled", 

260 "content_preview": preview, 

261 "source_type": source_type_name, 

262 "updated_at": updated_at.isoformat() 

263 if updated_at 

264 else None, 

265 "research_id": research_id, 

266 "url": _document_url(source_type_name, doc_id, research_id), 

267 } 

268 ) 

269 

270 return jsonify( 

271 { 

272 "success": True, 

273 "query": q, 

274 "results": results, 

275 "count": len(results), 

276 } 

277 ) 

278 

279 except Exception as e: 

280 return handle_api_error("unified keyword search", e) 

281 

282 

283@unified_search_bp.route("/api/semantic", methods=["GET"]) 

284@login_required 

285@_unified_search_limit 

286def semantic_search(): 

287 """Semantic leg: FAISS search over the system collections. 

288 

289 Query params: 

290 q: Search query (required, capped at MAX_SEARCH_LEN) 

291 limit: Maximum results (default 20, clamped 1..50) 

292 min_similarity: Minimum similarity 0.0-1.0 (default 0.25) 

293 

294 Searches the RAG index of every existing system collection 

295 (default_library, research_history, notes — missing ones are 

296 skipped, not created) and merges the hits across collections by 

297 similarity desc, one result per document (its best-scoring chunk). 

298 

299 Infrastructure failures PROPAGATE as a 500 via ``handle_api_error`` 

300 — an outage must not masquerade as "no matches" (repo rule: no 

301 silent fallbacks). The page's hybrid mode degrades per leg 

302 client-side, where the degradation is surfaced as a notice. 

303 """ 

304 username = session.get("username") 

305 if not username: 305 ↛ 306line 305 didn't jump to line 306 because the condition on line 305 was never true

306 return jsonify({"success": False, "error": "Not authenticated"}), 401 

307 

308 try: 

309 q, limit, error = _validated_query_and_limit() 

310 if error: 

311 return error 

312 try: 

313 min_similarity_raw = float( 

314 request.args.get("min_similarity", DEFAULT_MIN_SIMILARITY) 

315 ) 

316 # Reject nan/inf explicitly: float("nan") does not raise, and 

317 # max(0.0, min(nan, 1.0)) silently collapses to 0.0 (the most 

318 # permissive threshold) instead of surfacing the bad input. 

319 if not math.isfinite(min_similarity_raw): 

320 raise ValueError( # noqa: TRY301 — caught just below and mapped to a 400 

321 "min_similarity must be a finite number" 

322 ) 

323 min_similarity = max(0.0, min(min_similarity_raw, 1.0)) 

324 except (ValueError, TypeError): 

325 return jsonify( 

326 {"success": False, "error": "Invalid min_similarity"} 

327 ), 400 

328 

329 from ...database.models.library import ( 

330 Collection, 

331 Document, 

332 RAGIndex, 

333 SourceType, 

334 ) 

335 from ...database.session_context import get_user_db_session 

336 

337 # Resolve the searchable collections: the system collections that 

338 # exist, plus ANY other collection with a current RAG index. The 

339 # keyword leg covers every document, so scoping the semantic leg 

340 # to system collections only made the two modes cover different 

341 # corpora — documents living solely in an indexed user collection 

342 # were keyword-searchable but never AI-searchable. Unindexed 

343 # collections are harmless to include (the engine returns [] when 

344 # no current index exists), but filtering here avoids paying its 

345 # per-collection setup for collections that can't match. 

346 with get_user_db_session(username) as db: 

347 all_collections = db.query( 

348 Collection.id, 

349 Collection.name, 

350 Collection.collection_type, 

351 ).all() 

352 indexed_names = { 

353 name 

354 for (name,) in db.query(RAGIndex.collection_name) 

355 .filter(RAGIndex.is_current.is_(True)) 

356 .all() 

357 } 

358 collections = [ 

359 (collection_id, collection_name) 

360 for collection_id, collection_name, collection_type in all_collections 

361 if collection_type in SEMANTIC_COLLECTION_TYPES 

362 or f"collection_{collection_id}" in indexed_names 

363 ] 

364 

365 if not collections: 

366 return jsonify( 

367 {"success": True, "query": q, "results": [], "count": 0} 

368 ) 

369 

370 from ...web_search_engines.engines.search_engine_collection import ( 

371 CollectionSearchEngine, 

372 ) 

373 

374 # Over-fetch chunk-level hits per collection: documents are 

375 # chunk-indexed, so one long document can occupy several top-k 

376 # slots before the per-document dedup below. 

377 fetch_k = limit * 3 

378 hits = [] 

379 for collection_id, collection_name in collections: 

380 engine = CollectionSearchEngine( 

381 collection_id=collection_id, 

382 collection_name=collection_name, 

383 max_results=fetch_k, 

384 settings_snapshot={"_username": username}, 

385 ) 

386 # Engine failures propagate to handle_api_error below. 

387 raw_results = engine.search(q, limit=fetch_k) 

388 for r in raw_results or []: 

389 score = r.get("relevance_score", 0) 

390 if score < min_similarity: 

391 continue 

392 meta = r.get("metadata", {}) or {} 

393 doc_id = meta.get("document_id") or meta.get("source_id") 

394 if not doc_id: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true

395 continue 

396 hits.append( 

397 { 

398 "id": doc_id, 

399 "title": r.get("title", "Untitled"), 

400 "content_preview": (r.get("snippet", "") or "")[ 

401 :PREVIEW_LEN 

402 ], 

403 "similarity": round(float(score), 3), 

404 } 

405 ) 

406 

407 # Merge across collections by similarity desc; keep each 

408 # document's best-scoring chunk only. Dedup the WHOLE pool (not 

409 # just the first `limit`) so the existence check below can backfill 

410 # from lower-ranked valid hits when a top hit turns out to be a 

411 # FAISS orphan — otherwise a handful of orphans in the top `limit` 

412 # silently shrink the result set even though enough valid matches 

413 # were fetched. 

414 hits.sort(key=lambda h: h["similarity"], reverse=True) 

415 candidates = [] 

416 seen_doc_ids = set() 

417 for hit in hits: 

418 if hit["id"] in seen_doc_ids: 

419 continue 

420 seen_doc_ids.add(hit["id"]) 

421 candidates.append(hit) 

422 

423 # Enrich with the Document row (source type + research_id → url). 

424 # A candidate absent from the lookup is a FAISS orphan — the 

425 # document was deleted but its vectors linger until reindex — so 

426 # drop it rather than render a link that 404s, and only cut to 

427 # `limit` AFTER dropping orphans. 

428 results = [] 

429 if candidates: 

430 with get_user_db_session(username) as db: 

431 rows = ( 

432 db.query(Document.id, SourceType.name, Document.research_id) 

433 .join(SourceType, Document.source_type_id == SourceType.id) 

434 .filter( 

435 Document.id.in_([h["id"] for h in candidates]), 

436 # Match the keyword leg: don't surface in-progress 

437 # or failed documents even if their vectors exist. 

438 Document.status == "completed", 

439 ) 

440 .all() 

441 ) 

442 doc_info = { 

443 doc_id: (source_type_name, research_id) 

444 for doc_id, source_type_name, research_id in rows 

445 } 

446 for hit in candidates: 

447 info = doc_info.get(hit["id"]) 

448 if info is None: 

449 continue 

450 source_type_name, research_id = info 

451 hit["source_type"] = source_type_name 

452 hit["url"] = _document_url( 

453 source_type_name, hit["id"], research_id 

454 ) 

455 results.append(hit) 

456 if len(results) >= limit: 

457 break 

458 

459 return jsonify( 

460 { 

461 "success": True, 

462 "query": q, 

463 "results": results, 

464 "count": len(results), 

465 } 

466 ) 

467 

468 except Exception as e: 

469 return handle_api_error("unified semantic search", e)