Coverage for src/local_deep_research/web_search_engines/engines/search_engine_elasticsearch.py: 94%

237 statements  

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

1import json 

2import threading 

3import time as _time 

4from typing import Any, Dict, List, Optional 

5 

6from elasticsearch import Elasticsearch 

7from langchain_core.language_models import BaseLLM 

8 

9from ...constants import DEFAULT_SEARCH_TOOL, SNIPPET_LENGTH_SHORT 

10from ...security.secure_logging import logger 

11from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

12 

13 

14class ElasticsearchSearchEngine(BaseSearchEngine): 

15 """Elasticsearch search engine implementation with two-phase approach""" 

16 

17 is_local = True 

18 is_lexical = True 

19 needs_llm_relevance_filter = True 

20 # Egress (ADR-0007): a local document store — sensitive, contained. The 

21 # url_setting fail-up reclassifies exposure to EXPOSING when hosts resolve 

22 # public (quadrant 4: usable only by itself, contained inference). 

23 egress_sensitivity = Sensitivity.SENSITIVE 

24 egress_exposure = Exposure.CONTAINED 

25 # secrets to redact from error messages (see BaseSearchEngine._scrub_error) 

26 _secret_attrs = ("_api_key", "_password") 

27 # url_setting feeds the PDP's fail-up URL override: when the configured 

28 # hosts resolve to a PUBLIC endpoint (e.g. Elastic Cloud), the engine is 

29 # reclassified public so PRIVATE_ONLY denies it at selection time — 

30 # queries would leave the box even though the DATA is "local" in nature. 

31 # A localhost ES keeps the static is_local classification above. 

32 # cloud_id (which is NOT a host the PDP can classify) is handled 

33 # separately in __init__: it is rejected when the effective scope forbids 

34 # public egress. 

35 url_setting = "search.engine.web.elasticsearch.default_params.hosts" 

36 

37 @staticmethod 

38 def _cloud_id_forbidden_by_scope( 

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

40 ) -> bool: 

41 """True when the effective egress scope forbids the public Elastic 

42 Cloud endpoint a ``cloud_id`` targets. 

43 

44 Resolves the scope (including ADAPTIVE) via ``context_from_snapshot`` 

45 and returns True for PRIVATE_ONLY / STRICT. Fails CLOSED (forbidden) 

46 if the policy cannot be evaluated, so a snapshot/policy error cannot 

47 open a cloud egress under a private posture. A missing/empty snapshot 

48 resolves to the permissive default (BOTH) and is allowed. 

49 """ 

50 try: 

51 from ...security.egress.policy import ( 

52 EgressScope, 

53 context_from_snapshot, 

54 ) 

55 from ...config.thread_settings import get_setting_from_snapshot 

56 from ...search_system import username_from_snapshot 

57 

58 snapshot = settings_snapshot or {} 

59 primary = ( 

60 get_setting_from_snapshot( 

61 "search.tool", 

62 default=DEFAULT_SEARCH_TOOL, 

63 settings_snapshot=snapshot, 

64 ) 

65 or DEFAULT_SEARCH_TOOL 

66 ) 

67 ctx = context_from_snapshot( 

68 snapshot, primary, username=username_from_snapshot(snapshot) 

69 ) 

70 return ctx.scope in ( 

71 EgressScope.PRIVATE_ONLY, 

72 EgressScope.STRICT, 

73 ) 

74 except Exception: 

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

76 "elasticsearch cloud_id egress check failed; failing closed", 

77 exc_info=True, 

78 ) 

79 return True 

80 

81 # TTL cache for ``is_available`` probes. Keyed by JSON string representation of hosts so 

82 # two instances pointing at different clusters don't poison each other (and dict hosts don't raise TypeError). 

83 # The cache lives on the class so test patches that replace the class don't fight a stale module-level dict. 

84 # A negative result (connection refused / timeout) is cached for the same TTL — we don't 

85 # want a misconfigured engine to cost a TCP round-trip on every research run. 

86 _availability_cache: Dict[str, tuple[float, bool]] = {} 

87 _availability_cache_lock = threading.Lock() 

88 _AVAILABILITY_TTL_SECONDS = 60.0 

89 

90 @classmethod 

91 def clear_availability_cache(cls) -> None: 

92 """Clear the cached availability probe results.""" 

93 with cls._availability_cache_lock: 

94 cls._availability_cache.clear() 

95 

96 @classmethod 

97 def _get_cached_availability( 

98 cls, cache_key: str, now: float 

99 ) -> Optional[bool]: 

100 """Prune expired probe entries and return a fresh cached result.""" 

101 with cls._availability_cache_lock: 

102 for key, (timestamp, _available) in list( 

103 cls._availability_cache.items() 

104 ): 

105 if now - timestamp >= cls._AVAILABILITY_TTL_SECONDS: 

106 cls._availability_cache.pop(key, None) 

107 

108 cached = cls._availability_cache.get(cache_key) 

109 return cached[1] if cached is not None else None 

110 

111 @classmethod 

112 def is_available( 

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

114 ) -> bool: 

115 """Probe the configured Elasticsearch host(s) with a cheap TCP connect. 

116 

117 ``ElasticsearchSearchEngine.__init__`` calls ``self.client.info()`` 

118 which raises ``ConnectionError`` when the cluster is unreachable. 

119 Without this probe the factory logs ``Failed to create search 

120 engine 'elasticsearch' (ConnectionError)`` on every tool call, and 

121 worse the langgraph agent still advertises the engine as a tool in 

122 its per-step "selecting next action from …" heartbeat. 

123 

124 We do a bare TCP connect (not the full ES handshake) so the probe 

125 stays cheap enough to call from ``list_eligible_engine_configs``. 

126 Negative results are cached for ``_AVAILABILITY_TTL_SECONDS`` so a 

127 single down cluster doesn't cost a round-trip per research run. 

128 

129 Returns True (fail-open) for ``cloud_id`` configurations — those 

130 target Elastic Cloud and a TCP probe isn't meaningful. They'll 

131 surface their own error in ``__init__`` if misconfigured. 

132 """ 

133 from ...config.thread_settings import get_setting_from_snapshot 

134 

135 try: 

136 snapshot = settings_snapshot or {} 

137 

138 # cloud_id configs don't have a host we can TCP-probe. Trust them; 

139 # __init__ surfaces a useful error if the cluster is unreachable. 

140 cloud_id = get_setting_from_snapshot( 

141 "search.engine.web.elasticsearch.default_params.cloud_id", 

142 "", 

143 settings_snapshot=snapshot, 

144 ) 

145 if cloud_id: 

146 return True 

147 

148 hosts_setting = get_setting_from_snapshot( 

149 "search.engine.web.elasticsearch.default_params.hosts", 

150 ["http://localhost:9200"], 

151 settings_snapshot=snapshot, 

152 ) 

153 # The setting can arrive as a JSON string (ui_element=json) or a 

154 # real list — mirror the normalization __init__ does. 

155 hosts = cls._ensure_list( 

156 hosts_setting, default=["http://localhost:9200"] 

157 ) 

158 try: 

159 cache_key = json.dumps(hosts, sort_keys=True, default=str) 

160 except Exception: 

161 cache_key = str(hosts) 

162 

163 now = _time.monotonic() 

164 cached = cls._get_cached_availability(cache_key, now) 

165 if cached is not None: 

166 return cached 

167 

168 available = cls._probe_hosts_available(hosts) 

169 with cls._availability_cache_lock: 

170 cls._availability_cache[cache_key] = (now, available) 

171 if not available: 

172 logger.info( 

173 "Elasticsearch availability probe failed; excluding from " 

174 "agent tool list. Will re-probe in {:.0f}s.", 

175 cls._AVAILABILITY_TTL_SECONDS, 

176 ) 

177 return available 

178 except Exception as exc: 

179 logger.debug( 

180 "Elasticsearch is_available probe raised exception ({}) — failing open", 

181 type(exc).__name__, 

182 ) 

183 return True 

184 

185 @staticmethod 

186 def _probe_hosts_available(hosts: List[Any]) -> bool: 

187 """TCP-connect to the first reachable host in ``hosts``. 

188 

189 Any single host responding makes the engine available — matches the 

190 Elasticsearch client behavior of treating the list as failover. 

191 Cheap (no HTTP) and short-timeout (1s per host, capped at a 2.0s 

192 connection budget across all hosts). SSRF validation runs before each 

193 connection and can perform DNS resolution that cannot be interrupted 

194 by this connection-only budget. 

195 """ 

196 import socket 

197 from urllib.parse import urlparse 

198 from ...security.ssrf_validator import validate_url 

199 

200 start_time = _time.monotonic() 

201 total_budget = 2.0 # Cap aggregate probe duration across all hosts 

202 

203 for host in hosts: 

204 elapsed = _time.monotonic() - start_time 

205 remaining = total_budget - elapsed 

206 if remaining <= 0: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 break 

208 

209 if isinstance(host, dict): 

210 hostname = host.get("host") or host.get("hostname") 

211 if not hostname: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 continue 

213 scheme = host.get("scheme", "http") 

214 port = host.get("port") 

215 if port is None: 215 ↛ 216line 215 didn't jump to line 216 because the condition on line 215 was never true

216 port = 443 if scheme == "https" else 9200 

217 elif isinstance(host, str): 217 ↛ 229line 217 didn't jump to line 229 because the condition on line 217 was always true

218 url_str = host if "://" in host else f"http://{host}" 

219 try: 

220 parsed = urlparse(url_str) 

221 hostname = parsed.hostname or host 

222 scheme = parsed.scheme or "http" 

223 port = parsed.port 

224 if port is None: 

225 port = 443 if scheme == "https" else 9200 

226 except ValueError: 

227 continue 

228 else: 

229 continue 

230 

231 connection_host = str(hostname).strip("[]") 

232 url_host = ( 

233 f"[{connection_host}]" 

234 if ":" in connection_host 

235 else connection_host 

236 ) 

237 probe_url = f"{scheme}://{url_host}:{port}" 

238 if not validate_url( 

239 probe_url, allow_localhost=True, allow_private_ips=True 

240 ): 

241 logger.warning( 

242 "Elasticsearch host failed SSRF validation: {}", probe_url 

243 ) 

244 continue 

245 

246 timeout = min(remaining, 1.0) 

247 try: 

248 with socket.create_connection( 

249 (connection_host, port), timeout=timeout 

250 ): 

251 return True 

252 except (OSError, socket.timeout, ValueError): 

253 continue 

254 return False 

255 

256 def __init__( 

257 self, 

258 hosts: Optional[List[str]] = None, 

259 index_name: str = "documents", 

260 username: Optional[str] = None, 

261 password: Optional[str] = None, 

262 api_key: Optional[str] = None, 

263 cloud_id: Optional[str] = None, 

264 max_results: int = 10, 

265 highlight_fields: List[str] = ["content", "title"], 

266 search_fields: List[str] = ["content", "title"], 

267 filter_query: Optional[Dict[str, Any]] = None, 

268 llm: Optional[BaseLLM] = None, 

269 max_filtered_results: Optional[int] = None, 

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

271 ): 

272 """ 

273 Initialize the Elasticsearch search engine. 

274 

275 Args: 

276 hosts: List of Elasticsearch hosts 

277 index_name: Name of the index to search 

278 username: Optional username for authentication 

279 password: Optional password for authentication 

280 api_key: Optional API key for authentication 

281 cloud_id: Optional Elastic Cloud ID 

282 max_results: Maximum number of search results 

283 highlight_fields: Fields to highlight in search results 

284 search_fields: Fields to search in 

285 filter_query: Optional filter query in Elasticsearch DSL format 

286 llm: Language model for relevance filtering 

287 max_filtered_results: Maximum number of results to keep after filtering 

288 """ 

289 # Initialize the BaseSearchEngine with LLM, max_filtered_results, and max_results 

290 super().__init__( 

291 llm=llm, 

292 max_filtered_results=max_filtered_results, 

293 max_results=max_results, 

294 settings_snapshot=settings_snapshot, 

295 ) 

296 

297 self.index_name = index_name 

298 self.highlight_fields = self._ensure_list( 

299 highlight_fields, default=["content", "title"] 

300 ) 

301 self.search_fields = self._ensure_list( 

302 search_fields, default=["content", "title"] 

303 ) 

304 self.filter_query = filter_query or {} 

305 

306 # Store credentials for error-message redaction 

307 self._api_key = api_key 

308 self._password = password 

309 

310 # Normalize hosts – may arrive as a JSON-encoded string from settings 

311 hosts = self._ensure_list(hosts, default=["http://localhost:9200"]) 

312 

313 # Initialize the Elasticsearch client 

314 es_args: Dict[str, Any] = {} 

315 

316 # Basic authentication 

317 if username and password: 

318 es_args["basic_auth"] = (username, password) 

319 

320 # API key authentication 

321 if api_key: 

322 es_args["api_key"] = api_key 

323 

324 # Cloud ID for Elastic Cloud 

325 if cloud_id: 

326 # Egress policy: a cloud_id always targets a public Elastic Cloud 

327 # endpoint (*.cloud.es.io), but the url_setting reclassification 

328 # only inspects `hosts`. A cloud_id-only config would otherwise 

329 # keep the engine's static is_local=True and slip past 

330 # evaluate_engine, then connect at self.client.info() below. Reject 

331 # it when the effective scope forbids public egress (fail closed). 

332 if self._cloud_id_forbidden_by_scope(settings_snapshot): 

333 from ...security.egress.policy import ( 

334 Decision, 

335 PolicyDeniedError, 

336 ) 

337 

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

339 "refusing Elasticsearch cloud_id under private egress scope" 

340 ) 

341 raise PolicyDeniedError( 

342 Decision(False, "elasticsearch_cloud_id_public_egress"), 

343 target="search_engine:elasticsearch", 

344 ) 

345 es_args["cloud_id"] = cloud_id 

346 

347 # Connect to Elasticsearch 

348 self.client = Elasticsearch(hosts, **es_args) 

349 

350 # Verify connection 

351 try: 

352 info = self.client.info() 

353 logger.info( 

354 f"Connected to Elasticsearch cluster: {info.get('cluster_name')}" 

355 ) 

356 logger.info( 

357 f"Elasticsearch version: {info.get('version', {}).get('number')}" 

358 ) 

359 except Exception as e: 

360 safe_msg = self._scrub_error(e) 

361 logger.warning(f"Failed to connect to Elasticsearch: {safe_msg}") 

362 raise ConnectionError( 

363 f"Could not connect to Elasticsearch: {safe_msg}" 

364 ) from None 

365 

366 def close(self) -> None: 

367 """Close the Elasticsearch client and its connection pool.""" 

368 from ...utilities.resource_utils import safe_close 

369 

370 safe_close(self.client, "Elasticsearch client") 

371 super().close() 

372 

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

374 """ 

375 Get preview information for Elasticsearch documents. 

376 

377 Args: 

378 query: The search query 

379 

380 Returns: 

381 List of preview dictionaries 

382 """ 

383 logger.info( 

384 f"Getting document previews from Elasticsearch with query: {query}" 

385 ) 

386 

387 try: 

388 # Build the search query 

389 search_query = { 

390 "query": { 

391 "multi_match": { 

392 "query": query, 

393 "fields": self.search_fields, 

394 "type": "best_fields", 

395 "tie_breaker": 0.3, 

396 } 

397 }, 

398 "highlight": { 

399 "fields": {field: {} for field in self.highlight_fields}, 

400 "pre_tags": ["<em>"], 

401 "post_tags": ["</em>"], 

402 }, 

403 "size": self.max_results, 

404 } 

405 

406 # Add filter if provided 

407 if self.filter_query: 

408 search_query["query"] = { 

409 "bool": { 

410 "must": search_query["query"], 

411 "filter": self.filter_query, 

412 } 

413 } 

414 

415 # Execute the search 

416 response = self.client.search( 

417 index=self.index_name, 

418 body=search_query, 

419 ) 

420 

421 # Process the search results 

422 hits = response.get("hits", {}).get("hits", []) 

423 

424 # Format results as previews with basic information 

425 previews = [] 

426 for hit in hits: 

427 source = hit.get("_source", {}) 

428 highlight = hit.get("highlight", {}) 

429 

430 # Extract highlighted snippets or fall back to original content 

431 snippet = "" 

432 for field in self.highlight_fields: 

433 if highlight.get(field): 

434 # Join all highlights for this field 

435 field_snippets = " ... ".join(highlight[field]) 

436 snippet += field_snippets + " " 

437 

438 # If no highlights, use a portion of the content 

439 if not snippet and "content" in source: 

440 content = source.get("content", "") 

441 snippet = ( 

442 content[:SNIPPET_LENGTH_SHORT] + "..." 

443 if len(content) > SNIPPET_LENGTH_SHORT 

444 else content 

445 ) 

446 

447 # Create preview object 

448 preview = { 

449 "id": hit.get("_id", ""), 

450 "title": source.get("title", "Untitled Document"), 

451 "link": source.get("url", "") 

452 or f"elasticsearch://{self.index_name}/{hit.get('_id', '')}", 

453 "snippet": snippet.strip(), 

454 "score": hit.get("_score", 0), 

455 "_index": hit.get("_index", self.index_name), 

456 } 

457 

458 previews.append(preview) 

459 

460 logger.info( 

461 f"Found {len(previews)} preview results from Elasticsearch" 

462 ) 

463 return previews 

464 

465 except Exception as e: 

466 safe_msg = self._scrub_error(e) 

467 logger.warning(f"Error getting Elasticsearch previews: {safe_msg}") 

468 return [] 

469 

470 def _get_full_content( 

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

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

473 """ 

474 Get full content for the relevant Elasticsearch documents. 

475 

476 Args: 

477 relevant_items: List of relevant preview dictionaries 

478 

479 Returns: 

480 List of result dictionaries with full content 

481 """ 

482 logger.info("Getting full content for relevant Elasticsearch documents") 

483 

484 results = [] 

485 for item in relevant_items: 

486 # Start with the preview data 

487 result = item.copy() 

488 

489 # Get the document ID 

490 doc_id = item.get("id") 

491 if not doc_id: 

492 # Skip items without ID 

493 logger.warning(f"Skipping item without ID: {item}") 

494 results.append(result) 

495 continue 

496 

497 try: 

498 # Fetch the full document 

499 doc_response = self.client.get( 

500 index=self.index_name, 

501 id=doc_id, 

502 ) 

503 

504 # Get the source document 

505 source = doc_response.get("_source", {}) 

506 

507 # Add full content to the result 

508 result["content"] = source.get( 

509 "content", result.get("snippet", "") 

510 ) 

511 result["full_content"] = source.get("content", "") 

512 

513 # Add metadata from source 

514 for key, value in source.items(): 

515 if key not in result and key not in ["content"]: 

516 result[key] = value 

517 

518 except Exception as e: 

519 safe_msg = self._scrub_error(e) 

520 logger.warning( 

521 f"Error fetching full content for document {doc_id}: {safe_msg}" 

522 ) 

523 # Keep the preview data if we can't get the full content 

524 

525 results.append(result) 

526 

527 return results 

528 

529 def search_by_query_string(self, query_string: str) -> List[Dict[str, Any]]: 

530 """ 

531 Perform a search using Elasticsearch Query String syntax. 

532 

533 Args: 

534 query_string: The query in Elasticsearch Query String syntax 

535 

536 Returns: 

537 List of search results 

538 """ 

539 try: 

540 # Build the search query 

541 search_query = { 

542 "query": { 

543 "query_string": { 

544 "query": query_string, 

545 "fields": self.search_fields, 

546 } 

547 }, 

548 "highlight": { 

549 "fields": {field: {} for field in self.highlight_fields}, 

550 "pre_tags": ["<em>"], 

551 "post_tags": ["</em>"], 

552 }, 

553 "size": self.max_results, 

554 } 

555 

556 # Execute the search 

557 response = self.client.search( 

558 index=self.index_name, 

559 body=search_query, 

560 ) 

561 

562 # Process and return the results 

563 previews = self._process_es_response(response) 

564 return self._get_full_content(previews) 

565 

566 except Exception as e: 

567 safe_msg = self._scrub_error(e) 

568 logger.warning(f"Error in query_string search: {safe_msg}") 

569 return [] 

570 

571 def search_by_dsl(self, query_dsl: Dict[str, Any]) -> List[Dict[str, Any]]: 

572 """ 

573 Perform a search using Elasticsearch DSL (Query Domain Specific Language). 

574 

575 Args: 

576 query_dsl: The query in Elasticsearch DSL format 

577 

578 Returns: 

579 List of search results 

580 """ 

581 try: 

582 # Execute the search with the provided DSL 

583 response = self.client.search( 

584 index=self.index_name, 

585 body=query_dsl, 

586 ) 

587 

588 # Process and return the results 

589 previews = self._process_es_response(response) 

590 return self._get_full_content(previews) 

591 

592 except Exception as e: 

593 safe_msg = self._scrub_error(e) 

594 logger.warning(f"Error in DSL search: {safe_msg}") 

595 return [] 

596 

597 def _process_es_response(self, response: Any) -> List[Dict[str, Any]]: 

598 """ 

599 Process Elasticsearch response into preview dictionaries. 

600 

601 Args: 

602 response: Elasticsearch response dictionary 

603 

604 Returns: 

605 List of preview dictionaries 

606 """ 

607 hits = response.get("hits", {}).get("hits", []) 

608 

609 # Format results as previews 

610 previews = [] 

611 for hit in hits: 

612 source = hit.get("_source", {}) 

613 highlight = hit.get("highlight", {}) 

614 

615 # Extract highlighted snippets or fall back to original content 

616 snippet = "" 

617 for field in self.highlight_fields: 

618 if highlight.get(field): 

619 field_snippets = " ... ".join(highlight[field]) 

620 snippet += field_snippets + " " 

621 

622 # If no highlights, use a portion of the content 

623 if not snippet and "content" in source: 

624 content = source.get("content", "") 

625 snippet = ( 

626 content[:SNIPPET_LENGTH_SHORT] + "..." 

627 if len(content) > SNIPPET_LENGTH_SHORT 

628 else content 

629 ) 

630 

631 # Create preview object 

632 preview = { 

633 "id": hit.get("_id", ""), 

634 "title": source.get("title", "Untitled Document"), 

635 "link": source.get("url", "") 

636 or f"elasticsearch://{self.index_name}/{hit.get('_id', '')}", 

637 "snippet": snippet.strip(), 

638 "score": hit.get("_score", 0), 

639 "_index": hit.get("_index", self.index_name), 

640 } 

641 

642 previews.append(preview) 

643 

644 return previews