Coverage for src/local_deep_research/web_search_engines/search_engine_factory.py: 90%
229 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
1import inspect
2from typing import Any, Dict, Optional
4from ..security.egress.policy import PolicyDeniedError
5from ..security.log_sanitizer import scrub_error
6from ..security.module_whitelist import get_safe_module_class
7from ..security.secure_logging import logger
8from .retriever_registry import retriever_registry
9from .search_engine_base import BaseSearchEngine
10from .search_engines_config import search_config
13def create_search_engine(
14 engine_name: str,
15 llm=None,
16 username: str | None = None,
17 settings_snapshot: Dict[str, Any] | None = None,
18 programmatic_mode: bool = False,
19 **kwargs,
20) -> Optional[BaseSearchEngine]:
21 """
22 Create a search engine instance based on the engine name.
24 Args:
25 engine_name: Name of the search engine to create
26 llm: Language model instance (passed to engines that accept one)
27 programmatic_mode: If True, disables database operations and metrics tracking
28 **kwargs: Additional parameters to override defaults
30 Returns:
31 Initialized search engine instance or None if creation failed
32 """
33 # Debug logging
34 logger.info(
35 f"create_search_engine called with engine_name={engine_name} (type: {type(engine_name)})"
36 )
38 # Egress policy PEP runs AFTER retriever-registry + config lookup, so
39 # registered retrievers don't go through engine-name classification.
40 # The actual call site is right before engine instantiation, further
41 # down.
43 # Check if this is a registered retriever first
44 retriever = retriever_registry.get(engine_name)
45 if retriever:
46 # Egress policy: gate the retriever against the active scope.
47 # Retrievers are classified at registration (is_local, default
48 # True). Snapshot REQUIRED — previously the policy block was
49 # gated on "if settings_snapshot:" which let snapshot=None
50 # callers instantiate retrievers ungated.
51 # NB: PolicyDeniedError is imported at MODULE level — importing it
52 # again here (function-local) would shadow it across the whole
53 # function and make the outer `except PolicyDeniedError` an unbound
54 # local on code paths that skip this branch.
55 from ..security.egress.policy import (
56 Decision,
57 context_from_snapshot,
58 evaluate_retriever,
59 resolve_run_primary_engine,
60 )
62 if not settings_snapshot: 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true
63 raise PolicyDeniedError(
64 Decision(False, "no_snapshot"),
65 target=engine_name,
66 )
68 primary = resolve_run_primary_engine(
69 settings_snapshot, default=engine_name
70 )
71 try:
72 _ctx = context_from_snapshot(
73 settings_snapshot,
74 primary,
75 username=username,
76 )
77 except PolicyDeniedError:
78 raise
79 except ValueError as exc:
80 raise PolicyDeniedError(
81 Decision(False, "invalid_policy_config"),
82 target=engine_name,
83 ) from exc
84 # Pass metadata from the SAME registry reference used for
85 # .get() above, so evaluate_retriever doesn't re-read a
86 # different (e.g. test-patched) global singleton.
87 try:
88 _meta = retriever_registry.get_metadata(
89 engine_name, username=username
90 )
91 except AttributeError:
92 _meta = None
93 _decision = evaluate_retriever(engine_name, _ctx, metadata=_meta)
94 if not _decision.allowed: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true
95 logger.bind(policy_audit=True).warning(
96 "retriever denied by egress policy",
97 retriever=engine_name,
98 scope=_ctx.scope.value,
99 reason=_decision.reason,
100 )
101 raise PolicyDeniedError(_decision, target=engine_name)
103 logger.info(f"Using registered LangChain retriever: {engine_name}")
104 from .engines.search_engine_retriever import RetrieverSearchEngine
106 return RetrieverSearchEngine(
107 retriever=retriever,
108 name=engine_name,
109 max_results=kwargs.get("max_results", 10),
110 programmatic_mode=programmatic_mode,
111 )
113 # Extract search engine configs from settings snapshot
114 if settings_snapshot:
115 config = search_config(
116 username=username, settings_snapshot=settings_snapshot
117 )
119 logger.debug(
120 f"Extracted search engines from snapshot: {list(config.keys())}"
121 )
122 else:
123 raise RuntimeError(
124 "settings_snapshot is required for search engine creation in threads"
125 )
127 if engine_name == "none":
128 # Reject the literal string "none". Historically this silently fell
129 # through to a meta engine and hit live networks — callers that
130 # wanted an offline pipeline were unknowingly doing real searches.
131 raise ValueError(
132 "search.tool='none' is not a valid engine. Register a LangChain "
133 "retriever via `retrievers={...}` (see "
134 "examples/llm_integration/mock_llm_example.py) or pick a real "
135 "engine."
136 )
138 if engine_name not in config:
139 # Check if engine_name might be a display label instead of a config key
140 # Display labels have format: "{icon} {base_name} ({category})"
141 # e.g., "🔬 OpenAlex (Scientific)"
142 # NOTE: This fallback is deprecated - callers should pass config keys directly
143 logger.warning(
144 f"Engine '{engine_name}' not found in config - attempting display label fallback. "
145 "This is deprecated; callers should pass the config key directly."
146 )
148 # Try to extract the base name from the label
149 # To avoid ReDoS, we use string operations instead of regex
150 # Pattern: icon, space, base_name, space, (category)
151 # Example: "🔬 OpenAlex (Scientific)"
152 if " (" in engine_name and engine_name.endswith(")"):
153 # Split on the last occurrence of ' ('
154 parts = engine_name.rsplit(" (", 1)
155 if len(parts) == 2: 155 ↛ 183line 155 didn't jump to line 183 because the condition on line 155 was always true
156 # Remove icon (first word) from the beginning
157 before_paren = parts[0]
158 space_idx = before_paren.find(" ")
159 if space_idx > 0:
160 base_name = before_paren[space_idx + 1 :].strip()
161 logger.info(
162 f"Extracted base name '{base_name}' from label '{engine_name}'"
163 )
165 # Search for a config entry with matching display_name
166 for config_key, config_data in config.items():
167 if isinstance(config_data, dict): 167 ↛ 166line 167 didn't jump to line 166 because the condition on line 167 was always true
168 display_name = config_data.get(
169 "display_name", config_key
170 )
171 if display_name == base_name:
172 logger.info(
173 f"Matched label to config key: '{engine_name}' -> '{config_key}'"
174 )
175 engine_name = config_key
176 break
178 # If still not found, FAIL CLOSED.
179 # We raise a plain ValueError (not PolicyDeniedError) because
180 # "unknown engine name" is a config/wiring error, not a policy
181 # decision — keeping the exception type clean lets policy-aware
182 # callers tell them apart.
183 if engine_name not in config:
184 logger.bind(policy_audit=True).warning(
185 "unknown engine name rejected",
186 engine=engine_name,
187 available=list(config.keys()),
188 )
189 raise ValueError(
190 f"Unknown search engine '{engine_name}'. Available: "
191 f"{sorted(config.keys())}. The 'auto', 'meta', 'parallel' "
192 "and 'parallel_scientific' meta engines were removed — the "
193 "langgraph-agent strategy (the default) selects engines "
194 "dynamically; pick a concrete engine instead."
195 )
197 # Egress policy PEP: gate the resolved engine against the user's declared
198 # scope. Lazy import to break the circular dependency
199 # (security/egress/policy.py → web_search_engines/* → security/*).
200 # PolicyDeniedError comes from the module-level import (see the note
201 # in the retriever branch above — re-importing it here would shadow
202 # it function-wide and break the outer except clause).
203 from ..security.egress.policy import (
204 Decision,
205 context_from_snapshot,
206 evaluate_engine,
207 resolve_run_primary_engine,
208 )
210 # Resolve the run's primary via the shared helper so the scope this PEP
211 # enforces matches the one the LangGraph tool-list filter pre-filters with
212 # (default: this engine when search.tool is unset).
213 primary_engine = resolve_run_primary_engine(
214 settings_snapshot, default=engine_name
215 )
217 try:
218 ctx = context_from_snapshot(
219 settings_snapshot, primary_engine, username=username
220 )
221 except ValueError as exc:
222 raise PolicyDeniedError(
223 Decision(False, "invalid_policy_config"),
224 target=engine_name,
225 ) from exc
227 # Pass the engine config entry as metadata so a per-collection
228 # is_public classification is honored without a redundant DB lookup.
229 decision = evaluate_engine(
230 engine_name,
231 ctx,
232 settings_snapshot=settings_snapshot,
233 metadata=config.get(engine_name),
234 )
235 if not decision.allowed:
236 logger.bind(policy_audit=True).warning(
237 "engine denied by egress policy",
238 engine=engine_name,
239 scope=ctx.scope.value,
240 reason=decision.reason,
241 )
242 raise PolicyDeniedError(decision, target=engine_name)
244 # Get engine configuration
245 engine_config = config[engine_name]
247 # Set default max_results from config if not provided in kwargs
248 if "max_results" not in kwargs:
249 if settings_snapshot and "search.max_results" in settings_snapshot:
250 max_results = (
251 settings_snapshot["search.max_results"].get("value", 20)
252 if isinstance(settings_snapshot["search.max_results"], dict)
253 else settings_snapshot["search.max_results"]
254 )
255 else:
256 max_results = 20
257 kwargs["max_results"] = max_results
259 # Check for API key requirements
260 requires_api_key = engine_config.get("requires_api_key", False)
262 # Bound here (not just inside ``if requires_api_key``) so the except
263 # handler below can pass the in-scope key to scrub_error() — without
264 # the hoist, the no-key path would hit a NameError when scrubbing.
265 api_key = None
267 if requires_api_key:
268 # Check the settings snapshot for the API key
269 api_key_path = f"search.engine.web.{engine_name}.api_key"
271 if settings_snapshot: 271 ↛ 282line 271 didn't jump to line 282 because the condition on line 271 was always true
272 api_key_setting = settings_snapshot.get(api_key_path)
274 if api_key_setting:
275 api_key = (
276 api_key_setting.get("value")
277 if isinstance(api_key_setting, dict)
278 else api_key_setting
279 )
281 # Still try to get from engine config if not found
282 if not api_key:
283 api_key = engine_config.get("api_key")
285 if not api_key:
286 logger.info(
287 f"Required API key for {engine_name} not found in settings."
288 )
289 return None
291 # Pass the API key in kwargs for engines that need it
292 if api_key: 292 ↛ 298line 292 didn't jump to line 298 because the condition on line 292 was always true
293 kwargs["api_key"] = api_key
295 # Warn about missing LLM but allow engine creation in degraded mode.
296 # All engines with requires_llm=True handle llm=None gracefully
297 # (e.g. skipping query optimization, using reliability-based sorting).
298 if engine_config.get("requires_llm", False) and not llm:
299 logger.warning(
300 f"Engine '{engine_name}' is configured with requires_llm=True but no LLM provided. "
301 f"Creating engine without LLM — some features (query optimization, relevance filtering) "
302 f"may be unavailable."
303 )
305 try:
306 # Load the engine class
307 module_path = engine_config["module_path"]
308 class_name = engine_config["class_name"]
310 engine_class = get_safe_module_class(module_path, class_name)
312 # Get the engine class's __init__ parameters to filter out unsupported ones
313 engine_init_signature = inspect.signature(engine_class.__init__)
314 engine_init_params = list(engine_init_signature.parameters.keys())
316 # Combine default parameters with provided ones
317 all_params = {**engine_config.get("default_params", {}), **kwargs}
319 # Filter out parameters that aren't accepted by the engine class
320 # Note: 'self' is always the first parameter of instance methods, so we skip it
321 filtered_params = {
322 k: v for k, v in all_params.items() if k in engine_init_params[1:]
323 }
325 # Always pass settings_snapshot if the engine accepts it
326 if "settings_snapshot" in engine_init_params[1:] and settings_snapshot:
327 filtered_params["settings_snapshot"] = settings_snapshot
329 # Pass programmatic_mode if the engine accepts it
330 if "programmatic_mode" in engine_init_params[1:]:
331 filtered_params["programmatic_mode"] = programmatic_mode
333 # Add LLM if required OR if provided and engine accepts it
334 if engine_config.get("requires_llm", False):
335 filtered_params["llm"] = llm
336 elif (
337 "llm" in engine_init_params[1:]
338 and llm
339 and "llm" not in filtered_params
340 ):
341 # If LLM was provided and engine accepts it, pass it through
342 filtered_params["llm"] = llm
343 logger.info(
344 f"Passing LLM to {engine_name} (engine accepts it and LLM was provided)"
345 )
347 # Add API key if required and not already in filtered_params
348 if ( 348 ↛ 353line 348 didn't jump to line 353 because the condition on line 348 was never true
349 engine_config.get("requires_api_key", False)
350 and "api_key" not in filtered_params
351 ):
352 # Use the api_key we got earlier from settings
353 if api_key:
354 filtered_params["api_key"] = api_key
356 logger.info(
357 f"Creating {engine_name} with filtered parameters: {filtered_params.keys()}"
358 )
360 # Create the engine instance with filtered parameters
361 engine = engine_class(**filtered_params)
363 # Stamp the registry name so BaseSearchEngine._verify_egress_scope()
364 # can check egress scope at run time.
365 if isinstance(engine, BaseSearchEngine):
366 engine._engine_name = engine_name
368 # Most engine subclasses do not name ``programmatic_mode`` in their
369 # signature (or accept it via **kwargs without forwarding to
370 # ``super().__init__``), so the constructor often falls back to the
371 # BaseSearchEngine default of False even when the API caller asked
372 # for True. Apply the requested mode post-construction so the
373 # engine's rate tracker matches.
374 if isinstance(engine, BaseSearchEngine) and (
375 engine.programmatic_mode != programmatic_mode
376 ):
377 engine._configure_programmatic_mode(programmatic_mode)
379 # Determine if this engine should use LLM relevance filtering
380 # Priority: per-engine setting > needs_llm_relevance_filter > global setting
381 #
382 # Rationale:
383 # - Engines with needs_llm_relevance_filter=True have poor native relevance ranking
384 # (keyword-only, no ML ranking) and benefit from LLM-based filtering
385 # - Well-ranked engines (Google, Brave) and semantic engines (Exa, Tavily)
386 # do not need this and should not waste LLM calls
387 # - The global skip_relevance_filter only affects unclassified engines
388 # - CrossEngineFilter still ranks combined results at the strategy level
389 should_filter = False
391 # Check for per-engine setting first (highest priority)
392 per_engine_key = f"search.engine.web.{engine_name}.default_params.enable_llm_relevance_filter"
393 if settings_snapshot and per_engine_key in settings_snapshot:
394 per_engine_setting = settings_snapshot[per_engine_key]
395 should_filter = (
396 per_engine_setting.get("value", False)
397 if isinstance(per_engine_setting, dict)
398 else per_engine_setting
399 )
400 logger.info(
401 f"Using per-engine setting for {engine_name}: "
402 f"enable_llm_relevance_filter={should_filter}"
403 )
404 else:
405 # Auto-detection based on engine attribute (medium priority)
406 if (
407 hasattr(engine_class, "needs_llm_relevance_filter")
408 and engine_class.needs_llm_relevance_filter
409 ):
410 should_filter = True
411 logger.info(
412 f"Auto-enabling LLM filtering for {engine_name} "
413 f"(needs_llm_relevance_filter=True)"
414 )
415 else:
416 # Global override only applies to engines without needs_llm_relevance_filter
417 if (
418 settings_snapshot
419 and "search.skip_relevance_filter" in settings_snapshot
420 ):
421 skip_filter_setting = settings_snapshot[
422 "search.skip_relevance_filter"
423 ]
424 skip_filter = (
425 skip_filter_setting.get("value", False)
426 if isinstance(skip_filter_setting, dict)
427 else skip_filter_setting
428 )
429 if skip_filter: 429 ↛ 437line 429 didn't jump to line 437 because the condition on line 429 was always true
430 should_filter = False
431 logger.debug(
432 f"Global skip_relevance_filter=True applied "
433 f"for {engine_name}"
434 )
436 # Apply the setting
437 if should_filter and hasattr(engine, "llm") and engine.llm:
438 engine.enable_llm_relevance_filter = True
439 logger.info(f"✓ Enabled LLM relevance filtering for {engine_name}")
440 elif should_filter:
441 logger.warning(
442 f"LLM relevance filtering requested for {engine_name} "
443 f"but no LLM is available — filtering skipped"
444 )
445 else:
446 logger.debug(f"LLM relevance filtering disabled for {engine_name}")
448 # Check if we need to wrap with full search capabilities
449 if kwargs.get("use_full_search", False) and engine_config.get(
450 "supports_full_search", False
451 ):
452 return _create_full_search_wrapper(
453 engine_name,
454 engine,
455 engine_config,
456 llm,
457 kwargs,
458 username,
459 settings_snapshot,
460 )
462 return engine # type: ignore[no-any-return]
464 except PolicyDeniedError:
465 # An engine __init__ (or a child engine / full-search wrapper it
466 # builds) may itself consult the PDP and raise. Re-raise so the
467 # denial surfaces to policy-aware callers and the audit trail,
468 # rather than being downgraded to a generic "failed to create"
469 # None — which would fail OPEN from an enforcement standpoint.
470 raise
471 except Exception as e:
472 safe_msg = scrub_error(e, api_key)
473 logger.exception(
474 f"Failed to create search engine '{engine_name}' ({type(e).__name__}): {safe_msg}"
475 )
476 return None
479def _create_full_search_wrapper(
480 engine_name: str,
481 base_engine: BaseSearchEngine,
482 engine_config: Dict[str, Any],
483 llm,
484 params: Dict[str, Any],
485 username: str | None = None,
486 settings_snapshot: Dict[str, Any] | None = None,
487) -> Optional[BaseSearchEngine]:
488 """Create a full search wrapper for the base engine if supported"""
489 try:
490 # Bound up here (re-populated below) so the except handler can
491 # always pull credential values out of it for scrub_error() —
492 # without the hoist, an exception before the dict is rebuilt would
493 # hit a NameError when trying to scrub the keys.
494 wrapper_params: Dict[str, Any] = {}
496 # Get full search class details from engine_config (already has
497 # registry-injected values from search_config()).
498 module_path = engine_config.get("full_search_module")
499 class_name = engine_config.get("full_search_class")
501 if not module_path or not class_name:
502 logger.warning(
503 f"Full search configuration missing for {engine_name}"
504 )
505 return base_engine
507 # Import the full search class
508 full_search_class = get_safe_module_class(module_path, class_name)
510 # Get the wrapper's __init__ parameters to filter out unsupported ones
511 wrapper_init_signature = inspect.signature(full_search_class.__init__)
512 wrapper_init_params = list(wrapper_init_signature.parameters.keys())[
513 1:
514 ] # Skip 'self'
516 # Extract relevant parameters for the full search wrapper
517 wrapper_params = {
518 k: v for k, v in params.items() if k in wrapper_init_params
519 }
521 # Special case for SerpAPI which needs the API key directly
522 if (
523 engine_name == "serpapi"
524 and "serpapi_api_key" in wrapper_init_params
525 ):
526 # Check settings snapshot for API key
527 serpapi_api_key = None
528 if settings_snapshot: 528 ↛ 538line 528 didn't jump to line 538 because the condition on line 528 was always true
529 serpapi_setting = settings_snapshot.get(
530 "search.engine.web.serpapi.api_key"
531 )
532 if serpapi_setting: 532 ↛ 538line 532 didn't jump to line 538 because the condition on line 532 was always true
533 serpapi_api_key = (
534 serpapi_setting.get("value")
535 if isinstance(serpapi_setting, dict)
536 else serpapi_setting
537 )
538 if serpapi_api_key: 538 ↛ 542line 538 didn't jump to line 542 because the condition on line 538 was always true
539 wrapper_params["serpapi_api_key"] = serpapi_api_key
541 # Map some parameter names to what the wrapper expects
542 if (
543 "language" in params
544 and "search_language" not in params
545 and "language" in wrapper_init_params
546 ):
547 wrapper_params["language"] = params["language"]
549 if (
550 "safesearch" not in wrapper_params
551 and "safe_search" in params
552 and "safesearch" in wrapper_init_params
553 ):
554 wrapper_params["safesearch"] = (
555 "active" if params["safe_search"] else "off"
556 )
558 # Special case for Brave which needs the API key directly
559 if engine_name == "brave" and "api_key" in wrapper_init_params:
560 # Check settings snapshot for API key
561 brave_api_key = None
562 if settings_snapshot:
563 brave_setting = settings_snapshot.get(
564 "search.engine.web.brave.api_key"
565 )
566 if brave_setting: 566 ↛ 573line 566 didn't jump to line 573 because the condition on line 566 was always true
567 brave_api_key = (
568 brave_setting.get("value")
569 if isinstance(brave_setting, dict)
570 else brave_setting
571 )
573 if brave_api_key:
574 wrapper_params["api_key"] = brave_api_key
576 # Map some parameter names to what the wrapper expects
577 if (
578 "language" in params
579 and "search_language" not in params
580 and "language" in wrapper_init_params
581 ):
582 wrapper_params["language"] = params["language"]
584 if (
585 "safesearch" not in wrapper_params
586 and "safe_search" in params
587 and "safesearch" in wrapper_init_params
588 ):
589 wrapper_params["safesearch"] = (
590 "moderate" if params["safe_search"] else "off"
591 )
593 # Always include llm if it's a parameter
594 if "llm" in wrapper_init_params:
595 wrapper_params["llm"] = llm
597 # If the wrapper needs the base engine and has a parameter for it
598 if "web_search" in wrapper_init_params:
599 wrapper_params["web_search"] = base_engine
601 logger.debug(
602 f"Creating full search wrapper for {engine_name} with filtered parameters: {wrapper_params.keys()}"
603 )
605 # Create the full search wrapper with filtered parameters
606 service: BaseSearchEngine = full_search_class(**wrapper_params)
607 return service
609 except Exception as e:
610 # Extract any credential values the wrapper was going to receive
611 # so a constructor exception echoing them gets literal-redacted
612 # (scrub_error skips None/falsy values and str-coerces the rest).
613 safe_msg = scrub_error(
614 e,
615 wrapper_params.get("api_key"),
616 wrapper_params.get("serpapi_api_key"),
617 )
618 logger.exception(
619 f"Failed to create full search wrapper for {engine_name} ({type(e).__name__}): {safe_msg}"
620 )
621 return base_engine
624def get_search(
625 search_tool: str,
626 llm_instance,
627 max_results: int = 10,
628 region: str = "us",
629 time_period: str = "y",
630 safe_search: bool = True,
631 search_snippets_only: bool = False,
632 search_language: str = "English",
633 max_filtered_results: Optional[int] = None,
634 settings_snapshot: Dict[str, Any] | None = None,
635 programmatic_mode: bool = False,
636):
637 """
638 Get search tool instance based on the provided parameters.
640 Args:
641 search_tool: Name of the search engine to use
642 llm_instance: Language model instance
643 max_results: Maximum number of search results
644 region: Search region/locale
645 time_period: Time period for search results
646 safe_search: Whether to enable safe search
647 search_snippets_only: Whether to return just snippets (vs. full content)
648 search_language: Language for search results
649 max_filtered_results: Maximum number of results to keep after filtering
650 programmatic_mode: If True, disables database operations and metrics tracking
652 Returns:
653 Initialized search engine instance
654 """
655 # Common parameters
656 params = {
657 "max_results": max_results,
658 "llm": llm_instance, # Only used by engines that need it
659 }
661 # Add max_filtered_results if provided
662 if max_filtered_results is not None:
663 params["max_filtered_results"] = max_filtered_results
665 # Add engine-specific parameters
666 if search_tool in [
667 "duckduckgo",
668 "serpapi",
669 "google_pse",
670 "brave",
671 "mojeek",
672 ]:
673 params.update(
674 {
675 "region": region,
676 "safe_search": safe_search,
677 "use_full_search": not search_snippets_only,
678 }
679 )
681 if search_tool in ["serpapi", "brave", "google_pse", "wikinews"]:
682 params["search_language"] = search_language
684 if search_tool == "tinyfish": 684 ↛ 685line 684 didn't jump to line 685 because the condition on line 684 was never true
685 params["location"] = region.upper()
686 params["language"] = search_language
687 params["search_snippets_only"] = search_snippets_only
689 if search_tool == "sofya":
690 # Sofya derives its request depth from this: the paid "basic" depth
691 # (inline page content) is only requested when full content is used.
692 params["search_snippets_only"] = search_snippets_only
694 if search_tool == "wikinews":
695 params["search_snippets_only"] = search_snippets_only
696 params["adaptive_search"] = bool(
697 (settings_snapshot or {})
698 .get("search.engine.web.wikinews.adaptive_search", {})
699 .get("value", True)
700 )
702 if search_tool in ["serpapi", "wikinews"]:
703 params["time_period"] = time_period
705 # Create and return the search engine
706 logger.info(
707 f"Creating search engine for tool: {search_tool} (type: {type(search_tool)}) with params: {params.keys()}"
708 )
709 logger.info(
710 f"About to call create_search_engine with search_tool={search_tool}, settings_snapshot type={type(settings_snapshot)}"
711 )
712 logger.info(
713 f"Params being passed to create_search_engine: {list(params.keys()) if isinstance(params, dict) else type(params)}"
714 )
716 engine = create_search_engine(
717 search_tool,
718 settings_snapshot=settings_snapshot,
719 programmatic_mode=programmatic_mode,
720 **params,
721 )
723 # Add debugging to check if engine is None
724 if engine is None:
725 logger.error(
726 f"Failed to create search engine for {search_tool} - returned None"
727 )
728 else:
729 engine_type = type(engine).__name__
730 logger.info(
731 f"Successfully created search engine of type: {engine_type}"
732 )
733 # Check if the engine has run method
734 if hasattr(engine, "run"): 734 ↛ 737line 734 didn't jump to line 737 because the condition on line 734 was always true
735 logger.info(f"Engine has 'run' method: {engine.run}")
736 else:
737 logger.error("Engine does NOT have 'run' method!")
739 # For SearxNG, check availability flag
740 if hasattr(engine, "is_available"):
741 logger.info(f"Engine availability flag: {engine.is_available}")
743 return engine