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

205 statements  

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

53def username_from_snapshot(settings_snapshot): 

54 """Return the run owner's username carried in a settings snapshot. 

55 

56 Centralizes the ``settings_snapshot.get("_username")`` read (the mirror of 

57 :func:`ensure_snapshot_username`'s write) behind a single ``isinstance`` 

58 guard so every consumer treats a missing / non-dict snapshot identically — 

59 returning ``None`` rather than raising or applying an inconsistent guard. 

60 ``None`` resolves the shared/built-in namespace in the per-user registries. 

61 """ 

62 return ( 

63 settings_snapshot.get("_username") 

64 if isinstance(settings_snapshot, dict) 

65 else None 

66 ) 

67 

68 

69class AdvancedSearchSystem: 

70 """ 

71 Advanced search system that coordinates different search strategies. 

72 """ 

73 

74 def __init__( 

75 self, 

76 llm: BaseChatModel, 

77 search: BaseSearchEngine, 

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

79 include_text_content: bool | None = None, 

80 use_cross_engine_filter: bool = True, 

81 max_iterations: int | None = None, 

82 questions_per_iteration: int | None = None, 

83 use_atomic_facts: bool = False, 

84 username: str | None = None, 

85 settings_snapshot: dict | None = None, 

86 research_id: str | None = None, 

87 research_context: dict | None = None, 

88 programmatic_mode: bool = False, 

89 search_original_query: bool = True, 

90 ): 

91 """Initialize the advanced search system. 

92 

93 Args: 

94 llm: LLM to use for the search strategy. 

95 search: Search engine to use for queries. 

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

97 - "standard": Basic iterative search strategy 

98 - "iterdrag": Iterative Dense Retrieval Augmented Generation 

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

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

101 - "rapid": Quick single-pass search 

102 - "recursive": Recursive decomposition of complex queries 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

118 use_cross_engine_filter: Whether to filter results across search 

119 engines. 

120 max_iterations: The maximum number of search iterations to 

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

122 questions_per_iteration: The number of questions to include in 

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

124 use_atomic_facts: Whether to use atomic fact decomposition for 

125 complex queries when using the source-based strategy. 

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

127 This is useful for running searches without database dependencies. 

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

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

130 prompts to search engines. 

131 

132 """ 

133 # Both are read back from JSON-typed sources, so either may hold any 

134 # JSON type: strategy_name can come from settings or a replayed queue 

135 # row; research_context from the chat/follow-up routers or the 

136 # programmatic API parameter. 

137 if not isinstance(strategy_name, str): 

138 logger.warning( 

139 f"Ignoring non-string strategy_name " 

140 f"{type(strategy_name).__name__}; using source-based" 

141 ) 

142 strategy_name = "source-based" 

143 if research_context is not None and not isinstance( 

144 research_context, dict 

145 ): 

146 logger.warning( 

147 f"Ignoring non-dict research_context " 

148 f"{type(research_context).__name__}" 

149 ) 

150 research_context = None 

151 

152 # Store research context for strategies 

153 self.research_id = research_id 

154 self.research_context = research_context 

155 self.username = username 

156 

157 # Store required components 

158 self.model = llm 

159 self.search = search 

160 

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

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

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

164 # collections) can find it. See ensure_snapshot_username. 

165 self.settings_snapshot = ensure_snapshot_username( 

166 settings_snapshot or {}, username 

167 ) 

168 

169 # Store programmatic mode 

170 self.programmatic_mode = programmatic_mode 

171 

172 # Store search original query setting 

173 self.search_original_query = search_original_query 

174 

175 # Log if running in programmatic mode 

176 if self.programmatic_mode: 

177 logger.warning( 

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

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

180 ) 

181 

182 # Get iterations setting 

183 self.max_iterations = max_iterations 

184 if self.max_iterations is None: 

185 # Use settings from snapshot 

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

187 self.max_iterations = unwrap_setting( 

188 self.settings_snapshot["search.iterations"] 

189 ) 

190 else: 

191 self.max_iterations = 1 # Default 

192 

193 self.questions_per_iteration = questions_per_iteration 

194 if self.questions_per_iteration is None: 

195 # Use settings from snapshot 

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

197 self.questions_per_iteration = unwrap_setting( 

198 self.settings_snapshot["search.questions_per_iteration"] 

199 ) 

200 else: 

201 self.questions_per_iteration = 3 # Default 

202 

203 # Single shared content cache for the ENTIRE research run: the 

204 # initial quick-report phase, every detailed-report subsection and 

205 # all parallel subagents resolve webpage / RAG-chunk fetches through 

206 # this one instance instead of re-fetching on the spot. 

207 try: 

208 from .utilities.research_content_cache import ( 

209 ResearchContentCache, 

210 bind_cache_to_research, 

211 ) 

212 

213 self.content_cache = ResearchContentCache() 

214 except Exception: # pragma: no cover - defensive 

215 self.content_cache = None # type: ignore[assignment] 

216 

217 # Log the strategy name that's being used 

218 logger.info( 

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

220 ) 

221 

222 # Initialize components 

223 self.citation_handler = CitationHandler( 

224 self.model, settings_snapshot=self.settings_snapshot 

225 ) 

226 self.question_generator = StandardQuestionGenerator(self.model) 

227 self.findings_repository = FindingsRepository(self.model) 

228 # For backward compatibility 

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

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

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

232 

233 # Initialize strategy using factory 

234 from .search_system_factory import create_strategy 

235 

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

237 if strategy_name.lower() in [ 

238 "enhanced-contextual-followup", 

239 "enhanced_contextual_followup", 

240 "contextual-followup", 

241 "contextual_followup", 

242 ]: 

243 logger.info("Creating EnhancedContextualFollowUpStrategy instance") 

244 # Get delegate strategy from research context 

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

246 delegate_strategy_name = ( 

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

248 if self.research_context 

249 else "source-based" 

250 ) 

251 # research_context can be read back from JSON, so the value may 

252 # be any JSON type; only a name can select a strategy. 

253 if not isinstance(delegate_strategy_name, str): 

254 logger.warning( 

255 f"Ignoring non-string delegate_strategy " 

256 f"{type(delegate_strategy_name).__name__}; using source-based" 

257 ) 

258 delegate_strategy_name = "source-based" 

259 

260 delegate = create_strategy( 

261 strategy_name=delegate_strategy_name, 

262 model=self.model, 

263 search=self.search, 

264 all_links_of_system=[], 

265 settings_snapshot=self.settings_snapshot, 

266 knowledge_accumulation_mode=True, 

267 search_original_query=self.search_original_query, 

268 content_cache=getattr(self, "content_cache", None), 

269 max_iterations=self.max_iterations, 

270 questions_per_iteration=self.questions_per_iteration, 

271 ) 

272 

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

274 self.strategy = EnhancedContextualFollowUpStrategy( 

275 model=self.model, 

276 search=self.search, 

277 delegate_strategy=delegate, 

278 all_links_of_system=self.all_links_of_system, 

279 settings_snapshot=self.settings_snapshot, 

280 research_context=self.research_context, 

281 content_cache=getattr(self, "content_cache", None), 

282 ) 

283 # The delegate owns the actual fetch path — make sure it shares 

284 # the single research-scoped cache even when the factory drops 

285 # unknown kwargs for that strategy type. 

286 if getattr(self, "content_cache", None) is not None: 286 ↛ 327line 286 didn't jump to line 327 because the condition on line 286 was always true

287 try: 

288 delegate.content_cache = self.content_cache 

289 except Exception: # allow: silent-exception 

290 pass 

291 else: 

292 # Use factory for all other strategies 

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

294 self.strategy = create_strategy( 

295 strategy_name=strategy_name, 

296 model=self.model, 

297 search=self.search, 

298 all_links_of_system=self.all_links_of_system, 

299 settings_snapshot=self.settings_snapshot, 

300 # Pass strategy-specific parameters 

301 include_text_content=include_text_content, 

302 use_cross_engine_filter=use_cross_engine_filter, 

303 use_atomic_facts=use_atomic_facts, 

304 max_iterations=self.max_iterations, 

305 questions_per_iteration=self.questions_per_iteration, 

306 # Special parameters for iterative strategy 

307 search_iterations_per_round=self.max_iterations or 1, 

308 questions_per_search=self.questions_per_iteration, 

309 # Special parameters for adaptive strategy 

310 max_steps=self.max_iterations, 

311 source_questions_per_iteration=self.questions_per_iteration, 

312 # Special parameters for evidence and constrained strategies 

313 max_search_iterations=self.max_iterations, 

314 # Special parameters for focused iteration 

315 use_browsecomp_optimization=True, 

316 # Pass search original query parameter 

317 search_original_query=self.search_original_query, 

318 # Forwarded so strategies that create engines per tool call 

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

320 programmatic_mode=self.programmatic_mode, 

321 content_cache=getattr(self, "content_cache", None), 

322 ) 

323 

324 # Share the single research-scoped cache with the strategy (covers 

325 # strategies whose constructors do not accept content_cache, and 

326 # factory paths that drop unknown kwargs). 

327 if getattr(self, "content_cache", None) is not None: 327 ↛ 335line 327 didn't jump to line 335 because the condition on line 327 was always true

328 try: 

329 self.strategy.content_cache = self.content_cache 

330 except Exception: # allow: silent-exception 

331 pass 

332 # Also thread it into the long-lived search engine when present so 

333 # non-agent strategies (source-based / focused-iteration) share it 

334 # for their direct self.search.run() calls. 

335 if ( 335 ↛ 345line 335 didn't jump to line 345 because the condition on line 335 was always true

336 getattr(self, "content_cache", None) is not None 

337 and getattr(self, "search", None) is not None 

338 ): 

339 try: 

340 self.search.content_cache = self.content_cache 

341 except Exception: # allow: silent-exception 

342 pass 

343 

344 # Log the actual strategy class 

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

346 

347 # Configure the strategy with our attributes 

348 if ( 348 ↛ 356line 348 didn't jump to line 356 because the condition on line 348 was always true

349 hasattr(self, "progress_callback") 

350 and self.progress_callback is not None 

351 ): 

352 self.strategy.set_progress_callback(self.progress_callback) 

353 

354 # Publish the binding only after construction succeeds. A failed 

355 # strategy has no system for the caller to close. 

356 if getattr(self, "content_cache", None) is not None: 356 ↛ exitline 356 didn't return from function '__init__' because the condition on line 356 was always true

357 try: 

358 _rid = research_id or ( 

359 research_context.get("research_id") 

360 if isinstance(research_context, dict) 

361 else None 

362 ) 

363 if _rid is not None and not isinstance(_rid, str): 363 ↛ 364line 363 didn't jump to line 364 because the condition on line 363 was never true

364 _rid = str(_rid) 

365 if isinstance(_rid, str) and _rid: 

366 bind_cache_to_research(_rid, self.content_cache) 

367 except Exception: # allow: silent-exception 

368 pass 

369 

370 def close(self): 

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

372 

373 Cascades close to the strategy, which may hold persistent 

374 ThreadPoolExecutor instances (e.g. ConstraintParallelStrategy holds 

375 search_executor and evaluation_executor, 

376 ConcurrentDualConfidenceStrategy holds evaluation_executor). 

377 

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

379 (run_research_process) manages search engine lifecycle separately 

380 because the search engine may be shared or reused. 

381 

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

383 context-managed ThreadPoolExecutors that clean up automatically. 

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

385 strategies that hold persistent executors in __init__. Those 

386 strategies also shut down their executors in 

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

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

389 """ 

390 from .utilities.resource_utils import safe_close 

391 

392 if hasattr(self, "strategy"): 

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

394 # Release the research-scoped content cache binding. The cache 

395 # itself is in-memory only; unbinding prevents unbounded growth 

396 # of the registry across many programmatic calls. 

397 try: 

398 from .utilities.research_content_cache import ( 

399 unbind_cache_from_research, 

400 ) 

401 

402 _rid = getattr(self, "research_id", None) or ( 

403 self.research_context.get("research_id") 

404 if isinstance(self.research_context, dict) 

405 else None 

406 ) 

407 if _rid is not None and not isinstance(_rid, str): 407 ↛ 408line 407 didn't jump to line 408 because the condition on line 407 was never true

408 _rid = str(_rid) 

409 cache = getattr(self, "content_cache", None) 

410 if isinstance(_rid, str) and _rid and cache is not None: 

411 unbind_cache_from_research(_rid, cache=cache) 

412 except Exception: 

413 logger.debug( 

414 "content cache unbind failed (non-critical)", 

415 exc_info=True, 

416 ) 

417 

418 def _progress_callback( 

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

420 ) -> None: 

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

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

423 if hasattr(self, "progress_callback"): 

424 self.progress_callback(message, progress, metadata) 

425 

426 def set_progress_callback( 

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

428 ) -> None: 

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

430 self.progress_callback = callback 

431 if hasattr(self, "strategy"): 431 ↛ 440line 431 didn't jump to line 440 because the condition on line 431 was always true

432 self.strategy.set_progress_callback(callback) 

433 # Pipeline-strategy live path for bulk-fetch progress (#7193): 

434 # adapt the 3-arg UI callback to the 2-arg ``(done, total)`` hook 

435 # and attach it to the search engine so ``FullSearchResults`` 

436 # bulk fetches emit throttled milestones + per-page cancellation 

437 # checks. Late-binds so engines created via ``get_search`` before 

438 # the callback existed still get progress; agents stay snippets- 

439 # only post-#7189, so their hook remains dormant groundwork. 

440 try: 

441 search = getattr(self, "search", None) 

442 if search is not None and callback is not None: 442 ↛ exitline 442 didn't return from function 'set_progress_callback' because the condition on line 442 was always true

443 from .web_search_engines.engines.full_search import ( 

444 make_fetch_progress_adapter, 

445 ) 

446 

447 hook = make_fetch_progress_adapter(callback) 

448 setter = getattr(search, "set_fetch_progress_callback", None) 

449 if callable(setter): 449 ↛ 451line 449 didn't jump to line 451 because the condition on line 449 was always true

450 setter(hook) 

451 elif hasattr(search, "progress_callback"): 

452 search.progress_callback = hook 

453 except Exception: 

454 logger.debug("Failed to wire fetch progress to search engine") 

455 

456 def analyze_topic( 

457 self, 

458 query: str, 

459 is_user_search: bool = True, 

460 is_news_search: bool = False, 

461 user_id: str = "anonymous", 

462 search_id: str | None = None, 

463 **kwargs, 

464 ) -> Dict: 

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

466 

467 Args: 

468 query: The research query to analyze 

469 is_user_search: Whether this is a user-initiated search 

470 is_news_search: Whether this is a news search 

471 user_id: The user ID for tracking 

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

473 **kwargs: Additional arguments 

474 """ 

475 

476 # Generate search ID if not provided 

477 if search_id is None: 

478 import uuid 

479 

480 search_id = str(uuid.uuid4()) 

481 

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

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

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

485 # programmatic API construct AdvancedSearchSystem directly and would 

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

487 _armed_egress = self._arm_egress_backstop() 

488 # Bind the single research-scoped content cache for the whole run 

489 # (quick phase + all detailed subsections + subagents). Deep funnels 

490 # (engines created per web_search call, ContentFetcher, pipeline, 

491 # library resolver) resolve it via the thread-local search context. 

492 try: 

493 from .utilities.research_content_cache import ( 

494 bind_cache_to_research, 

495 ) 

496 

497 _rid = self.research_id or ( 

498 self.research_context.get("research_id") 

499 if isinstance(self.research_context, dict) 

500 else None 

501 ) 

502 if _rid is not None and not isinstance(_rid, str): 502 ↛ 503line 502 didn't jump to line 503 because the condition on line 502 was never true

503 _rid = str(_rid) 

504 if ( 

505 isinstance(_rid, str) 

506 and _rid 

507 and getattr(self, "content_cache", None) is not None 

508 ): 

509 bind_cache_to_research(_rid, self.content_cache) 

510 # Re-share with strategy/engine in case they were constructed 

511 # without it (factory drops unknown kwargs on some paths). 

512 if getattr(self, "content_cache", None) is not None: 512 ↛ 528line 512 didn't jump to line 528 because the condition on line 512 was always true

513 try: 

514 if getattr(self.strategy, "content_cache", None) is None: 514 ↛ 515line 514 didn't jump to line 515 because the condition on line 514 was never true

515 self.strategy.content_cache = self.content_cache 

516 except Exception: # allow: silent-exception 

517 pass 

518 try: 

519 if ( 519 ↛ 523line 519 didn't jump to line 523 because the condition on line 519 was never true

520 getattr(self, "search", None) is not None 

521 and getattr(self.search, "content_cache", None) is None 

522 ): 

523 self.search.content_cache = self.content_cache 

524 except Exception: # allow: silent-exception 

525 pass 

526 except Exception: # allow: silent-exception 

527 pass 

528 try: 

529 # Perform the search 

530 return self._perform_search( 

531 query, search_id, is_user_search, is_news_search, user_id 

532 ) 

533 finally: 

534 if _armed_egress: 

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

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

537 # own teardown via @thread_cleanup). 

538 from .security.egress.audit_hook import clear_active_context 

539 

540 clear_active_context() 

541 

542 def _arm_egress_backstop(self) -> bool: 

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

544 

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

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

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

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

549 """ 

550 try: 

551 from .security.egress.audit_hook import ( 

552 get_active_context, 

553 set_active_context, 

554 ) 

555 from .security.egress.policy import ( 

556 PolicyDeniedError, 

557 context_from_snapshot, 

558 resolve_run_primary_engine, 

559 ) 

560 except Exception: 

561 return False 

562 

563 if get_active_context() is not None: 

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

565 if not self.settings_snapshot: 

566 return False 

567 

568 try: 

569 ctx = context_from_snapshot( 

570 self.settings_snapshot, 

571 resolve_run_primary_engine(self.settings_snapshot), 

572 username=self.username, 

573 ) 

574 except (PolicyDeniedError, ValueError): 

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

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

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

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

579 ) 

580 return False 

581 except Exception: 

582 return False 

583 

584 set_active_context(ctx) 

585 return True 

586 

587 def _perform_search( 

588 self, 

589 query: str, 

590 search_id: str, 

591 is_user_search: bool, 

592 is_news_search: bool, 

593 user_id: str, 

594 ) -> Dict: 

595 """Perform the actual search.""" 

596 # Send progress message with LLM info 

597 # Get settings from snapshot if available 

598 llm_provider = "unknown" 

599 llm_model = "unknown" 

600 search_tool = "unknown" 

601 

602 if self.settings_snapshot: 

603 # Extract values from settings snapshot 

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

605 llm_provider = ( 

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

607 if isinstance(provider_setting, dict) 

608 else provider_setting 

609 ) 

610 

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

612 llm_model = ( 

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

614 if isinstance(model_setting, dict) 

615 else model_setting 

616 ) 

617 

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

619 search_tool = ( 

620 tool_setting.get("value", DEFAULT_SEARCH_TOOL) 

621 if isinstance(tool_setting, dict) 

622 else tool_setting 

623 ) 

624 

625 self.progress_callback( 

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

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

628 { 

629 "phase": "setup", 

630 "llm_info": { 

631 "name": llm_model, 

632 "provider": llm_provider, 

633 }, 

634 }, 

635 ) 

636 # Send progress message with search strategy info 

637 self.progress_callback( 

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

639 1.5, # Between setup and processing steps 

640 { 

641 "phase": "setup", 

642 "search_info": { 

643 "tool": search_tool, 

644 }, 

645 }, 

646 ) 

647 

648 # Use the strategy to analyze the topic 

649 result = self.strategy.analyze_topic(query) 

650 

651 # Update our attributes for backward compatibility 

652 

653 self.questions_by_iteration = ( 

654 self.strategy.questions_by_iteration.copy() 

655 ) 

656 # Send progress message with search info 

657 

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

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

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

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

662 self.strategy.all_links_of_system 

663 ): 

664 self.all_links_of_system.extend(self.strategy.all_links_of_system) 

665 

666 # Include the search system instance for access to citations 

667 result["search_system"] = self 

668 result["all_links_of_system"] = self.all_links_of_system 

669 

670 # Ensure query is included in the result 

671 if "query" not in result: 

672 result["query"] = query 

673 result["questions_by_iteration"] = self.questions_by_iteration 

674 

675 # Call news callback 

676 try: 

677 from .news.core.search_integration import NewsSearchCallback 

678 

679 callback = NewsSearchCallback() 

680 context = { 

681 "is_user_search": is_user_search, 

682 "is_news_search": is_news_search, 

683 "user_id": user_id, 

684 "search_id": search_id, 

685 } 

686 callback(query, result, context) 

687 except Exception: 

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

689 

690 return result