Coverage for src/local_deep_research/web_search_engines/search_engine_factory.py: 90%

256 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +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# Engines whose constructors accept the global ``search.time_period`` filter. 

13# Used both by get_search()'s explicit forwarding and by 

14# create_search_engine()'s settings-snapshot fallback so that direct callers 

15# (langgraph-agent strategy tools, MCP server) honor the setting too. 

16TIME_PERIOD_ENGINES = ("serpapi", "tavily", "wikinews") 

17 

18 

19def create_search_engine( 

20 engine_name: str, 

21 llm=None, 

22 username: str | None = None, 

23 settings_snapshot: Dict[str, Any] | None = None, 

24 programmatic_mode: bool = False, 

25 **kwargs, 

26) -> Optional[BaseSearchEngine]: 

27 """ 

28 Create a search engine instance based on the engine name. 

29 

30 Args: 

31 engine_name: Name of the search engine to create 

32 llm: Language model instance (passed to engines that accept one) 

33 programmatic_mode: If True, disables database operations and metrics tracking 

34 **kwargs: Additional parameters to override defaults 

35 

36 Returns: 

37 Initialized search engine instance or None if creation failed 

38 """ 

39 # Debug logging 

40 logger.info( 

41 f"create_search_engine called with engine_name={engine_name} (type: {type(engine_name)})" 

42 ) 

43 

44 # Resolve the effective username for per-user registry lookups. Callers 

45 # that thread it explicitly win; otherwise fall back to the ``_username`` 

46 # carried in the settings snapshot (injected by search_config.get_search / 

47 # AdvancedSearchSystem). None resolves the shared namespace only. 

48 if username is None: 48 ↛ 60line 48 didn't jump to line 60 because the condition on line 48 was always true

49 from ..search_system import username_from_snapshot 

50 

51 username = username_from_snapshot(settings_snapshot) 

52 

53 # Egress policy PEP runs AFTER retriever-registry + config lookup, so 

54 # registered retrievers don't go through engine-name classification. 

55 # The actual call site is right before engine instantiation, further 

56 # down. 

57 

58 # Check if this is a registered retriever first (the caller's own 

59 # retrievers resolve before shared/global ones). 

60 retriever = retriever_registry.get(engine_name, username=username) 

61 if retriever: 

62 # Egress policy: gate the retriever against the active scope. 

63 # Retrievers are classified at registration (is_local, default 

64 # True). Snapshot REQUIRED — previously the policy block was 

65 # gated on "if settings_snapshot:" which let snapshot=None 

66 # callers instantiate retrievers ungated. 

67 # NB: PolicyDeniedError is imported at MODULE level — importing it 

68 # again here (function-local) would shadow it across the whole 

69 # function and make the outer `except PolicyDeniedError` an unbound 

70 # local on code paths that skip this branch. 

71 from ..security.egress.policy import ( 

72 Decision, 

73 context_from_snapshot, 

74 evaluate_retriever, 

75 resolve_run_primary_engine, 

76 ) 

77 

78 if not settings_snapshot: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true

79 raise PolicyDeniedError( 

80 Decision(False, "no_snapshot"), 

81 target=engine_name, 

82 ) 

83 

84 primary = resolve_run_primary_engine( 

85 settings_snapshot, default=engine_name 

86 ) 

87 try: 

88 _ctx = context_from_snapshot( 

89 settings_snapshot, 

90 primary, 

91 username=username, 

92 ) 

93 except PolicyDeniedError: 

94 raise 

95 except ValueError as exc: 

96 raise PolicyDeniedError( 

97 Decision(False, "invalid_policy_config"), 

98 target=engine_name, 

99 ) from exc 

100 # Pass metadata from the SAME registry reference used for 

101 # .get() above, so evaluate_retriever doesn't re-read a 

102 # different (e.g. test-patched) global singleton. 

103 try: 

104 _meta = retriever_registry.get_metadata( 

105 engine_name, username=username 

106 ) 

107 except AttributeError: 

108 _meta = None 

109 _decision = evaluate_retriever(engine_name, _ctx, metadata=_meta) 

110 if not _decision.allowed: 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true

111 logger.bind(policy_audit=True).warning( 

112 "retriever denied by egress policy", 

113 retriever=engine_name, 

114 scope=_ctx.scope.value, 

115 reason=_decision.reason, 

116 ) 

117 raise PolicyDeniedError(_decision, target=engine_name) 

118 

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

120 from .engines.search_engine_retriever import RetrieverSearchEngine 

121 

122 return RetrieverSearchEngine( 

123 retriever=retriever, 

124 name=engine_name, 

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

126 programmatic_mode=programmatic_mode, 

127 ) 

128 

129 # Extract search engine configs from settings snapshot 

130 if settings_snapshot: 

131 config = search_config( 

132 username=username, settings_snapshot=settings_snapshot 

133 ) 

134 

135 logger.debug( 

136 f"Extracted search engines from snapshot: {list(config.keys())}" 

137 ) 

138 else: 

139 raise RuntimeError( 

140 "settings_snapshot is required for search engine creation in threads" 

141 ) 

142 

143 if engine_name == "none": 

144 # Reject the literal string "none". Historically this silently fell 

145 # through to a meta engine and hit live networks — callers that 

146 # wanted an offline pipeline were unknowingly doing real searches. 

147 raise ValueError( 

148 "search.tool='none' is not a valid engine. Register a LangChain " 

149 "retriever via `retrievers={...}` (see " 

150 "examples/llm_integration/mock_llm_example.py) or pick a real " 

151 "engine." 

152 ) 

153 

154 if engine_name not in config: 

155 # Deprecated path: some older callers pass UI display labels instead of 

156 # config keys. Display labels look like "{icon} {base_name} ({category})" 

157 # e.g. "🔬 OpenAlex (Scientific)". Only attempt (and warn about) 

158 # this fallback when the name actually matches that shape — plain keys 

159 # like "collection_<uuid>" never will, and the warning was pure noise. 

160 looks_like_display_label = " (" in engine_name and engine_name.endswith( 

161 ")" 

162 ) 

163 if looks_like_display_label: 

164 logger.warning( 

165 f"Engine '{engine_name}' not found in config - attempting " 

166 "display label fallback. This is deprecated; callers should " 

167 "pass the config key directly." 

168 ) 

169 

170 # To avoid ReDoS, use string ops instead of regex. 

171 # Pattern: icon, space, base_name, space, (category) 

172 parts = engine_name.rsplit(" (", 1) 

173 if len(parts) == 2: 173 ↛ 201line 173 didn't jump to line 201 because the condition on line 173 was always true

174 before_paren = parts[0] 

175 space_idx = before_paren.find(" ") 

176 if space_idx > 0: 

177 base_name = before_paren[space_idx + 1 :].strip() 

178 logger.info( 

179 f"Extracted base name '{base_name}' from label " 

180 f"'{engine_name}'" 

181 ) 

182 

183 for config_key, config_data in config.items(): 

184 if isinstance(config_data, dict): 184 ↛ 183line 184 didn't jump to line 183 because the condition on line 184 was always true

185 display_name = config_data.get( 

186 "display_name", config_key 

187 ) 

188 if display_name == base_name: 

189 logger.info( 

190 f"Matched label to config key: " 

191 f"'{engine_name}' -> '{config_key}'" 

192 ) 

193 engine_name = config_key 

194 break 

195 

196 # If still not found, FAIL CLOSED. 

197 # We raise a plain ValueError (not PolicyDeniedError) because 

198 # "unknown engine name" is a config/wiring error, not a policy 

199 # decision — keeping the exception type clean lets policy-aware 

200 # callers tell them apart. 

201 if engine_name not in config: 

202 logger.bind(policy_audit=True).warning( 

203 "unknown engine name rejected", 

204 engine=engine_name, 

205 available=list(config.keys()), 

206 ) 

207 raise ValueError( 

208 f"Unknown search engine '{engine_name}'. Available: " 

209 f"{sorted(config.keys())}. The 'auto', 'meta', 'parallel' " 

210 "and 'parallel_scientific' meta engines were removed — the " 

211 "langgraph-agent strategy (the default) selects engines " 

212 "dynamically; pick a concrete engine instead." 

213 ) 

214 

215 # Egress policy PEP: gate the resolved engine against the user's declared 

216 # scope. Lazy import to break the circular dependency 

217 # (security/egress/policy.py → web_search_engines/* → security/*). 

218 # PolicyDeniedError comes from the module-level import (see the note 

219 # in the retriever branch above — re-importing it here would shadow 

220 # it function-wide and break the outer except clause). 

221 from ..security.egress.policy import ( 

222 Decision, 

223 context_from_snapshot, 

224 evaluate_engine, 

225 resolve_run_primary_engine, 

226 ) 

227 

228 # Resolve the run's primary via the shared helper so the scope this PEP 

229 # enforces matches the one the LangGraph tool-list filter pre-filters with 

230 # (default: this engine when search.tool is unset). 

231 primary_engine = resolve_run_primary_engine( 

232 settings_snapshot, default=engine_name 

233 ) 

234 

235 try: 

236 ctx = context_from_snapshot( 

237 settings_snapshot, primary_engine, username=username 

238 ) 

239 except ValueError as exc: 

240 raise PolicyDeniedError( 

241 Decision(False, "invalid_policy_config"), 

242 target=engine_name, 

243 ) from exc 

244 

245 # Pass the engine config entry as metadata so a per-collection 

246 # is_public classification is honored without a redundant DB lookup. 

247 decision = evaluate_engine( 

248 engine_name, 

249 ctx, 

250 settings_snapshot=settings_snapshot, 

251 metadata=config.get(engine_name), 

252 ) 

253 if not decision.allowed: 

254 logger.bind(policy_audit=True).warning( 

255 "engine denied by egress policy", 

256 engine=engine_name, 

257 scope=ctx.scope.value, 

258 reason=decision.reason, 

259 ) 

260 raise PolicyDeniedError(decision, target=engine_name) 

261 

262 # Get engine configuration 

263 engine_config = config[engine_name] 

264 

265 # Set default max_results from config if not provided in kwargs 

266 if "max_results" not in kwargs: 

267 if settings_snapshot and "search.max_results" in settings_snapshot: 

268 max_results = ( 

269 settings_snapshot["search.max_results"].get("value", 20) 

270 if isinstance(settings_snapshot["search.max_results"], dict) 

271 else settings_snapshot["search.max_results"] 

272 ) 

273 else: 

274 max_results = 20 

275 kwargs["max_results"] = max_results 

276 

277 # Route the global time filter to engines that understand it when the 

278 # caller didn't pass one explicitly. get_search() forwards it as a kwarg, 

279 # but direct callers (langgraph-agent strategy tools, the MCP server) 

280 # only pass a settings_snapshot — without this fallback the user's 

281 # Settings → Search → Time Period choice is silently ignored on those 

282 # paths. Placed in kwargs so it takes precedence over per-engine 

283 # default_params, matching get_search()'s behavior. 

284 if ( 

285 engine_name in TIME_PERIOD_ENGINES 

286 and "time_period" not in kwargs 

287 and settings_snapshot 

288 and "search.time_period" in settings_snapshot 

289 ): 

290 raw_time_period = settings_snapshot["search.time_period"] 

291 kwargs["time_period"] = ( 

292 raw_time_period.get("value", "all") 

293 if isinstance(raw_time_period, dict) 

294 else raw_time_period 

295 ) 

296 

297 # Check for API key requirements 

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

299 

300 # Bound here (not just inside ``if requires_api_key``) so the except 

301 # handler below can pass the in-scope key to scrub_error() — without 

302 # the hoist, the no-key path would hit a NameError when scrubbing. 

303 api_key = None 

304 

305 if requires_api_key: 

306 # Check the settings snapshot for the API key 

307 api_key_path = f"search.engine.web.{engine_name}.api_key" 

308 

309 if settings_snapshot: 309 ↛ 320line 309 didn't jump to line 320 because the condition on line 309 was always true

310 api_key_setting = settings_snapshot.get(api_key_path) 

311 

312 if api_key_setting: 

313 api_key = ( 

314 api_key_setting.get("value") 

315 if isinstance(api_key_setting, dict) 

316 else api_key_setting 

317 ) 

318 

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

320 if not api_key: 

321 api_key = engine_config.get("api_key") 

322 

323 if not api_key: 

324 logger.info( 

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

326 ) 

327 return None 

328 

329 # Pass the API key in kwargs for engines that need it 

330 if api_key: 330 ↛ 336line 330 didn't jump to line 336 because the condition on line 330 was always true

331 kwargs["api_key"] = api_key 

332 

333 # Warn about missing LLM but allow engine creation in degraded mode. 

334 # All engines with requires_llm=True handle llm=None gracefully 

335 # (e.g. skipping query optimization, using reliability-based sorting). 

336 if engine_config.get("requires_llm", False) and not llm: 

337 logger.warning( 

338 f"Engine '{engine_name}' is configured with requires_llm=True but no LLM provided. " 

339 f"Creating engine without LLM — some features (query optimization, relevance filtering) " 

340 f"may be unavailable." 

341 ) 

342 

343 try: 

344 # Load the engine class 

345 module_path = engine_config["module_path"] 

346 class_name = engine_config["class_name"] 

347 

348 engine_class = get_safe_module_class(module_path, class_name) 

349 

350 # Get the engine class's __init__ parameters to filter out unsupported ones 

351 engine_init_signature = inspect.signature(engine_class.__init__) 

352 engine_init_params = list(engine_init_signature.parameters.keys()) 

353 

354 # Combine default parameters with provided ones 

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

356 

357 # Filter out parameters that aren't accepted by the engine class 

358 # Note: 'self' is always the first parameter of instance methods, so we skip it 

359 filtered_params = { 

360 k: v for k, v in all_params.items() if k in engine_init_params[1:] 

361 } 

362 

363 # Always pass settings_snapshot if the engine accepts it 

364 if "settings_snapshot" in engine_init_params[1:] and settings_snapshot: 

365 filtered_params["settings_snapshot"] = settings_snapshot 

366 

367 # Pass programmatic_mode if the engine accepts it 

368 if "programmatic_mode" in engine_init_params[1:]: 

369 filtered_params["programmatic_mode"] = programmatic_mode 

370 

371 # Add LLM if required OR if provided and engine accepts it 

372 if engine_config.get("requires_llm", False): 

373 filtered_params["llm"] = llm 

374 elif ( 

375 "llm" in engine_init_params[1:] 

376 and llm 

377 and "llm" not in filtered_params 

378 ): 

379 # If LLM was provided and engine accepts it, pass it through 

380 filtered_params["llm"] = llm 

381 logger.info( 

382 f"Passing LLM to {engine_name} (engine accepts it and LLM was provided)" 

383 ) 

384 

385 # Add API key if required and not already in filtered_params 

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

387 engine_config.get("requires_api_key", False) 

388 and "api_key" not in filtered_params 

389 ): 

390 # Use the api_key we got earlier from settings 

391 if api_key: 

392 filtered_params["api_key"] = api_key 

393 

394 logger.info( 

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

396 ) 

397 

398 # Create the engine instance with filtered parameters 

399 engine = engine_class(**filtered_params) 

400 

401 # Stamp the registry name so BaseSearchEngine._verify_egress_scope() 

402 # can check egress scope at run time. 

403 if isinstance(engine, BaseSearchEngine): 

404 engine._engine_name = engine_name 

405 

406 # Most engine subclasses do not name ``programmatic_mode`` in their 

407 # signature (or accept it via **kwargs without forwarding to 

408 # ``super().__init__``), so the constructor often falls back to the 

409 # BaseSearchEngine default of False even when the API caller asked 

410 # for True. Apply the requested mode post-construction so the 

411 # engine's rate tracker matches. 

412 if isinstance(engine, BaseSearchEngine) and ( 

413 engine.programmatic_mode != programmatic_mode 

414 ): 

415 engine._configure_programmatic_mode(programmatic_mode) 

416 

417 # Determine if this engine should use LLM relevance filtering 

418 # Priority: per-engine setting > needs_llm_relevance_filter > global setting 

419 # 

420 # Rationale: 

421 # - Engines with needs_llm_relevance_filter=True have poor native relevance ranking 

422 # (keyword-only, no ML ranking) and benefit from LLM-based filtering 

423 # - Well-ranked engines (Google, Brave) and semantic engines (Exa, Tavily) 

424 # do not need this and should not waste LLM calls 

425 # - The global skip_relevance_filter only affects unclassified engines 

426 # - CrossEngineFilter still ranks combined results at the strategy level 

427 should_filter = False 

428 

429 # Check for per-engine setting first (highest priority) 

430 per_engine_key = f"search.engine.web.{engine_name}.default_params.enable_llm_relevance_filter" 

431 if settings_snapshot and per_engine_key in settings_snapshot: 

432 per_engine_setting = settings_snapshot[per_engine_key] 

433 should_filter = ( 

434 per_engine_setting.get("value", False) 

435 if isinstance(per_engine_setting, dict) 

436 else per_engine_setting 

437 ) 

438 logger.info( 

439 f"Using per-engine setting for {engine_name}: " 

440 f"enable_llm_relevance_filter={should_filter}" 

441 ) 

442 else: 

443 # Auto-detection based on engine attribute (medium priority) 

444 if ( 

445 hasattr(engine_class, "needs_llm_relevance_filter") 

446 and engine_class.needs_llm_relevance_filter 

447 ): 

448 should_filter = True 

449 logger.info( 

450 f"Auto-enabling LLM filtering for {engine_name} " 

451 f"(needs_llm_relevance_filter=True)" 

452 ) 

453 else: 

454 # Global override only applies to engines without needs_llm_relevance_filter 

455 if ( 

456 settings_snapshot 

457 and "search.skip_relevance_filter" in settings_snapshot 

458 ): 

459 skip_filter_setting = settings_snapshot[ 

460 "search.skip_relevance_filter" 

461 ] 

462 skip_filter = ( 

463 skip_filter_setting.get("value", False) 

464 if isinstance(skip_filter_setting, dict) 

465 else skip_filter_setting 

466 ) 

467 if skip_filter: 467 ↛ 475line 467 didn't jump to line 475 because the condition on line 467 was always true

468 should_filter = False 

469 logger.debug( 

470 f"Global skip_relevance_filter=True applied " 

471 f"for {engine_name}" 

472 ) 

473 

474 # Apply the setting 

475 if should_filter and hasattr(engine, "llm") and engine.llm: 

476 engine.enable_llm_relevance_filter = True 

477 logger.info(f"✓ Enabled LLM relevance filtering for {engine_name}") 

478 elif should_filter: 

479 logger.warning( 

480 f"LLM relevance filtering requested for {engine_name} " 

481 f"but no LLM is available — filtering skipped" 

482 ) 

483 else: 

484 logger.debug(f"LLM relevance filtering disabled for {engine_name}") 

485 

486 # Check if we need to wrap with full search capabilities 

487 if kwargs.get("use_full_search", False) and engine_config.get( 

488 "supports_full_search", False 

489 ): 

490 return _create_full_search_wrapper( 

491 engine_name, 

492 engine, 

493 engine_config, 

494 llm, 

495 kwargs, 

496 username, 

497 settings_snapshot, 

498 ) 

499 

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

501 

502 except PolicyDeniedError: 

503 # An engine __init__ (or a child engine / full-search wrapper it 

504 # builds) may itself consult the PDP and raise. Re-raise so the 

505 # denial surfaces to policy-aware callers and the audit trail, 

506 # rather than being downgraded to a generic "failed to create" 

507 # None — which would fail OPEN from an enforcement standpoint. 

508 raise 

509 except Exception as e: 

510 safe_msg = scrub_error(e, api_key) 

511 logger.exception( 

512 f"Failed to create search engine '{engine_name}' ({type(e).__name__}): {safe_msg}" 

513 ) 

514 return None 

515 

516 

517def _create_full_search_wrapper( 

518 engine_name: str, 

519 base_engine: BaseSearchEngine, 

520 engine_config: Dict[str, Any], 

521 llm, 

522 params: Dict[str, Any], 

523 username: str | None = None, 

524 settings_snapshot: Dict[str, Any] | None = None, 

525) -> Optional[BaseSearchEngine]: 

526 """Create a full search wrapper for the base engine if supported""" 

527 try: 

528 # Bound up here (re-populated below) so the except handler can 

529 # always pull credential values out of it for scrub_error() — 

530 # without the hoist, an exception before the dict is rebuilt would 

531 # hit a NameError when trying to scrub the keys. 

532 wrapper_params: Dict[str, Any] = {} 

533 

534 # Get full search class details from engine_config (already has 

535 # registry-injected values from search_config()). 

536 module_path = engine_config.get("full_search_module") 

537 class_name = engine_config.get("full_search_class") 

538 

539 if not module_path or not class_name: 

540 logger.warning( 

541 f"Full search configuration missing for {engine_name}" 

542 ) 

543 return base_engine 

544 

545 # Import the full search class 

546 full_search_class = get_safe_module_class(module_path, class_name) 

547 

548 # Get the wrapper's __init__ parameters to filter out unsupported ones 

549 wrapper_init_signature = inspect.signature(full_search_class.__init__) 

550 wrapper_init_params = list(wrapper_init_signature.parameters.keys())[ 

551 1: 

552 ] # Skip 'self' 

553 

554 # Extract relevant parameters for the full search wrapper 

555 wrapper_params = { 

556 k: v for k, v in params.items() if k in wrapper_init_params 

557 } 

558 

559 # Special case for SerpAPI which needs the API key directly 

560 if ( 

561 engine_name == "serpapi" 

562 and "serpapi_api_key" in wrapper_init_params 

563 ): 

564 # Check settings snapshot for API key 

565 serpapi_api_key = None 

566 if settings_snapshot: 566 ↛ 576line 566 didn't jump to line 576 because the condition on line 566 was always true

567 serpapi_setting = settings_snapshot.get( 

568 "search.engine.web.serpapi.api_key" 

569 ) 

570 if serpapi_setting: 570 ↛ 576line 570 didn't jump to line 576 because the condition on line 570 was always true

571 serpapi_api_key = ( 

572 serpapi_setting.get("value") 

573 if isinstance(serpapi_setting, dict) 

574 else serpapi_setting 

575 ) 

576 if serpapi_api_key: 576 ↛ 580line 576 didn't jump to line 580 because the condition on line 576 was always true

577 wrapper_params["serpapi_api_key"] = serpapi_api_key 

578 

579 # Map some parameter names to what the wrapper expects 

580 if ( 

581 "language" in params 

582 and "search_language" not in params 

583 and "language" in wrapper_init_params 

584 ): 

585 wrapper_params["language"] = params["language"] 

586 

587 if ( 

588 "safesearch" not in wrapper_params 

589 and "safe_search" in params 

590 and "safesearch" in wrapper_init_params 

591 ): 

592 wrapper_params["safesearch"] = ( 

593 "active" if params["safe_search"] else "off" 

594 ) 

595 

596 # Special case for Brave which needs the API key directly 

597 if engine_name == "brave" and "api_key" in wrapper_init_params: 

598 # Check settings snapshot for API key 

599 brave_api_key = None 

600 if settings_snapshot: 

601 brave_setting = settings_snapshot.get( 

602 "search.engine.web.brave.api_key" 

603 ) 

604 if brave_setting: 604 ↛ 611line 604 didn't jump to line 611 because the condition on line 604 was always true

605 brave_api_key = ( 

606 brave_setting.get("value") 

607 if isinstance(brave_setting, dict) 

608 else brave_setting 

609 ) 

610 

611 if brave_api_key: 

612 wrapper_params["api_key"] = brave_api_key 

613 

614 # Map some parameter names to what the wrapper expects 

615 if ( 

616 "language" in params 

617 and "search_language" not in params 

618 and "language" in wrapper_init_params 

619 ): 

620 wrapper_params["language"] = params["language"] 

621 

622 if ( 

623 "safesearch" not in wrapper_params 

624 and "safe_search" in params 

625 and "safesearch" in wrapper_init_params 

626 ): 

627 wrapper_params["safesearch"] = ( 

628 "moderate" if params["safe_search"] else "off" 

629 ) 

630 

631 # Always include llm if it's a parameter 

632 if "llm" in wrapper_init_params: 

633 wrapper_params["llm"] = llm 

634 

635 # If the wrapper needs the base engine and has a parameter for it 

636 if "web_search" in wrapper_init_params: 

637 wrapper_params["web_search"] = base_engine 

638 

639 logger.debug( 

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

641 ) 

642 

643 # Create the full search wrapper with filtered parameters 

644 service: BaseSearchEngine = full_search_class(**wrapper_params) 

645 return service 

646 

647 except Exception as e: 

648 # Extract any credential values the wrapper was going to receive 

649 # so a constructor exception echoing them gets literal-redacted 

650 # (scrub_error skips None/falsy values and str-coerces the rest). 

651 safe_msg = scrub_error( 

652 e, 

653 wrapper_params.get("api_key"), 

654 wrapper_params.get("serpapi_api_key"), 

655 ) 

656 logger.exception( 

657 f"Failed to create full search wrapper for {engine_name} ({type(e).__name__}): {safe_msg}" 

658 ) 

659 return base_engine 

660 

661 

662def get_search( 

663 search_tool: str, 

664 llm_instance, 

665 max_results: int = 10, 

666 region: str = "us", 

667 time_period: str = "all", 

668 safe_search: bool = True, 

669 search_snippets_only: bool = False, 

670 search_language: str = "English", 

671 max_filtered_results: Optional[int] = None, 

672 settings_snapshot: Dict[str, Any] | None = None, 

673 programmatic_mode: bool = False, 

674): 

675 """ 

676 Get search tool instance based on the provided parameters. 

677 

678 Args: 

679 search_tool: Name of the search engine to use 

680 llm_instance: Language model instance 

681 max_results: Maximum number of search results 

682 region: Search region/locale 

683 time_period: Time period for search results 

684 safe_search: Whether to enable safe search 

685 search_snippets_only: Whether to return just snippets (vs. full content) 

686 search_language: Language for search results 

687 max_filtered_results: Maximum number of results to keep after filtering 

688 programmatic_mode: If True, disables database operations and metrics tracking 

689 

690 Returns: 

691 Initialized search engine instance 

692 """ 

693 # Common parameters 

694 params = { 

695 "max_results": max_results, 

696 "llm": llm_instance, # Only used by engines that need it 

697 } 

698 

699 # Add max_filtered_results if provided 

700 if max_filtered_results is not None: 

701 params["max_filtered_results"] = max_filtered_results 

702 

703 # Add engine-specific parameters 

704 if search_tool in [ 

705 "duckduckgo", 

706 "serpapi", 

707 "google_pse", 

708 "brave", 

709 "mojeek", 

710 ]: 

711 params.update( 

712 { 

713 "region": region, 

714 "safe_search": safe_search, 

715 "use_full_search": not search_snippets_only, 

716 } 

717 ) 

718 

719 if search_tool == "searxng": 

720 params["use_full_search"] = not search_snippets_only 

721 

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

723 params["search_language"] = search_language 

724 

725 if search_tool == "tinyfish": 725 ↛ 726line 725 didn't jump to line 726 because the condition on line 725 was never true

726 params["location"] = region.upper() 

727 params["language"] = search_language 

728 params["search_snippets_only"] = search_snippets_only 

729 

730 if search_tool == "sofya": 

731 # Sofya derives its request depth from this: the paid "basic" depth 

732 # (inline page content) is only requested when full content is used. 

733 params["search_snippets_only"] = search_snippets_only 

734 

735 if search_tool == "wikinews": 

736 params["search_snippets_only"] = search_snippets_only 

737 params["adaptive_search"] = bool( 

738 (settings_snapshot or {}) 

739 .get("search.engine.web.wikinews.adaptive_search", {}) 

740 .get("value", True) 

741 ) 

742 

743 if search_tool in TIME_PERIOD_ENGINES: 

744 params["time_period"] = time_period 

745 

746 # Create and return the search engine 

747 logger.info( 

748 f"Creating search engine for tool: {search_tool} (type: {type(search_tool)}) with params: {params.keys()}" 

749 ) 

750 logger.info( 

751 f"About to call create_search_engine with search_tool={search_tool}, settings_snapshot type={type(settings_snapshot)}" 

752 ) 

753 logger.info( 

754 f"Params being passed to create_search_engine: {list(params.keys()) if isinstance(params, dict) else type(params)}" 

755 ) 

756 

757 engine = create_search_engine( 

758 search_tool, 

759 settings_snapshot=settings_snapshot, 

760 programmatic_mode=programmatic_mode, 

761 **params, 

762 ) 

763 

764 # Add debugging to check if engine is None 

765 if engine is None: 

766 logger.error( 

767 f"Failed to create search engine for {search_tool} - returned None" 

768 ) 

769 else: 

770 engine_type = type(engine).__name__ 

771 logger.info( 

772 f"Successfully created search engine of type: {engine_type}" 

773 ) 

774 # Check if the engine has run method 

775 if hasattr(engine, "run"): 775 ↛ 778line 775 didn't jump to line 778 because the condition on line 775 was always true

776 logger.info(f"Engine has 'run' method: {engine.run}") 

777 else: 

778 logger.error("Engine does NOT have 'run' method!") 

779 

780 # Check engine availability flag / method 

781 avail = None 

782 if getattr(engine, "_is_available", None) is not None: 

783 avail = getattr(engine, "_is_available") 

784 elif hasattr(engine, "is_available"): 

785 if callable(engine.is_available): 785 ↛ 808line 785 didn't jump to line 808 because the condition on line 785 was always true

786 try: 

787 avail = engine.is_available( 

788 settings_snapshot=settings_snapshot 

789 ) 

790 except TypeError: 

791 try: 

792 avail = engine.is_available() 

793 except Exception as exc: 

794 logger.debug( 

795 "Engine availability fallback failed for {} ({})", 

796 engine_type, 

797 type(exc).__name__, 

798 ) 

799 avail = None 

800 except Exception as exc: 

801 logger.debug( 

802 "Engine availability check failed for {} ({})", 

803 engine_type, 

804 type(exc).__name__, 

805 ) 

806 avail = None 

807 else: 

808 avail = engine.is_available 

809 if avail is not None: 

810 logger.info(f"Engine availability flag: {avail}") 

811 

812 return engine