Coverage for src/local_deep_research/web_search_engines/search_engine_base.py: 96%

401 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1import json 

2import time 

3from abc import ABC, abstractmethod 

4from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union 

5from urllib.parse import urlparse 

6 

7from langchain_core.language_models import BaseLLM 

8from ..security.secure_logging import logger 

9from tenacity import ( 

10 RetryError, 

11 retry, 

12 retry_if_exception_type, 

13 stop_after_attempt, 

14) 

15from tenacity.wait import wait_base 

16 

17from ..advanced_search_system.filters.base_filter import BaseFilter 

18from ..utilities.type_utils import unwrap_setting 

19from ..config.constants import DEFAULT_MAX_FILTERED_RESULTS 

20from ..config.thread_settings import get_setting_from_snapshot 

21from ..security.log_sanitizer import sanitize_error_message, scrub_error 

22from ..security.egress.classification import Exposure, Sensitivity 

23from ..utilities.thread_context import clear_search_context, set_search_context 

24 

25from .rate_limiting import RateLimitError, get_tracker 

26from ..constants import DEFAULT_SEARCH_TOOL 

27 

28if TYPE_CHECKING: 

29 from ..advanced_search_system.filters.journal_reputation_filter import ( 

30 JournalReputationFilter, 

31 ) 

32 

33 

34# Common placeholder values that should never be treated as a real API key. 

35# Three call sites previously had inconsistent subsets of this list, which 

36# let invalid placeholders silently through the production path in 

37# search_engines_config.py. Centralizing here so all three stay in sync. 

38# 

39# All entries are lowercased — comparison is case-insensitive (see 

40# ``_is_api_key_placeholder``). Substring patterns like ``endswith("_API_KEY")`` 

41# are intentionally NOT used: they silently rejected legitimate keys that 

42# happened to contain those substrings (e.g. ``sk-real-secret_API_KEY``). 

43# Users who paste an env-var name like ``BRAVE_API_KEY`` instead of its value 

44# will get a clear API-call-time auth failure instead. 

45API_KEY_PLACEHOLDERS = frozenset( 

46 { 

47 "", 

48 "none", 

49 "null", 

50 "placeholder", 

51 # Underscore forms 

52 "your_api_key_here", 

53 "your_api_key", 

54 "api_key", 

55 "insert_api_key_here", 

56 "insert_api_key", 

57 # Hyphenated forms shipped in ``src/local_deep_research/defaults/.env.template`` 

58 # or ``docs/env_configuration.md`` as the literal value users are told to 

59 # copy-paste. Each must be rejected verbatim. Drift is enforced by 

60 # ``TestEnvTemplatePlaceholdersCovered`` and ``TestEnvDocsPlaceholdersCovered``. 

61 "your-api-key-here", 

62 "your-anthropic-key-here", 

63 "your-openai-key-here", 

64 "your-openrouter-key-here", 

65 "your-google-api-key-here", 

66 "your-brave-key-here", 

67 "your-serpapi-key-here", 

68 "your-tavily-key-here", 

69 # Defensive extras — plausible variations users might type but not in 

70 # any template/docs. Listed explicitly so they don't silently accumulate. 

71 "your-api-key", # without -here suffix 

72 "your-key-here", # without api- prefix 

73 "your-azure-key", # Azure provider variant 

74 } 

75) 

76 

77# Canonical short codes for time-restricted search (the global 

78# ``search.time_period`` setting minus "all", which means "no filter" and is 

79# expressed by omitting the engine's recency param entirely). Engines that 

80# forward a recency filter to their API (brave, serpapi, tavily) validate 

81# against this shared set so the accepted codes can't drift apart. 

82VALID_TIME_PERIODS = frozenset({"d", "w", "m", "y"}) 

83 

84 

85def _is_api_key_placeholder(api_key: Optional[str]) -> bool: 

86 """Return True if ``api_key`` looks like a placeholder, not a real key. 

87 

88 Catches: 

89 - Exact-match placeholders, case-insensitively (see ``API_KEY_PLACEHOLDERS``) 

90 - Angle-bracket templates: ``<...>`` (e.g. ``<your_api_key>``) 

91 - Shell-variable templates: ``${...}`` (e.g. ``${BRAVE_API_KEY}``) 

92 """ 

93 if not api_key: 

94 return True 

95 api_key = api_key.strip().lower() 

96 if api_key in API_KEY_PLACEHOLDERS: 

97 return True 

98 if api_key.startswith("<") and api_key.endswith(">"): 

99 return True 

100 if api_key.startswith("${") and api_key.endswith("}"): 

101 return True 

102 return False 

103 

104 

105class AdaptiveWait(wait_base): 

106 """Custom wait strategy that uses adaptive rate limiting.""" 

107 

108 def __init__(self, get_wait_func): 

109 self.get_wait_func = get_wait_func 

110 

111 def __call__(self, retry_state): 

112 return self.get_wait_func() 

113 

114 

115class BaseSearchEngine(ABC): 

116 """ 

117 Abstract base class for search engines with two-phase retrieval capability. 

118 Handles common parameters and implements the two-phase search approach. 

119 

120 Subclass contract for ``__init__`` 

121 --------------------------------- 

122 Concrete engines should forward ``settings_snapshot`` and 

123 ``programmatic_mode`` to ``super().__init__`` so the base class can wire 

124 them up correctly. The cleanest way is to declare ``**kwargs`` and pass 

125 it along:: 

126 

127 def __init__(self, max_results=10, *, my_param=None, **kwargs): 

128 super().__init__(max_results=max_results, **kwargs) 

129 self.my_param = my_param 

130 

131 Many existing engines accept ``**kwargs`` but don't forward — that 

132 silently drops ``programmatic_mode`` (and used to bind the wrong rate 

133 tracker). The factory has a post-construction safety net that calls 

134 ``_configure_programmatic_mode`` when the engine's mode doesn't match 

135 what the caller asked for, but new engines should not rely on it: the 

136 safety net only covers ``programmatic_mode``, not ``settings_snapshot`` 

137 or other base kwargs. 

138 """ 

139 

140 # Class attribute to indicate if this engine searches public internet sources 

141 # Should be overridden by subclasses - defaults to False for safety 

142 is_public = False 

143 

144 # Class attribute to indicate if this is a generic search engine (vs specialized) 

145 # Generic engines are general web search (Google, Bing, etc) vs specialized (arXiv, PubMed). 

146 # Note: generic does NOT imply good native ranking — see is_lexical. 

147 is_generic = False 

148 

149 # Class attribute to indicate if this is a scientific/academic search engine 

150 # Scientific engines include arXiv, PubMed, Semantic Scholar, etc. 

151 is_scientific = False 

152 

153 # Class attribute to indicate if this is a local RAG/document search engine 

154 # Local engines search private document collections stored locally 

155 is_local = False 

156 

157 # Egress data classification (ADR-0007). Two orthogonal axes, declared 

158 # explicitly per engine alongside is_public/is_local. The defaults fail 

159 # closed: an engine that forgets to classify itself is treated as the most 

160 # restrictive quadrant (sensitive data + exposing sink → usable only by 

161 # itself). Engines whose label depends on configuration (a collection's 

162 # is_public flag, a self-hosted store's URL) are refined by the classifier 

163 # at run time; these constants are the static fallback. 

164 egress_sensitivity = Sensitivity.SENSITIVE 

165 egress_exposure = Exposure.EXPOSING 

166 

167 # Class attribute to indicate if this is a news search engine 

168 # News engines specialize in news articles and current events 

169 is_news = False 

170 

171 # Class attribute to indicate if this is a code search engine 

172 # Code engines specialize in searching code repositories 

173 is_code = False 

174 

175 # Class attribute to indicate if this is a book/literature search engine 

176 # Book engines search libraries and literary archives 

177 is_books = False 

178 

179 # Classification: does this engine use lexical/keyword-based search? 

180 # Lexical engines (arXiv, PubMed, Wikipedia, Mojeek, etc.) match results by 

181 # keywords without ML-based ranking. This is an informational flag that can 

182 # drive multiple behaviors (query optimization, result deduplication, UI hints). 

183 # For LLM relevance filtering specifically, see needs_llm_relevance_filter. 

184 is_lexical = False 

185 

186 # Behavioral: should the factory auto-enable LLM relevance filtering? 

187 # When True, the factory sets enable_llm_relevance_filter=True on the engine 

188 # instance, causing _filter_for_relevance() to run after previews are fetched. 

189 # Typically set alongside is_lexical=True, but can be set independently — 

190 # e.g. a non-lexical engine with noisy results could opt in. 

191 needs_llm_relevance_filter = False 

192 

193 # Tuning for the LLM relevance filter (only applies when the filter 

194 # is active for this engine). 

195 # 

196 # relevance_filter_batch_size: split previews into chunks of this many 

197 # before sending to the LLM. Smaller batches are faster per call and 

198 # more reliable on weaker models which struggle with many indices in 

199 # one context. None or 0 = single-call mode (no batching). 

200 # 

201 # relevance_filter_max_parallel_batches: number of batches to dispatch 

202 # concurrently against the LLM. 1 = sequential. Most providers handle 

203 # parallel requests fine (Ollama with OLLAMA_NUM_PARALLEL>1, OpenAI, 

204 # Anthropic). 

205 relevance_filter_batch_size: Optional[int] = 5 

206 relevance_filter_max_parallel_batches: int = 10 

207 

208 # Class attribute for rate limit detection patterns 

209 # Subclasses can override to add engine-specific patterns 

210 rate_limit_patterns: Set[str] = { 

211 "rate limit", 

212 "rate_limit", 

213 "ratelimit", 

214 "too many requests", 

215 "throttl", 

216 "quota exceeded", 

217 "quota_exceeded", 

218 "limit exceeded", 

219 "request limit", 

220 "api limit", 

221 "usage limit", 

222 } 

223 

224 # Instance-attribute names holding credential values to redact from error 

225 # messages/logs via _scrub_error(). Subclasses override when they store 

226 # secrets under different names (e.g. Elasticsearch: _api_key/_password). 

227 # Centralizing the list keeps every dual-scrub call site uniform and 

228 # prevents the per-site drift that previously dropped a secret. 

229 _secret_attrs: tuple[str, ...] = ("api_key",) 

230 

231 @staticmethod 

232 def _ensure_list(value, *, default=None): 

233 """Normalize a value that should be a list. 

234 

235 Handles JSON-encoded strings, comma-separated strings, and 

236 already-parsed lists. Returns *default* (empty list when not 

237 supplied) for ``None`` or empty/unparseable input. 

238 """ 

239 if default is None: 

240 default = [] 

241 if value is None: 

242 return default 

243 if isinstance(value, list): 

244 return value 

245 if isinstance(value, str): 

246 stripped = value.strip() 

247 if not stripped: 

248 return default 

249 if stripped.startswith("["): 

250 try: 

251 parsed = json.loads(stripped) 

252 if isinstance(parsed, list): 252 ↛ 256line 252 didn't jump to line 256 because the condition on line 252 was always true

253 return [str(item) for item in parsed] 

254 except (json.JSONDecodeError, ValueError, RecursionError): 

255 pass 

256 return [ 

257 item.strip() for item in stripped.split(",") if item.strip() 

258 ] 

259 return default 

260 

261 @classmethod 

262 def _load_engine_class(cls, name: str, config: Dict[str, Any]): 

263 """ 

264 Helper method to load an engine class dynamically. 

265 

266 Args: 

267 name: Engine name 

268 config: Engine configuration dict with module_path and class_name 

269 

270 Returns: 

271 Tuple of (success: bool, engine_class or None, error_msg or None) 

272 """ 

273 from ..security.module_whitelist import ( 

274 ModuleNotAllowedError, 

275 get_safe_module_class, 

276 ) 

277 

278 try: 

279 module_path = config.get("module_path") 

280 class_name = config.get("class_name") 

281 

282 if not module_path or not class_name: 

283 return ( 

284 False, 

285 None, 

286 f"Missing module_path or class_name for {name}", 

287 ) 

288 

289 # Use whitelist-validated safe import 

290 engine_class = get_safe_module_class(module_path, class_name) 

291 

292 return True, engine_class, None 

293 

294 except ModuleNotAllowedError as e: 

295 return ( 

296 False, 

297 None, 

298 f"Security error loading engine class for {name}: " 

299 f"{scrub_error(e)}", 

300 ) 

301 except Exception as e: 

302 return ( 

303 False, 

304 None, 

305 f"Could not load engine class for {name}: {scrub_error(e)}", 

306 ) 

307 

308 @classmethod 

309 def is_available( 

310 cls, settings_snapshot: Optional[Dict[str, Any]] = None 

311 ) -> bool: 

312 """Runtime availability probe used by ``list_eligible_engine_configs``. 

313 

314 Subclasses whose ``__init__`` performs a network probe that can 

315 hard-fail (e.g. Elasticsearch's ``client.info()`` raises 

316 ``ConnectionError`` when the cluster is unreachable) should override 

317 this to fail closed: returning ``False`` causes the engine to be 

318 omitted from specialized agent tool surfaces and the auto-search 

319 candidate pool, so those paths do not advertise or instantiate a 

320 broken engine. The primary generic ``web_search`` tool and direct 

321 factory callers can still attempt construction and surface the 

322 underlying configuration or connection error. 

323 

324 Default is ``True`` — engines that don't probe at init stay 

325 eligible. This is the opposite default of ``BaseLLMProvider`` because 

326 the vast majority of search engines here are pure REST clients with 

327 lazy connection errors; changing the default would force every 

328 subclass to opt in to ``True`` for no benefit. 

329 """ 

330 return True 

331 

332 def __init__( 

333 self, 

334 llm: Optional[BaseLLM] = None, 

335 max_filtered_results: Optional[int] = None, 

336 max_results: Optional[int] = 10, # Default value if not provided 

337 preview_filters: List[BaseFilter] | None = None, 

338 content_filters: List[BaseFilter] | None = None, 

339 search_snippets_only: bool = True, # New parameter with default 

340 include_full_content: bool = False, 

341 settings_snapshot: Optional[Dict[str, Any]] = None, 

342 programmatic_mode: bool = False, 

343 **kwargs, 

344 ): 

345 """ 

346 Initialize the search engine with common parameters. 

347 

348 Args: 

349 llm: Optional language model for relevance filtering 

350 max_filtered_results: Maximum number of results to keep after filtering 

351 max_results: Maximum number of search results to return 

352 preview_filters: Filters that will be applied to all previews 

353 produced by the search engine, before relevancy checks. 

354 content_filters: Filters that will be applied to the full content 

355 produced by the search engine, after relevancy checks. 

356 search_snippets_only: Whether to return only snippets or full content 

357 include_full_content: Whether to use FullSearchResults for full webpage content 

358 settings_snapshot: Settings snapshot for configuration 

359 programmatic_mode: If True, disables database operations and uses memory-only tracking 

360 **kwargs: Additional engine-specific parameters 

361 """ 

362 if max_filtered_results is None: 

363 max_filtered_results = DEFAULT_MAX_FILTERED_RESULTS 

364 if max_results is None: 

365 max_results = 10 

366 self._preview_filters: List[BaseFilter] = ( 

367 preview_filters if preview_filters is not None else [] 

368 ) 

369 self._content_filters: List[BaseFilter] = ( 

370 content_filters if content_filters is not None else [] 

371 ) 

372 

373 self.llm = llm # LLM for relevance filtering 

374 self._max_filtered_results = int( 

375 max_filtered_results 

376 ) # Ensure it's an integer 

377 self._max_results = max( 

378 1, int(max_results) 

379 ) # Ensure it's a positive integer 

380 self.search_snippets_only = search_snippets_only # Store the setting 

381 self.include_full_content = include_full_content 

382 self.settings_snapshot = ( 

383 settings_snapshot or {} 

384 ) # Store settings snapshot 

385 

386 self.engine_type = self.__class__.__name__ 

387 self._engine_name: str = "" # set by the factory after construction 

388 # The snapshot dict the runtime egress backstop last verified 

389 # against (identity comparison; holding the reference keeps the 

390 # memo valid — a verification re-runs when the caller assigns a 

391 # NEW snapshot) plus the policy-relevant VALUES it carried at 

392 # verification time, so an in-place mutation of the scope or 

393 # primary key on the same dict is also caught. 

394 self._egress_verified_snapshot: Optional[Dict[str, Any]] = None 

395 self._egress_verified_policy_key: Optional[tuple] = None 

396 self._egress_skip_warned = False 

397 self._configure_programmatic_mode(programmatic_mode) 

398 self._last_wait_time = ( 

399 0.0 # Default to 0 for successful searches without rate limiting 

400 ) 

401 self._last_results_count = 0 

402 

403 def _create_journal_filter( 

404 self, 

405 engine_name: str, 

406 llm: Optional[BaseLLM], 

407 settings_snapshot: Optional[Dict[str, Any]], 

408 ) -> Optional["JournalReputationFilter"]: 

409 """Build the default :class:`JournalReputationFilter` for this engine. 

410 

411 Wraps the identical 8-line ``JournalReputationFilter.create_default`` 

412 boilerplate that previously lived in every academic subclass. The 

413 ``engine_name`` is passed explicitly because the auto-derived class 

414 name does not match the settings key for ``nasa_ads`` (would be 

415 ``"nasaads"``) or ``semantic_scholar`` (would be 

416 ``"semanticscholar"``). 

417 

418 ``llm`` and ``settings_snapshot`` are passed in rather than read from 

419 ``self`` because subclasses build their preview filters *before* 

420 calling ``super().__init__()`` (the filter is handed to the parent 

421 constructor), so ``self.llm`` / ``self.settings_snapshot`` do not 

422 exist yet at call time. 

423 

424 Args: 

425 engine_name: Settings key identifying the engine (e.g. 

426 ``"arxiv"``, ``"nasa_ads"``). 

427 llm: Language model used for the filter's relevance pass. 

428 settings_snapshot: Settings snapshot for configuration lookups. 

429 

430 Returns: 

431 A configured :class:`JournalReputationFilter`, or ``None`` if 

432 filtering is disabled in settings for this engine. 

433 """ 

434 from ..advanced_search_system.filters.journal_reputation_filter import ( 

435 JournalReputationFilter, 

436 ) 

437 

438 return JournalReputationFilter.create_default( 

439 model=llm, # type: ignore[arg-type] 

440 engine_name=engine_name, 

441 settings_snapshot=settings_snapshot, 

442 ) 

443 

444 def _configure_programmatic_mode(self, programmatic_mode: bool) -> None: 

445 """Set ``programmatic_mode`` and (re)bind the matching rate tracker. 

446 

447 Called from ``__init__`` and from the factory as a fallback when an 

448 engine subclass swallows the ``programmatic_mode`` kwarg without 

449 forwarding it to ``super().__init__``. Safe to call after init — 

450 rebinding ``rate_tracker`` discards the previous tracker (no 

451 resources to release) and the new one starts with empty in-memory 

452 caches. 

453 """ 

454 self.programmatic_mode = programmatic_mode 

455 if programmatic_mode: 

456 from .rate_limiting.tracker import AdaptiveRateLimitTracker 

457 

458 self.rate_tracker = AdaptiveRateLimitTracker( 

459 settings_snapshot=self.settings_snapshot, 

460 programmatic_mode=programmatic_mode, 

461 ) 

462 else: 

463 self.rate_tracker = get_tracker() 

464 

465 @property 

466 def max_filtered_results(self) -> int: 

467 """Get the maximum number of filtered results.""" 

468 return self._max_filtered_results 

469 

470 @max_filtered_results.setter 

471 def max_filtered_results(self, value: int) -> None: 

472 """Set the maximum number of filtered results.""" 

473 if value is None: 

474 value = DEFAULT_MAX_FILTERED_RESULTS 

475 logger.warning( 

476 f"Setting max_filtered_results to {DEFAULT_MAX_FILTERED_RESULTS}" 

477 ) 

478 self._max_filtered_results = int(value) 

479 

480 @property 

481 def max_results(self) -> int: 

482 """Get the maximum number of search results.""" 

483 return self._max_results 

484 

485 @max_results.setter 

486 def max_results(self, value: int) -> None: 

487 """Set the maximum number of search results.""" 

488 if value is None: 

489 value = 10 

490 self._max_results = max(1, int(value)) 

491 

492 def _get_adaptive_wait(self) -> float: 

493 """Get adaptive wait time from tracker.""" 

494 wait_time = self.rate_tracker.get_wait_time(self.engine_type) 

495 self._last_wait_time = wait_time 

496 logger.debug( 

497 f"{self.engine_type} waiting {wait_time:.2f}s before retry" 

498 ) 

499 return wait_time 

500 

501 def _record_retry_outcome(self, retry_state) -> None: 

502 """Record outcome after retry completes.""" 

503 success = ( 

504 not retry_state.outcome.failed if retry_state.outcome else False 

505 ) 

506 self.rate_tracker.record_outcome( 

507 self.engine_type, 

508 self._last_wait_time or 0, 

509 success, 

510 retry_state.attempt_number, 

511 error_type="RateLimitError" if not success else None, 

512 search_result_count=self._last_results_count if success else 0, 

513 ) 

514 

515 def _verify_egress_scope(self) -> None: 

516 """Runtime backstop: verify this engine is allowed under the egress 

517 scope in ``self.settings_snapshot`` before executing a search. 

518 

519 No-op when ``settings_snapshot`` is missing or empty (programmatic 

520 API callers) or when ``_engine_name`` has not been set. Raises 

521 ``PolicyDeniedError`` when the policy denies the engine; any other 

522 internal error in the policy evaluation is logged and ignored so a 

523 broken backstop never takes down searches the factory PEP already 

524 approved. 

525 

526 This is a defense-in-depth check behind the factory PEP, the 

527 strategy-level filters, and the audit hook. It covers engines that 

528 bypassed the factory (direct instantiation with a snapshot). Note 

529 the limit: the snapshot is the one captured at construction, so a 

530 scope change AFTER construction is only caught if the caller also 

531 refreshes ``settings_snapshot`` (assigns a NEW dict — in-place 

532 mutation of the existing dict is not detected, see below). 

533 

534 The verification is memoized per snapshot IDENTITY plus the 

535 policy-relevant VALUES (scope, primary): the full policy inputs 

536 are stable for a given snapshot object, so re-evaluating on every 

537 ``run()`` would only repeat the same decision — and under 

538 ADAPTIVE scope with a URL-configurable primary the evaluation can 

539 include a DNS lookup (bounded at 2s), which must not become a 

540 per-search stall. Assigning a refreshed snapshot OR mutating the 

541 scope/primary keys in place invalidates the memo and re-verifies. 

542 Denials are never memoized (they raise). 

543 """ 

544 if not self.settings_snapshot: 

545 return 

546 if not self._engine_name: 

547 # A snapshot WITHOUT a stamped engine name means a direct 

548 # instantiation the factory never saw and no subclass 

549 # self-stamps — the backstop cannot evaluate it. Surface the 

550 # gap in the audit log (once per instance) instead of 

551 # skipping silently. 

552 if not self._egress_skip_warned: 552 ↛ 559line 552 didn't jump to line 559 because the condition on line 552 was always true

553 self._egress_skip_warned = True 

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

555 "egress backstop skipped: engine has a settings " 

556 "snapshot but no _engine_name stamped", 

557 engine_type=self.__class__.__name__, 

558 ) 

559 return 

560 if ( 

561 self.settings_snapshot is self._egress_verified_snapshot 

562 and self._egress_policy_key() == self._egress_verified_policy_key 

563 ): 

564 return 

565 from ..security.egress.policy import PolicyDeniedError 

566 

567 try: 

568 self._check_egress_policy() 

569 except PolicyDeniedError: 

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

571 "runtime egress backstop denied engine", 

572 engine=self._engine_name, 

573 ) 

574 raise 

575 except Exception: 

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

577 "egress runtime verify errored; " 

578 "factory PEP still enforces at instantiation", 

579 engine=self._engine_name, 

580 ) 

581 else: 

582 self._egress_verified_snapshot = self.settings_snapshot 

583 self._egress_verified_policy_key = self._egress_policy_key() 

584 

585 def _egress_policy_key(self) -> tuple: 

586 """The policy-relevant snapshot values the backstop memo guards on. 

587 

588 Cheap dict reads only — no context construction, no DNS. Not an 

589 exhaustive input set (URL settings or DB-side collection flags can 

590 also influence the decision), but it covers the keys an in-place 

591 mutation would realistically target; the factory PEP remains the 

592 primary enforcement for everything else. 

593 

594 MAINTENANCE: if a new snapshot key ever becomes policy-relevant in 

595 ``context_from_snapshot``/``evaluate_engine``, add it here too — 

596 otherwise an in-place mutation of that key returns a stale memo. 

597 

598 """ 

599 scope = unwrap_setting( 

600 self.settings_snapshot.get("policy.egress_scope") 

601 ) 

602 primary = unwrap_setting(self.settings_snapshot.get("search.tool")) 

603 return (scope, primary) 

604 

605 def _check_egress_policy(self) -> None: 

606 """Inner helper so the raise is not inside the except handler 

607 (ruff TRY301). Raises PolicyDeniedError on scope mismatch.""" 

608 from ..security.egress.policy import ( 

609 PolicyDeniedError, 

610 context_from_snapshot, 

611 evaluate_engine, 

612 ) 

613 from ..search_system import username_from_snapshot 

614 

615 primary = unwrap_setting( 

616 self.settings_snapshot.get("search.tool", self._engine_name) 

617 ) 

618 ctx = context_from_snapshot( 

619 self.settings_snapshot, 

620 primary or self._engine_name, 

621 username=username_from_snapshot(self.settings_snapshot), 

622 ) 

623 decision = evaluate_engine( 

624 self._engine_name, 

625 ctx, 

626 settings_snapshot=self.settings_snapshot, 

627 ) 

628 if not decision.allowed: 

629 raise PolicyDeniedError(decision, target=self._engine_name) 

630 

631 def run( 

632 self, query: str, research_context: Dict[str, Any] | None = None 

633 ) -> List[Dict[str, Any]]: 

634 """ 

635 Run the search engine with a given query, retrieving and filtering results. 

636 This implements a two-phase retrieval approach: 

637 1. Get preview information for many results 

638 2. Filter the previews for relevance 

639 3. Get full content for only the relevant results 

640 

641 Args: 

642 query: The search query 

643 research_context: Context from previous research to use. 

644 

645 Returns: 

646 List of search results with full content (if available) 

647 """ 

648 logger.info(f"---Execute a search using {self.__class__.__name__}---") 

649 

650 # Runtime egress scope backstop: verify this engine is allowed 

651 # before making any HTTP requests. 

652 self._verify_egress_scope() 

653 

654 # Track search call for metrics (if available and not in programmatic mode) 

655 should_record_metrics = False 

656 context_was_set = False 

657 if not self.programmatic_mode: 

658 from ..metrics.search_tracker import SearchTracker 

659 

660 should_record_metrics = True 

661 

662 # For thread-safe context propagation: if we have research_context parameter, use it 

663 # Otherwise, try to inherit from current thread context (normal case) 

664 # This allows strategies running in threads to explicitly pass context when needed 

665 if research_context: 

666 # Explicit context provided - use it and set it for this thread 

667 set_search_context(research_context) 

668 context_was_set = True 

669 

670 engine_name = self.__class__.__name__.replace( 

671 "SearchEngine", "" 

672 ).lower() 

673 start_time = time.time() 

674 

675 success = True 

676 error_message = None 

677 results_count = 0 

678 

679 # Define the core search function with retry logic 

680 if self.rate_tracker.enabled: 

681 # Rate limiting enabled - use retry with adaptive wait 

682 @retry( 

683 stop=stop_after_attempt(3), 

684 wait=AdaptiveWait(lambda: self._get_adaptive_wait()), 

685 retry=retry_if_exception_type((RateLimitError,)), 

686 after=self._record_retry_outcome, 

687 reraise=True, 

688 ) 

689 def _run_with_retry(): 

690 nonlocal success, error_message, results_count 

691 return _execute_search() 

692 else: 

693 # Rate limiting disabled - run without retry 

694 def _run_with_retry(): 

695 nonlocal success, error_message, results_count 

696 return _execute_search() 

697 

698 def _execute_search(): 

699 nonlocal success, error_message, results_count 

700 

701 try: 

702 # Step 1: Get preview information for items 

703 previews = self._get_previews(query) 

704 if not previews: 

705 logger.info( 

706 f"Search engine {self.__class__.__name__} returned no preview results for query: {query}" 

707 ) 

708 results_count = 0 

709 return [] 

710 

711 # Pre-filter enrichment: resolve DOIs to OpenAlex source 

712 # IDs BEFORE the preview filters run. The 

713 # JournalReputationFilter is registered as a preview 

714 # filter and uses ``result["openalex_source_id"]`` for 

715 # Tier 2 lookups; running enrichment afterwards (as the 

716 # old pipeline did) left the field empty at filter time, 

717 # silently forcing a fragile-name-match fallback. Only 

718 # for scientific engines whose results carry DOIs. 

719 if getattr(self, "is_scientific", False): 

720 try: 

721 from ..utilities.openalex_enrichment import ( 

722 enrich_results_with_source_ids, 

723 ) 

724 

725 email = getattr(self, "email", None) 

726 previews = enrich_results_with_source_ids( 

727 previews, email=email 

728 ) 

729 except Exception: 

730 logger.debug( 

731 "DOI enrichment skipped (import or call failed)" 

732 ) 

733 

734 for preview_filter in self._preview_filters: 

735 previews = preview_filter.filter_results(previews, query) 

736 

737 # Step 2: Filter previews for relevance with LLM 

738 enable_llm_filter = getattr( 

739 self, "enable_llm_relevance_filter", False 

740 ) 

741 

742 if enable_llm_filter and self.llm: 

743 filtered_items = self._filter_for_relevance(previews, query) 

744 else: 

745 filtered_items = previews 

746 logger.debug( 

747 f"[{type(self).__name__}] Relevance filter skipped " 

748 f"(enabled={enable_llm_filter}, " 

749 f"llm={'yes' if self.llm else 'no'})" 

750 ) 

751 

752 # Step 3: Get full content for filtered items 

753 if self.search_snippets_only: 

754 logger.info("Returning snippet-only results as per config") 

755 results = filtered_items 

756 else: 

757 results = self._get_full_content(filtered_items) 

758 

759 for content_filter in self._content_filters: 

760 results = content_filter.filter_results(results, query) 

761 

762 results_count = len(results) 

763 self._last_results_count = results_count 

764 

765 # Record success if we get here and rate limiting is enabled 

766 if self.rate_tracker.enabled: 

767 logger.info( 

768 f"Recording successful search for {self.engine_type}: wait_time={self._last_wait_time}s, results={results_count}" 

769 ) 

770 self.rate_tracker.record_outcome( 

771 self.engine_type, 

772 self._last_wait_time, 

773 success=True, 

774 retry_count=1, # First attempt succeeded 

775 search_result_count=results_count, 

776 ) 

777 else: 

778 logger.info( 

779 f"Rate limiting disabled, not recording search for {self.engine_type}" 

780 ) 

781 

782 return results 

783 

784 except RateLimitError: 

785 # Only re-raise if rate limiting is enabled 

786 if self.rate_tracker.enabled: 

787 raise 

788 # If rate limiting is disabled, treat as regular error 

789 success = False 

790 error_message = "Rate limit hit but rate limiting disabled" 

791 logger.warning( 

792 f"Rate limit hit on {self.__class__.__name__} but rate limiting is disabled" 

793 ) 

794 results_count = 0 

795 return [] 

796 except Exception as e: 

797 # Other errors - don't retry 

798 success = False 

799 # Sanitize before it flows to SearchTracker.record_search 

800 # (and the database) and to the log. Use logger.warning with 

801 # the dual-scrubbed text instead of logger.exception: the 

802 # cause chain frequently carries the request URL or auth 

803 # header from upstream HTTP clients (see #4131). 

804 error_message = safe_msg = self._scrub_error(e) 

805 logger.warning( 

806 f"Search engine {self.__class__.__name__} failed: {safe_msg}" 

807 ) 

808 results_count = 0 

809 return [] 

810 

811 try: 

812 return _run_with_retry() # type: ignore[no-any-return] 

813 except RetryError as e: 

814 # All retries exhausted 

815 success = False 

816 error_message = self._scrub_error( 

817 f"Rate limited after all retries: {e}" 

818 ) 

819 safe_msg = self._scrub_error(e) 

820 logger.warning( 

821 f"{self.__class__.__name__} failed after all retries: {safe_msg}" 

822 ) 

823 return [] 

824 except Exception as e: 

825 success = False 

826 error_message = safe_msg = self._scrub_error(e) 

827 logger.warning( 

828 f"Search engine {self.__class__.__name__} error: {safe_msg}" 

829 ) 

830 return [] 

831 finally: 

832 try: 

833 # Record search metrics BEFORE clearing context (record_search needs it) 

834 if should_record_metrics: 

835 response_time_ms = int((time.time() - start_time) * 1000) 

836 SearchTracker.record_search( 

837 engine_name=engine_name, 

838 query=query, 

839 results_count=results_count, 

840 response_time_ms=response_time_ms, 

841 success=success, 

842 error_message=error_message, 

843 ) 

844 finally: 

845 # Clean up temporary search result storage 

846 for attr in self._temp_attributes(): 

847 if hasattr(self, attr): 

848 delattr(self, attr) 

849 # ALWAYS clean up search context, even if recording fails 

850 if context_was_set: 

851 clear_search_context() 

852 

853 def invoke(self, query: str) -> List[Dict[str, Any]]: 

854 """Compatibility method for LangChain tools""" 

855 return self.run(query) 

856 

857 def _filter_for_relevance( 

858 self, previews: List[Dict[str, Any]], query: str 

859 ) -> List[Dict[str, Any]]: 

860 """ 

861 Filter search results by relevance using the LLM. 

862 

863 Delegates to the ``relevance_filter`` module, which prompts the 

864 LLM for a plain-text list of relevant indices and parses them 

865 with a regex (no structured output). 

866 

867 Args: 

868 previews: List of preview dictionaries 

869 query: The original search query 

870 

871 Returns: 

872 Filtered list of preview dictionaries 

873 """ 

874 engine_name = self._engine_name or type(self).__name__ 

875 

876 if not self.llm or len(previews) <= 1: 

877 logger.debug( 

878 f"[{engine_name}] Skipping relevance filter " 

879 f"(llm={'yes' if self.llm else 'no'}, " 

880 f"previews={len(previews)})" 

881 ) 

882 return previews 

883 

884 from .relevance_filter import filter_previews_for_relevance 

885 

886 return filter_previews_for_relevance( 

887 llm=self.llm, 

888 previews=previews, 

889 query=query, 

890 max_filtered_results=self.max_filtered_results, 

891 engine_name=engine_name, 

892 batch_size=self.relevance_filter_batch_size, 

893 max_parallel_batches=self.relevance_filter_max_parallel_batches, 

894 ) 

895 

896 # ========================================================================= 

897 # Shared Helper Methods for Subclasses 

898 # ========================================================================= 

899 

900 @staticmethod 

901 def _is_valid_api_key(api_key: Optional[str]) -> bool: 

902 """ 

903 Check if an API key is valid (not a placeholder value). 

904 

905 Args: 

906 api_key: The API key to validate 

907 

908 Returns: 

909 True if the key appears to be a real API key, False if it's a placeholder 

910 

911 Example: 

912 >>> BaseSearchEngine._is_valid_api_key("sk-abc123") 

913 True 

914 >>> BaseSearchEngine._is_valid_api_key("YOUR_API_KEY_HERE") 

915 False 

916 """ 

917 return not _is_api_key_placeholder(api_key) 

918 

919 @staticmethod 

920 def _extract_display_link(url: str, fallback: str = "") -> str: 

921 """Extract the netloc (domain) from a URL for display purposes. 

922 

923 Args: 

924 url: The URL to extract from 

925 fallback: Value to return on parse failure (default: empty string) 

926 

927 Returns: 

928 The netloc portion of the URL, or *fallback* if parsing fails. 

929 """ 

930 if not url: 

931 return fallback 

932 try: 

933 parsed = urlparse(url) 

934 return parsed.netloc or fallback 

935 except Exception: 

936 return fallback 

937 

938 @staticmethod 

939 def _clean_result_url(value: Any) -> str: 

940 """Normalize a raw search-result URL for the validity gate. 

941 

942 Coerces ``None`` (and any other falsy value) to ``""`` and any 

943 truthy value to ``str`` before stripping surrounding whitespace. 

944 

945 The strip is **load-bearing**, not merely cosmetic: several engines 

946 gate the URL on a ``url.lower().startswith(("http://", "https://"))`` 

947 prefix check (see ``_is_valid_search_result``) *before* it reaches 

948 the SSRF validator's own internal strip, so leading/trailing 

949 whitespace — common in HTML-scraped ``href`` attributes — would 

950 silently drop an otherwise-valid result. Cleaning once at extraction 

951 also keeps the URL tidy for logs and downstream consumers (preview 

952 ``id``/``link`` fields, etc.). 

953 

954 Args: 

955 value: The raw URL value from a search result, e.g. 

956 ``result.get("url")`` or a parsed HTML ``href`` attribute. 

957 

958 Returns: 

959 The whitespace-stripped URL string, or ``""`` if *value* is 

960 falsy (``None``, empty, etc.). 

961 """ 

962 if not value: 

963 return "" 

964 return str(value).strip() 

965 

966 def _resolve_api_key( 

967 self, 

968 api_key: Optional[str], 

969 setting_key: str, 

970 engine_name: str = "search engine", 

971 settings_snapshot: Optional[Dict[str, Any]] = None, 

972 ) -> str: 

973 """ 

974 Resolve an API key from multiple sources with priority order. 

975 

976 Environment variables are handled automatically by SettingsManager 

977 when building the settings snapshot, so they don't need to be 

978 checked separately here. 

979 

980 Priority order: 

981 1. Direct parameter (api_key argument) 

982 2. Settings snapshot (via setting_key) 

983 

984 Args: 

985 api_key: API key passed directly as parameter 

986 setting_key: Key to look up in settings snapshot (e.g., "search.brave_api_key") 

987 engine_name: Human-readable engine name for error messages 

988 settings_snapshot: Optional settings snapshot dict (uses self.settings_snapshot if not provided) 

989 

990 Returns: 

991 The resolved API key string 

992 

993 Raises: 

994 ValueError: If no valid API key is found from any source 

995 

996 Example: 

997 >>> engine._resolve_api_key( 

998 ... api_key=None, 

999 ... setting_key="search.brave_api_key", 

1000 ... engine_name="Brave Search" 

1001 ... ) 

1002 "sk-abc123..." 

1003 """ 

1004 # Use instance settings snapshot if not provided 

1005 if settings_snapshot is None: 

1006 settings_snapshot = self.settings_snapshot 

1007 

1008 # Priority 1: Direct parameter 

1009 if self._is_valid_api_key(api_key) and api_key is not None: 

1010 return api_key.strip() 

1011 

1012 # Priority 2: Settings snapshot (includes env var overrides via SettingsManager) 

1013 if settings_snapshot: 

1014 settings_value = get_setting_from_snapshot( 

1015 setting_key, 

1016 default=None, 

1017 settings_snapshot=settings_snapshot, 

1018 ) 

1019 if self._is_valid_api_key(settings_value): 

1020 return settings_value.strip() if settings_value else "" 

1021 

1022 # No valid API key found 

1023 masked_key = self._mask_api_key(str(api_key)) if api_key else "None" 

1024 raise ValueError( 

1025 f"No valid API key found for {engine_name}. " 

1026 f"Checked: direct parameter ({masked_key}), " 

1027 f"settings key '{setting_key}'. " 

1028 f"Please provide a valid API key." 

1029 ) 

1030 

1031 def _is_rate_limit_error( 

1032 self, 

1033 error: Union[Exception, str, int], 

1034 additional_patterns: Optional[Set[str]] = None, 

1035 ) -> bool: 

1036 """ 

1037 Detect if an error is a rate limit error. 

1038 

1039 Checks multiple sources for rate limit indicators: 

1040 - HTTP status code 429 

1041 - HTTPError response objects 

1042 - Error messages containing rate limit phrases 

1043 

1044 Args: 

1045 error: The error to check (Exception, string, or HTTP status code) 

1046 additional_patterns: Optional set of additional patterns to match 

1047 

1048 Returns: 

1049 True if the error appears to be a rate limit error 

1050 

1051 Example: 

1052 >>> engine._is_rate_limit_error(429) 

1053 True 

1054 >>> engine._is_rate_limit_error("Rate limit exceeded") 

1055 True 

1056 >>> engine._is_rate_limit_error(ValueError("Invalid input")) 

1057 False 

1058 """ 

1059 # Combine default and additional patterns 

1060 patterns = self.rate_limit_patterns.copy() 

1061 if additional_patterns: 

1062 patterns.update(additional_patterns) 

1063 

1064 # Check integer status code directly 

1065 if isinstance(error, int): 

1066 return error == 429 

1067 

1068 # Convert to string for pattern matching 

1069 error_str = "" 

1070 status_code = None 

1071 

1072 if isinstance(error, str): 

1073 error_str = error 

1074 elif isinstance(error, Exception): 

1075 error_str = str(error) 

1076 

1077 # Check for HTTP status code in common HTTP error types 

1078 if hasattr(error, "status_code"): 

1079 status_code = error.status_code 

1080 elif hasattr(error, "response"): 

1081 response = error.response 

1082 if hasattr(response, "status_code"): 

1083 status_code = response.status_code 

1084 

1085 # Check status code first 

1086 if status_code == 429: 

1087 return True 

1088 

1089 # Case-insensitive pattern matching 

1090 error_lower = error_str.lower() 

1091 for pattern in patterns: 

1092 if pattern.lower() in error_lower: 

1093 return True 

1094 

1095 return False 

1096 

1097 def _raise_if_rate_limit( 

1098 self, 

1099 error: Union[Exception, str, int], 

1100 additional_patterns: Optional[Set[str]] = None, 

1101 ) -> None: 

1102 """ 

1103 Raise RateLimitError if the given error is a rate limit error. 

1104 

1105 Convenience method that combines _is_rate_limit_error check with 

1106 raising RateLimitError. 

1107 

1108 Args: 

1109 error: The error to check 

1110 additional_patterns: Optional set of additional patterns to match 

1111 

1112 Raises: 

1113 RateLimitError: If the error is detected as a rate limit error 

1114 

1115 Example: 

1116 >>> try: 

1117 ... response = make_api_call() 

1118 ... except Exception as e: 

1119 ... engine._raise_if_rate_limit(e) 

1120 """ 

1121 if self._is_rate_limit_error(error, additional_patterns): 

1122 error_msg = str(error) if not isinstance(error, str) else error 

1123 raise RateLimitError( 

1124 f"Rate limit detected: {self._sanitize_error_message(error_msg)}" 

1125 ) 

1126 

1127 def _extract_full_result(self, item: Dict[str, Any]) -> Dict[str, Any]: 

1128 """ 

1129 Extract the full result from an item that may contain a _full_result key. 

1130 

1131 This is a helper for the default _get_full_content implementation. 

1132 It extracts data from the _full_result key if present, otherwise uses 

1133 the item directly, and removes the internal _full_result key. 

1134 

1135 Args: 

1136 item: A search result item that may contain a _full_result key 

1137 

1138 Returns: 

1139 A dictionary with the full result data, without the _full_result key 

1140 

1141 Example: 

1142 >>> engine._extract_full_result({"title": "A", "_full_result": {"title": "A", "content": "Full"}}) 

1143 {"title": "A", "content": "Full"} 

1144 """ 

1145 source = item.get("_full_result") 

1146 if source is None: 

1147 source = item 

1148 return {k: v for k, v in source.items() if k != "_full_result"} 

1149 

1150 def _get_full_content( 

1151 self, relevant_items: List[Dict[str, Any]] 

1152 ) -> List[Dict[str, Any]]: 

1153 """ 

1154 Get full content for the relevant items. 

1155 

1156 Default implementation extracts data from _full_result keys if present. 

1157 Subclasses can override this method to fetch additional content from 

1158 external sources (e.g., web scraping, API calls). 

1159 

1160 Args: 

1161 relevant_items: List of relevant preview dictionaries 

1162 

1163 Returns: 

1164 List of result dictionaries with full content 

1165 

1166 Example: 

1167 >>> engine._get_full_content([ 

1168 ... {"title": "A", "_full_result": {"title": "A", "content": "Full A"}}, 

1169 ... {"title": "B"} 

1170 ... ]) 

1171 [{"title": "A", "content": "Full A"}, {"title": "B"}] 

1172 """ 

1173 if not relevant_items: 

1174 return [] 

1175 return [self._extract_full_result(item) for item in relevant_items] 

1176 

1177 def _build_full_search_egress_context(self): 

1178 """Build an EgressContext from self.settings_snapshot for the 

1179 per-URL scope gate inside FullSearchResults. Returns None when a 

1180 context can't be built and disables full-content fetching when 

1181 policy evaluation fails on a supplied snapshot. 

1182 

1183 The previous "fail closed by returning None" comment was wrong: 

1184 full_search.py treats ``egress_context is None`` as 

1185 ``evaluate_url_fn = None`` and skips the per-URL scope check 

1186 entirely, leaving only SSRF — which allows public hosts under 

1187 PRIVATE_ONLY. When the snapshot was supplied but the policy is 

1188 unevaluable, we now turn full-content fetching off for this 

1189 engine instead. 

1190 """ 

1191 if not getattr(self, "settings_snapshot", None): 

1192 return None 

1193 try: 

1194 from ..security.egress.policy import ( 

1195 PolicyDeniedError, 

1196 context_from_snapshot, 

1197 ) 

1198 except ImportError: 

1199 logger.debug( 

1200 "egress_policy unavailable in search_engine_base; " 

1201 "FullSearchResults fetches will be SSRF-only" 

1202 ) 

1203 return None 

1204 

1205 from ..search_system import username_from_snapshot 

1206 

1207 primary_raw = unwrap_setting( 

1208 self.settings_snapshot.get("search.tool", DEFAULT_SEARCH_TOOL) 

1209 ) 

1210 try: 

1211 # Thread username so a per-user private retriever primary 

1212 # resolves PRIVATE_ONLY here too; otherwise the per-URL fetch 

1213 # gate would fall back to BOTH and allow public hosts under a 

1214 # run whose posture is private. 

1215 return context_from_snapshot( 

1216 self.settings_snapshot, 

1217 primary_raw or DEFAULT_SEARCH_TOOL, 

1218 username=username_from_snapshot(self.settings_snapshot), 

1219 ) 

1220 except (PolicyDeniedError, ValueError) as exc: 

1221 safe_msg = self._scrub_error(exc) 

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

1223 "Disabling include_full_content: egress policy could " 

1224 "not be evaluated for this engine", 

1225 reason=safe_msg, 

1226 ) 

1227 self.include_full_content = False 

1228 return None 

1229 

1230 def _init_full_search( 

1231 self, 

1232 web_search=None, 

1233 language="en", 

1234 max_results=10, 

1235 region=None, 

1236 time_period=None, 

1237 safe_search=None, 

1238 ): 

1239 """Initialize FullSearchResults if include_full_content is True. 

1240 

1241 Call this at the end of your __init__ after setting up your search wrapper. 

1242 

1243 Args: 

1244 web_search: The search wrapper/engine to pass to FullSearchResults 

1245 language: Language for search results 

1246 max_results: Maximum number of results 

1247 region: Region/country code for results 

1248 time_period: Time period filter 

1249 safe_search: Safe search setting (string value for FullSearchResults) 

1250 """ 

1251 if self.include_full_content and self.llm: 

1252 try: 

1253 from .engines.full_search import FullSearchResults 

1254 

1255 # Egress context for per-URL scope gating. Built once 

1256 # here (not per fetch) so the policy snapshot is the 

1257 # same one used by the factory PEP that authorized this 

1258 # engine. 

1259 egress_context = self._build_full_search_egress_context() 

1260 self.full_search = FullSearchResults( 

1261 llm=self.llm, 

1262 web_search=web_search, 

1263 language=language, 

1264 max_results=max_results, 

1265 region=region, 

1266 time=time_period, 

1267 safesearch=safe_search, 

1268 settings_snapshot=self.settings_snapshot, 

1269 egress_context=egress_context, 

1270 ) 

1271 except ImportError: 

1272 logger.warning( 

1273 "FullSearchResults not available. " 

1274 "Full content retrieval disabled." 

1275 ) 

1276 self.include_full_content = False 

1277 

1278 def _temp_attributes(self): 

1279 """Return list of temporary attribute names to clean up after run(). 

1280 

1281 Override in subclasses that store additional temporary data. 

1282 """ 

1283 return ["_search_results"] 

1284 

1285 def _sanitize_error_message(self, message: str) -> str: 

1286 """ 

1287 Remove/mask API keys, tokens, and secrets from error messages. 

1288 

1289 Uses pattern matching for common credential formats. 

1290 

1291 Args: 

1292 message: The error message to sanitize 

1293 

1294 Returns: 

1295 Sanitized message with sensitive data redacted 

1296 

1297 Example: 

1298 >>> engine._sanitize_error_message( 

1299 ... "Error with key sk-abc123def456ghi789jkl012" 

1300 ... ) 

1301 "Error with key [REDACTED_KEY]" 

1302 """ 

1303 return sanitize_error_message(message) 

1304 

1305 def _scrub_error(self, error: Union[BaseException, str]) -> str: 

1306 """Return a log/DB-safe rendering of *error*. 

1307 

1308 Delegates the "dual-scrub" to :func:`~..security.log_sanitizer.scrub_error`, 

1309 resolving this engine's known literal secret values from 

1310 ``_secret_attrs``. *error* may be an exception or a pre-built 

1311 message string. 

1312 

1313 Use this at every catch site that logs or persists an exception so 

1314 the two scrub passes can never drift apart per-engine. 

1315 """ 

1316 return scrub_error( 

1317 error, *(getattr(self, name, None) for name in self._secret_attrs) 

1318 ) 

1319 

1320 def _mask_api_key(self, api_key: str, visible_chars: int = 4) -> str: 

1321 """ 

1322 Mask an API key for safe logging, showing only first and last characters. 

1323 

1324 Args: 

1325 api_key: The API key to mask 

1326 visible_chars: Number of characters to show at start and end 

1327 

1328 Returns: 

1329 Masked API key in format "sk-1...nop" or "***" for short keys 

1330 

1331 Example: 

1332 >>> engine._mask_api_key("sk-abcdefghijklmnop123456") 

1333 "sk-a...3456" 

1334 >>> engine._mask_api_key("short") 

1335 "***" 

1336 """ 

1337 if not api_key: 

1338 return "***" 

1339 

1340 api_key = str(api_key).strip() 

1341 

1342 if len(api_key) <= visible_chars * 2: 

1343 return "***" 

1344 

1345 return f"{api_key[:visible_chars]}...{api_key[-visible_chars:]}" 

1346 

1347 @abstractmethod 

1348 def _get_previews(self, query: str) -> List[Dict[str, Any]]: 

1349 """ 

1350 Get preview information (titles, summaries) for initial search results. 

1351 

1352 Args: 

1353 query: The search query 

1354 

1355 Returns: 

1356 List of preview dictionaries with at least 'id', 'title', and 'snippet' keys 

1357 """ 

1358 pass 

1359 

1360 def close(self) -> None: 

1361 """ 

1362 Close any resources held by this search engine. 

1363 

1364 Subclasses with HTTP sessions or other resources should override this. 

1365 The base implementation safely closes any 'session' attribute if present 

1366 and closes both preview and content filters that hold resources. 

1367 """ 

1368 from ..utilities.resource_utils import safe_close 

1369 

1370 if hasattr(self, "session") and self.session is not None: 

1371 safe_close(self.session, "HTTP session") 

1372 # Close preview filters as well as content filters — the journal 

1373 # reputation filter is registered as a preview filter on academic 

1374 # engines (arxiv, pubmed, openalex, nasa_ads, semantic_scholar) 

1375 # and holds a SearXNG engine + LLM client that need releasing. 

1376 if hasattr(self, "_preview_filters"): 

1377 for preview_filter in self._preview_filters: 1377 ↛ 1378line 1377 didn't jump to line 1378 because the loop on line 1377 never started

1378 safe_close(preview_filter, "preview filter") 

1379 if hasattr(self, "_content_filters"): 

1380 for content_filter in self._content_filters: 1380 ↛ 1381line 1380 didn't jump to line 1381 because the loop on line 1380 never started

1381 safe_close(content_filter, "content filter") 

1382 

1383 def __enter__(self): 

1384 """Support context manager usage.""" 

1385 return self 

1386 

1387 def __exit__(self, exc_type, exc_val, exc_tb): 

1388 """Cleanup on context exit.""" 

1389 self.close() 

1390 return False