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

1import inspect 

2from typing import Any, Dict, Optional 

3 

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 

11 

12 

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. 

23 

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 

29 

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 ) 

37 

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. 

42 

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 ) 

61 

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 ) 

67 

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) 

102 

103 logger.info(f"Using registered LangChain retriever: {engine_name}") 

104 from .engines.search_engine_retriever import RetrieverSearchEngine 

105 

106 return RetrieverSearchEngine( 

107 retriever=retriever, 

108 name=engine_name, 

109 max_results=kwargs.get("max_results", 10), 

110 programmatic_mode=programmatic_mode, 

111 ) 

112 

113 # Extract search engine configs from settings snapshot 

114 if settings_snapshot: 

115 config = search_config( 

116 username=username, settings_snapshot=settings_snapshot 

117 ) 

118 

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 ) 

126 

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 ) 

137 

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 ) 

147 

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 ) 

164 

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 

177 

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 ) 

196 

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 ) 

209 

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 ) 

216 

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 

226 

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) 

243 

244 # Get engine configuration 

245 engine_config = config[engine_name] 

246 

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 

258 

259 # Check for API key requirements 

260 requires_api_key = engine_config.get("requires_api_key", False) 

261 

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 

266 

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" 

270 

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) 

273 

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 ) 

280 

281 # Still try to get from engine config if not found 

282 if not api_key: 

283 api_key = engine_config.get("api_key") 

284 

285 if not api_key: 

286 logger.info( 

287 f"Required API key for {engine_name} not found in settings." 

288 ) 

289 return None 

290 

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 

294 

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 ) 

304 

305 try: 

306 # Load the engine class 

307 module_path = engine_config["module_path"] 

308 class_name = engine_config["class_name"] 

309 

310 engine_class = get_safe_module_class(module_path, class_name) 

311 

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()) 

315 

316 # Combine default parameters with provided ones 

317 all_params = {**engine_config.get("default_params", {}), **kwargs} 

318 

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 } 

324 

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 

328 

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 

332 

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 ) 

346 

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 

355 

356 logger.info( 

357 f"Creating {engine_name} with filtered parameters: {filtered_params.keys()}" 

358 ) 

359 

360 # Create the engine instance with filtered parameters 

361 engine = engine_class(**filtered_params) 

362 

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 

367 

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) 

378 

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 

390 

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 ) 

435 

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}") 

447 

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 ) 

461 

462 return engine # type: ignore[no-any-return] 

463 

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 

477 

478 

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] = {} 

495 

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") 

500 

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 

506 

507 # Import the full search class 

508 full_search_class = get_safe_module_class(module_path, class_name) 

509 

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' 

515 

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 } 

520 

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 

540 

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"] 

548 

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 ) 

557 

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 ) 

572 

573 if brave_api_key: 

574 wrapper_params["api_key"] = brave_api_key 

575 

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"] 

583 

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 ) 

592 

593 # Always include llm if it's a parameter 

594 if "llm" in wrapper_init_params: 

595 wrapper_params["llm"] = llm 

596 

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 

600 

601 logger.debug( 

602 f"Creating full search wrapper for {engine_name} with filtered parameters: {wrapper_params.keys()}" 

603 ) 

604 

605 # Create the full search wrapper with filtered parameters 

606 service: BaseSearchEngine = full_search_class(**wrapper_params) 

607 return service 

608 

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 

622 

623 

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. 

639 

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 

651 

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 } 

660 

661 # Add max_filtered_results if provided 

662 if max_filtered_results is not None: 

663 params["max_filtered_results"] = max_filtered_results 

664 

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 ) 

680 

681 if search_tool in ["serpapi", "brave", "google_pse", "wikinews"]: 

682 params["search_language"] = search_language 

683 

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 

688 

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 

693 

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 ) 

701 

702 if search_tool in ["serpapi", "wikinews"]: 

703 params["time_period"] = time_period 

704 

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 ) 

715 

716 engine = create_search_engine( 

717 search_tool, 

718 settings_snapshot=settings_snapshot, 

719 programmatic_mode=programmatic_mode, 

720 **params, 

721 ) 

722 

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!") 

738 

739 # For SearxNG, check availability flag 

740 if hasattr(engine, "is_available"): 

741 logger.info(f"Engine availability flag: {engine.is_available}") 

742 

743 return engine