Coverage for src/local_deep_research/search_system.py: 91%

126 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1# src/local_deep_research/search_system.py 

2from typing import Any, Callable, Dict 

3 

4from langchain_core.language_models import BaseChatModel 

5from loguru import logger 

6 

7from .advanced_search_system.findings.repository import FindingsRepository 

8from .advanced_search_system.questions.standard_question import ( 

9 StandardQuestionGenerator, 

10) 

11from .advanced_search_system.strategies.followup.enhanced_contextual_followup import ( 

12 EnhancedContextualFollowUpStrategy, 

13) 

14 

15from .utilities.type_utils import unwrap_setting 

16from .citation_handler import CitationHandler 

17from .web_search_engines.search_engine_base import BaseSearchEngine 

18from .constants import DEFAULT_SEARCH_TOOL 

19 

20 

21def _ensure_snapshot_username(settings_snapshot, username): 

22 """Ensure the run's settings snapshot carries the username under the 

23 ``_username`` key that snapshot-driven consumers look it up by. 

24 

25 The LangGraph agent re-instantiates the search engine *per tool call* 

26 from this snapshot (``_make_web_search_tool`` -> ``create_search_engine``), 

27 and registering a user's document collections needs the username 

28 (``get_user_db_session``), which collection registration reads only from 

29 ``settings_snapshot["_username"]``. The snapshot is built without it (the 

30 user lives under ``system.user``), so without this a collection/library 

31 primary fails inside the agent with "Unknown search engine 'collection_…'". 

32 

33 Injected here in ``AdvancedSearchSystem.__init__`` — the narrowest common 

34 consumer of the strategy-running paths — so the web run and the 

35 programmatic API are repaired in one place. (No-ops when ``username`` is 

36 absent, e.g. benchmarks/news, which don't use per-user collections anyway.) 

37 Mirrors the convention ``get_search()`` already applies to its own local 

38 snapshot copy. 

39 

40 Returns the snapshot unchanged when there is no username, it is not a dict, 

41 or ``_username`` is already set (never overwrites an explicit value); 

42 otherwise a shallow copy with ``_username`` added (no mutation). 

43 """ 

44 if ( 

45 username 

46 and isinstance(settings_snapshot, dict) 

47 and not settings_snapshot.get("_username") 

48 ): 

49 return {**settings_snapshot, "_username": username} 

50 return settings_snapshot 

51 

52 

53class AdvancedSearchSystem: 

54 """ 

55 Advanced search system that coordinates different search strategies. 

56 """ 

57 

58 def __init__( 

59 self, 

60 llm: BaseChatModel, 

61 search: BaseSearchEngine, 

62 strategy_name: str = "source-based", # Default to comprehensive research strategy 

63 include_text_content: bool = True, 

64 use_cross_engine_filter: bool = True, 

65 max_iterations: int | None = None, 

66 questions_per_iteration: int | None = None, 

67 use_atomic_facts: bool = False, 

68 username: str | None = None, 

69 settings_snapshot: dict | None = None, 

70 research_id: str | None = None, 

71 research_context: dict | None = None, 

72 programmatic_mode: bool = False, 

73 search_original_query: bool = True, 

74 ): 

75 """Initialize the advanced search system. 

76 

77 Args: 

78 llm: LLM to use for the search strategy. 

79 search: Search engine to use for queries. 

80 strategy_name: The name of the search strategy to use. Options: 

81 - "standard": Basic iterative search strategy 

82 - "iterdrag": Iterative Dense Retrieval Augmented Generation 

83 - "source-based": Focuses on finding and extracting from sources 

84 - "parallel": Runs multiple search queries in parallel 

85 - "rapid": Quick single-pass search 

86 - "recursive": Recursive decomposition of complex queries 

87 - "iterative": Loop-based reasoning with persistent knowledge 

88 - "adaptive": Adaptive step-by-step reasoning 

89 - "smart": Automatically chooses best strategy based on query 

90 - "browsecomp": Optimized for BrowseComp-style puzzle queries 

91 - "evidence": Enhanced evidence-based verification with improved candidate discovery 

92 - "constrained": Progressive constraint-based search that narrows candidates step by step 

93 - "parallel-constrained": Parallel constraint-based search with combined constraint execution 

94 - "early-stop-constrained": Parallel constraint search with immediate evaluation and early stopping at 99% confidence 

95 - "dual-confidence": Dual confidence scoring with positive/negative/uncertainty 

96 - "dual-confidence-with-rejection": Dual confidence with early rejection of poor candidates 

97 - "concurrent-dual-confidence": Concurrent search & evaluation with progressive constraint relaxation 

98 - "modular": Modular architecture using constraint checking and candidate exploration modules 

99 - "browsecomp-entity": Entity-focused search for BrowseComp questions with knowledge graph building 

100 - "iterative-refinement": Iteratively refines results using LLM evaluation and follow-up queries 

101 include_text_content: If False, only includes metadata and links in search results 

102 use_cross_engine_filter: Whether to filter results across search 

103 engines. 

104 max_iterations: The maximum number of search iterations to 

105 perform. Will be read from the settings if not specified. 

106 questions_per_iteration: The number of questions to include in 

107 each iteration. Will be read from the settings if not specified. 

108 use_atomic_facts: Whether to use atomic fact decomposition for 

109 complex queries when using the source-based strategy. 

110 programmatic_mode: If True, disables database operations and metrics tracking. 

111 This is useful for running searches without database dependencies. 

112 search_original_query: Whether to include the original query in the first iteration 

113 of search. Set to False for news searches to avoid sending long subscription 

114 prompts to search engines. 

115 

116 """ 

117 # Store research context for strategies 

118 self.research_id = research_id 

119 self.research_context = research_context 

120 self.username = username 

121 

122 # Store required components 

123 self.model = llm 

124 self.search = search 

125 

126 # Store settings snapshot. Inject the username under "_username" so 

127 # snapshot-driven consumers (notably the LangGraph agent's per-call 

128 # search-engine creation, which must register the user's document 

129 # collections) can find it. See _ensure_snapshot_username. 

130 self.settings_snapshot = _ensure_snapshot_username( 

131 settings_snapshot or {}, username 

132 ) 

133 

134 # Store programmatic mode 

135 self.programmatic_mode = programmatic_mode 

136 

137 # Store search original query setting 

138 self.search_original_query = search_original_query 

139 

140 # Log if running in programmatic mode 

141 if self.programmatic_mode: 

142 logger.warning( 

143 "Running in programmatic mode - database operations and metrics tracking disabled. " 

144 "Rate limiting, search metrics, and persistence features will not be available." 

145 ) 

146 

147 # Get iterations setting 

148 self.max_iterations = max_iterations 

149 if self.max_iterations is None: 

150 # Use settings from snapshot 

151 if "search.iterations" in self.settings_snapshot: 

152 self.max_iterations = unwrap_setting( 

153 self.settings_snapshot["search.iterations"] 

154 ) 

155 else: 

156 self.max_iterations = 1 # Default 

157 

158 self.questions_per_iteration = questions_per_iteration 

159 if self.questions_per_iteration is None: 

160 # Use settings from snapshot 

161 if "search.questions_per_iteration" in self.settings_snapshot: 

162 self.questions_per_iteration = unwrap_setting( 

163 self.settings_snapshot["search.questions_per_iteration"] 

164 ) 

165 else: 

166 self.questions_per_iteration = 3 # Default 

167 

168 # Log the strategy name that's being used 

169 logger.info( 

170 f"Initializing AdvancedSearchSystem with strategy_name='{strategy_name}'" 

171 ) 

172 

173 # Initialize components 

174 self.citation_handler = CitationHandler( 

175 self.model, settings_snapshot=self.settings_snapshot 

176 ) 

177 self.question_generator = StandardQuestionGenerator(self.model) 

178 self.findings_repository = FindingsRepository(self.model) 

179 # For backward compatibility 

180 self.questions_by_iteration: dict[Any, Any] = {} 

181 self.progress_callback = lambda _1, _2, _3: None 

182 self.all_links_of_system: list[dict[Any, Any]] = [] 

183 

184 # Initialize strategy using factory 

185 from .search_system_factory import create_strategy 

186 

187 # Special handling for follow-up strategy which needs different logic 

188 if strategy_name.lower() in [ 

189 "enhanced-contextual-followup", 

190 "enhanced_contextual_followup", 

191 "contextual-followup", 

192 "contextual_followup", 

193 ]: 

194 logger.info("Creating EnhancedContextualFollowUpStrategy instance") 

195 # Get delegate strategy from research context 

196 # This should be the user's preferred strategy from settings 

197 delegate_strategy_name = ( 

198 self.research_context.get("delegate_strategy", "source-based") 

199 if self.research_context 

200 else "source-based" 

201 ) 

202 

203 delegate = create_strategy( 

204 strategy_name=delegate_strategy_name, 

205 model=self.model, 

206 search=self.search, 

207 all_links_of_system=[], 

208 settings_snapshot=self.settings_snapshot, 

209 knowledge_accumulation_mode=True, 

210 search_original_query=self.search_original_query, 

211 ) 

212 

213 # Create the contextual follow-up strategy with the delegate 

214 self.strategy = EnhancedContextualFollowUpStrategy( 

215 model=self.model, 

216 search=self.search, 

217 delegate_strategy=delegate, 

218 all_links_of_system=self.all_links_of_system, 

219 settings_snapshot=self.settings_snapshot, 

220 research_context=self.research_context, 

221 ) 

222 else: 

223 # Use factory for all other strategies 

224 logger.info(f"Creating {strategy_name} strategy using factory") 

225 self.strategy = create_strategy( 

226 strategy_name=strategy_name, 

227 model=self.model, 

228 search=self.search, 

229 all_links_of_system=self.all_links_of_system, 

230 settings_snapshot=self.settings_snapshot, 

231 # Pass strategy-specific parameters 

232 include_text_content=include_text_content, 

233 use_cross_engine_filter=use_cross_engine_filter, 

234 use_atomic_facts=use_atomic_facts, 

235 max_iterations=self.max_iterations, 

236 questions_per_iteration=self.questions_per_iteration, 

237 # Special parameters for iterative strategy 

238 search_iterations_per_round=self.max_iterations or 1, 

239 questions_per_search=self.questions_per_iteration, 

240 # Special parameters for adaptive strategy 

241 max_steps=self.max_iterations, 

242 source_questions_per_iteration=self.questions_per_iteration, 

243 # Special parameters for evidence and constrained strategies 

244 max_search_iterations=self.max_iterations, 

245 # Special parameters for focused iteration 

246 use_browsecomp_optimization=True, 

247 # Pass search original query parameter 

248 search_original_query=self.search_original_query, 

249 # Forwarded so strategies that create engines per tool call 

250 # (e.g. langgraph-agent) can match the system's mode. 

251 programmatic_mode=self.programmatic_mode, 

252 ) 

253 

254 # Log the actual strategy class 

255 logger.info(f"Created strategy of type: {type(self.strategy).__name__}") 

256 

257 # Configure the strategy with our attributes 

258 if ( 258 ↛ exitline 258 didn't return from function '__init__' because the condition on line 258 was always true

259 hasattr(self, "progress_callback") 

260 and self.progress_callback is not None 

261 ): 

262 self.strategy.set_progress_callback(self.progress_callback) 

263 

264 def close(self): 

265 """Close resources held by the search system. 

266 

267 Cascades close to the strategy, which may hold persistent 

268 ThreadPoolExecutor instances (e.g. ConstraintParallelStrategy holds 

269 search_executor and evaluation_executor, 

270 ConcurrentDualConfidenceStrategy holds evaluation_executor). 

271 

272 NOTE: Does NOT close self.search (the search engine) — the caller 

273 (run_research_process) manages search engine lifecycle separately 

274 because the search engine may be shared or reused. 

275 

276 Most strategies (including the default source-based) use 

277 context-managed ThreadPoolExecutors that clean up automatically. 

278 The close() call here is a safety net for the two constraint-based 

279 strategies that hold persistent executors in __init__. Those 

280 strategies also shut down their executors in 

281 find_relevant_information()'s finally block, so this is a second 

282 line of defense for the edge case where the method is never called. 

283 """ 

284 from .utilities.resource_utils import safe_close 

285 

286 if hasattr(self, "strategy"): 

287 safe_close(self.strategy, "search strategy") 

288 

289 def _progress_callback( 

290 self, message: str, progress: int, metadata: dict 

291 ) -> None: 

292 """Handle progress updates from the strategy.""" 

293 logger.info(f"Progress: {progress}% - {message}") 

294 if hasattr(self, "progress_callback"): 

295 self.progress_callback(message, progress, metadata) 

296 

297 def set_progress_callback( 

298 self, callback: Callable[[str, int, dict], None] 

299 ) -> None: 

300 """Set a callback function to receive progress updates.""" 

301 self.progress_callback = callback 

302 if hasattr(self, "strategy"): 302 ↛ exitline 302 didn't return from function 'set_progress_callback' because the condition on line 302 was always true

303 self.strategy.set_progress_callback(callback) 

304 

305 def analyze_topic( 

306 self, 

307 query: str, 

308 is_user_search: bool = True, 

309 is_news_search: bool = False, 

310 user_id: str = "anonymous", 

311 search_id: str | None = None, 

312 **kwargs, 

313 ) -> Dict: 

314 """Analyze a topic using the current strategy. 

315 

316 Args: 

317 query: The research query to analyze 

318 is_user_search: Whether this is a user-initiated search 

319 is_news_search: Whether this is a news search 

320 user_id: The user ID for tracking 

321 search_id: The search ID (auto-generated if not provided) 

322 **kwargs: Additional arguments 

323 """ 

324 

325 # Generate search ID if not provided 

326 if search_id is None: 

327 import uuid 

328 

329 search_id = str(uuid.uuid4()) 

330 

331 # Defense-in-depth: arm the PEP-578 egress audit-hook backstop for this 

332 # run if no caller already armed it. The web worker arms it in 

333 # research_service before calling us; CLI, the news scheduler and the 

334 # programmatic API construct AdvancedSearchSystem directly and would 

335 # otherwise run the full pipeline with the secondary net inactive. 

336 _armed_egress = self._arm_egress_backstop() 

337 try: 

338 # Perform the search 

339 return self._perform_search( 

340 query, search_id, is_user_search, is_news_search, user_id 

341 ) 

342 finally: 

343 if _armed_egress: 

344 # Only clear what WE armed, so a reused thread doesn't leak the 

345 # context to unrelated subsequent work (the web worker owns its 

346 # own teardown via @thread_cleanup). 

347 from .security.egress.audit_hook import clear_active_context 

348 

349 clear_active_context() 

350 

351 def _arm_egress_backstop(self) -> bool: 

352 """Arm the egress audit-hook context for this run when no caller has. 

353 

354 Returns True only when THIS call armed the context (so ``analyze_topic`` 

355 clears it on exit). Returns False when it was already armed (web worker) 

356 or could not be built. Never raises — a backstop failure must not break 

357 a research run; the explicit PEPs remain the primary enforcement. 

358 """ 

359 try: 

360 from .security.egress.audit_hook import ( 

361 get_active_context, 

362 set_active_context, 

363 ) 

364 from .security.egress.policy import ( 

365 PolicyDeniedError, 

366 context_from_snapshot, 

367 ) 

368 except Exception: 

369 return False 

370 

371 if get_active_context() is not None: 

372 return False # already armed (e.g. by the web research worker) 

373 if not self.settings_snapshot: 

374 return False 

375 

376 try: 

377 primary = unwrap_setting( 

378 self.settings_snapshot.get("search.tool", DEFAULT_SEARCH_TOOL) 

379 ) 

380 ctx = context_from_snapshot( 

381 self.settings_snapshot, 

382 primary or DEFAULT_SEARCH_TOOL, 

383 username=self.username, 

384 ) 

385 except (PolicyDeniedError, ValueError): 

386 # Corrupted scope: the explicit PEPs and the run-start 

387 # precheck handle these; don't arm a partial backstop. 

388 logger.bind(policy_audit=True).debug( 

389 "egress backstop not armed: policy unevaluable", exc_info=True 

390 ) 

391 return False 

392 except Exception: 

393 return False 

394 

395 set_active_context(ctx) 

396 return True 

397 

398 def _perform_search( 

399 self, 

400 query: str, 

401 search_id: str, 

402 is_user_search: bool, 

403 is_news_search: bool, 

404 user_id: str, 

405 ) -> Dict: 

406 """Perform the actual search.""" 

407 # Send progress message with LLM info 

408 # Get settings from snapshot if available 

409 llm_provider = "unknown" 

410 llm_model = "unknown" 

411 search_tool = "unknown" 

412 

413 if self.settings_snapshot: 

414 # Extract values from settings snapshot 

415 provider_setting = self.settings_snapshot.get("llm.provider", {}) 

416 llm_provider = ( 

417 provider_setting.get("value", "unknown") 

418 if isinstance(provider_setting, dict) 

419 else provider_setting 

420 ) 

421 

422 model_setting = self.settings_snapshot.get("llm.model", {}) 

423 llm_model = ( 

424 model_setting.get("value", "unknown") 

425 if isinstance(model_setting, dict) 

426 else model_setting 

427 ) 

428 

429 tool_setting = self.settings_snapshot.get("search.tool", {}) 

430 search_tool = ( 

431 tool_setting.get("value", DEFAULT_SEARCH_TOOL) 

432 if isinstance(tool_setting, dict) 

433 else tool_setting 

434 ) 

435 

436 self.progress_callback( 

437 f"Using {llm_provider} model: {llm_model}", 

438 1, # Low percentage to show this as an early step 

439 { 

440 "phase": "setup", 

441 "llm_info": { 

442 "name": llm_model, 

443 "provider": llm_provider, 

444 }, 

445 }, 

446 ) 

447 # Send progress message with search strategy info 

448 self.progress_callback( 

449 f"Using search tool: {search_tool}", 

450 1.5, # Between setup and processing steps 

451 { 

452 "phase": "setup", 

453 "search_info": { 

454 "tool": search_tool, 

455 }, 

456 }, 

457 ) 

458 

459 # Use the strategy to analyze the topic 

460 result = self.strategy.analyze_topic(query) 

461 

462 # Update our attributes for backward compatibility 

463 

464 self.questions_by_iteration = ( 

465 self.strategy.questions_by_iteration.copy() 

466 ) 

467 # Send progress message with search info 

468 

469 # Only extend if they're different objects in memory to avoid duplication 

470 # This check prevents doubling the list when they reference the same object 

471 # Fix for issue #301: "too many links in detailed report mode" 

472 if id(self.all_links_of_system) != id( 

473 self.strategy.all_links_of_system 

474 ): 

475 self.all_links_of_system.extend(self.strategy.all_links_of_system) 

476 

477 # Include the search system instance for access to citations 

478 result["search_system"] = self 

479 result["all_links_of_system"] = self.all_links_of_system 

480 

481 # Ensure query is included in the result 

482 if "query" not in result: 

483 result["query"] = query 

484 result["questions_by_iteration"] = self.questions_by_iteration 

485 

486 # Call news callback 

487 try: 

488 from .news.core.search_integration import NewsSearchCallback 

489 

490 callback = NewsSearchCallback() 

491 context = { 

492 "is_user_search": is_user_search, 

493 "is_news_search": is_news_search, 

494 "user_id": user_id, 

495 "search_id": search_id, 

496 } 

497 callback(query, result, context) 

498 except Exception: 

499 logger.exception("Error in news callback") 

500 

501 return result