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
« 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
4from langchain_core.language_models import BaseChatModel
5from loguru import logger
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)
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
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.
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_…'".
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.
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
53def username_from_snapshot(settings_snapshot):
54 """Return the run owner's username carried in a settings snapshot.
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 )
69class AdvancedSearchSystem:
70 """
71 Advanced search system that coordinates different search strategies.
72 """
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.
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.
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
152 # Store research context for strategies
153 self.research_id = research_id
154 self.research_context = research_context
155 self.username = username
157 # Store required components
158 self.model = llm
159 self.search = search
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 )
169 # Store programmatic mode
170 self.programmatic_mode = programmatic_mode
172 # Store search original query setting
173 self.search_original_query = search_original_query
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 )
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
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
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 )
213 self.content_cache = ResearchContentCache()
214 except Exception: # pragma: no cover - defensive
215 self.content_cache = None # type: ignore[assignment]
217 # Log the strategy name that's being used
218 logger.info(
219 f"Initializing AdvancedSearchSystem with strategy_name='{strategy_name}'"
220 )
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]] = []
233 # Initialize strategy using factory
234 from .search_system_factory import create_strategy
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"
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 )
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 )
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
344 # Log the actual strategy class
345 logger.info(f"Created strategy of type: {type(self.strategy).__name__}")
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)
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
370 def close(self):
371 """Close resources held by the search system.
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).
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.
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
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 )
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 )
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)
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 )
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")
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.
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 """
476 # Generate search ID if not provided
477 if search_id is None:
478 import uuid
480 search_id = str(uuid.uuid4())
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 )
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
540 clear_active_context()
542 def _arm_egress_backstop(self) -> bool:
543 """Arm the egress audit-hook context for this run when no caller has.
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
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
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
584 set_active_context(ctx)
585 return True
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"
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 )
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 )
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 )
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 )
648 # Use the strategy to analyze the topic
649 result = self.strategy.analyze_topic(query)
651 # Update our attributes for backward compatibility
653 self.questions_by_iteration = (
654 self.strategy.questions_by_iteration.copy()
655 )
656 # Send progress message with search info
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)
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
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
675 # Call news callback
676 try:
677 from .news.core.search_integration import NewsSearchCallback
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")
690 return result