Coverage for src/local_deep_research/web_search_engines/engines/search_engine_federal_register.py: 92%

273 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1import re 

2import time 

3from dataclasses import dataclass, field 

4from typing import Any, Dict, Final, List, Optional, cast 

5from urllib.parse import quote 

6 

7import requests 

8from bs4 import BeautifulSoup 

9from langchain_core.language_models import BaseLLM 

10from urllib3.exceptions import LocationParseError 

11from urllib3.util import parse_url 

12 

13from ...security.safe_requests import safe_get 

14from ...security.secure_logging import logger 

15from ...security.ssrf_validator import RFC_FORBIDDEN_URL_CHARS_RE 

16from ..engine_availability import retry_after_from_error 

17from ..rate_limiting import RateLimitError 

18from ..search_engine_base import ( 

19 BaseSearchEngine, 

20 Exposure, 

21 Sensitivity, 

22 _SearchRunState, 

23) 

24 

25 

26FULL_CONTENT_DEADLINE_SECONDS: Final = 120 

27FULL_CONTENT_MAX_CHARS: Final = 50000 

28 

29 

30class _FullContentDeadlineReached(Exception): 

31 """Internal signal that the full-content budget ran out mid-item. 

32 

33 Raised by ``_request_timeout`` and caught by ``_get_full_content``, so 

34 every way of running out of time unwinds through a single path that 

35 still returns one result per input item. 

36 """ 

37 

38 

39class _UnexpectedResponseHost(Exception): 

40 """A list response whose redirect chain ended off Federal Register.""" 

41 

42 

43@dataclass 

44class _FederalRegisterRunState(_SearchRunState): 

45 full_content_deadline: float | None = None 

46 full_content_fetch_cache: dict[str, tuple[str | None, str | None]] = field( 

47 default_factory=dict 

48 ) 

49 

50 

51class FederalRegisterSearchEngine(BaseSearchEngine): 

52 """Search rules, proposed rules, notices, and presidential documents.""" 

53 

54 is_public = True 

55 is_generic = False 

56 is_scientific = False 

57 is_lexical = True 

58 needs_llm_relevance_filter = True 

59 egress_sensitivity = Sensitivity.NON_SENSITIVE 

60 egress_exposure = Exposure.EXPOSING 

61 

62 LIST_URL = "https://www.federalregister.gov/api/v1/documents.json" 

63 DETAIL_URL = "https://www.federalregister.gov/api/v1/documents" 

64 MAX_QUERY_LENGTH = 400 

65 MAX_RESULTS = 20 

66 REQUEST_TIMEOUT = 30 

67 RAW_TEXT_HOSTS = frozenset( 

68 {"www.federalregister.gov", "federalregister.gov"} 

69 ) 

70 # The values the API's ``conditions[type][]`` filter accepts: the four 

71 # the ``/documents/facets/type`` endpoint lists, plus CORRECT 

72 # ("Correction"), UNKNOWN ("Uncategorized Document") and SUNSHINE 

73 # ("Sunshine Act Document"), which the filter also recognises (its 

74 # reply names the type) though the facet omits them. SUNSHINE is a 

75 # legacy type that currently matches no documents. The API answers 

76 # any other value (``"rule"``, ``"Rule"``, ``"RULES"``) with HTTP 200 

77 # ``{"description": "Documents of type ", "count": 0}`` — 

78 # indistinguishable from a query that matched nothing — so an unknown 

79 # type must never be sent. 

80 VALID_DOCUMENT_TYPES: Final = ( 

81 "RULE", 

82 "PRORULE", 

83 "NOTICE", 

84 "PRESDOCU", 

85 "CORRECT", 

86 "UNKNOWN", 

87 "SUNSHINE", 

88 ) 

89 

90 def __init__( 

91 self, 

92 max_results: int = 10, 

93 document_types: Optional[List[str]] = None, 

94 llm: Optional[BaseLLM] = None, 

95 max_filtered_results: Optional[int] = None, 

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

97 search_snippets_only: bool = True, 

98 max_content_chars: int = FULL_CONTENT_MAX_CHARS, 

99 **kwargs, 

100 ): 

101 super().__init__( 

102 llm=llm, 

103 max_filtered_results=max_filtered_results, 

104 max_results=max_results, 

105 settings_snapshot=settings_snapshot, 

106 search_snippets_only=search_snippets_only, 

107 **kwargs, 

108 ) 

109 self.document_types = self._normalize_document_types(document_types) 

110 self.max_content_chars = max_content_chars 

111 

112 def _new_search_run_state(self) -> _FederalRegisterRunState: 

113 return _FederalRegisterRunState() 

114 

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

116 result_limit = min(self.max_results, self.MAX_RESULTS) 

117 

118 try: 

119 params: Dict[str, Any] = { 

120 "conditions[term]": query[: self.MAX_QUERY_LENGTH], 

121 "per_page": result_limit, 

122 "order": "newest", 

123 } 

124 if self.document_types: 

125 params["conditions[type][]"] = self.document_types 

126 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

127 self.engine_type 

128 ) 

129 response = safe_get( 

130 self.LIST_URL, 

131 params=params, 

132 timeout=self.REQUEST_TIMEOUT, 

133 ) 

134 # Checked before the 429: a rate limit (and Retry-After) from 

135 # an off-host redirect target says nothing about Federal 

136 # Register's own limit, and its body is not a Federal Register 

137 # answer either. 

138 self._raise_if_list_ended_off_host(response) 

139 self._raise_if_rate_limited(response) 

140 response.raise_for_status() 

141 data = response.json() 

142 except RateLimitError: 

143 raise 

144 except _UnexpectedResponseHost as error: 

145 self._search_failed = True 

146 self._record_search_failure(error) 

147 logger.warning( 

148 "Federal Register request ended on an unexpected host" 

149 ) 

150 return [] 

151 except requests.exceptions.RequestException as error: 

152 self._search_failed = True 

153 self._record_search_failure(error) 

154 status_code = getattr( 

155 getattr(error, "response", None), "status_code", None 

156 ) 

157 logger.warning( 

158 "Federal Register request failed " 

159 f"(status={status_code if status_code is not None else 'unknown'})" 

160 ) 

161 return [] 

162 except (AttributeError, KeyError, TypeError, ValueError) as error: 

163 self._search_failed = True 

164 self._record_search_failure(error) 

165 logger.warning("Federal Register response parsing failed") 

166 return [] 

167 

168 if not isinstance(data, dict): 

169 self._search_failed = True 

170 self._record_search_failure( 

171 ValueError("Federal Register response was not an object") 

172 ) 

173 logger.warning( 

174 "Federal Register response did not contain an object" 

175 ) 

176 return [] 

177 documents = data.get("results") 

178 if documents is None and self._is_zero_match(data): 

179 # FederalRegister.gov answers a query that matched nothing 

180 # with ``{"description": ..., "count": 0}`` and no 

181 # ``results`` key. That is a valid empty result, not a 

182 # provider failure: it must neither fail the search in 

183 # metrics nor cool the engine down for the rest of the run. 

184 return [] 

185 if not isinstance(documents, list): 

186 self._search_failed = True 

187 self._record_search_failure( 

188 ValueError("Federal Register response had no results list") 

189 ) 

190 logger.warning("Federal Register response did not contain results") 

191 return [] 

192 

193 previews = [] 

194 for document in documents[:result_limit]: 

195 if not isinstance(document, dict): 195 ↛ 196line 195 didn't jump to line 196 because the condition on line 195 was never true

196 continue 

197 document_number = document.get("document_number") 

198 if not isinstance(document_number, str) or not document_number: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true

199 continue 

200 previews.append( 

201 { 

202 "id": document_number, 

203 "title": document.get("title") or "Untitled", 

204 "link": document.get("html_url") or "", 

205 "snippet": document.get("abstract") 

206 or document.get("excerpts") 

207 or "", 

208 "type": document.get("type") or "", 

209 "agencies": document.get("agencies") or [], 

210 "publication_date": document.get("publication_date") or "", 

211 "pdf_url": document.get("pdf_url") or "", 

212 } 

213 ) 

214 return previews 

215 

216 def _get_full_content( 

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

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

219 state = cast(_FederalRegisterRunState, self._get_search_run_state()) 

220 deadline = state.full_content_deadline 

221 if deadline is None: 

222 deadline = time.monotonic() + FULL_CONTENT_DEADLINE_SECONDS 

223 state.full_content_deadline = deadline 

224 

225 fetch_cache = state.full_content_fetch_cache 

226 

227 pending = list(relevant_items) 

228 results: List[Dict[str, Any]] = [] 

229 for position, item in enumerate(pending): 

230 result = self._preview_fields(item) 

231 document_number = result.get("id") 

232 if not isinstance(document_number, str) or not document_number: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true

233 results.append(result) 

234 continue 

235 

236 cached_fetch = fetch_cache.get(document_number) 

237 if cached_fetch is not None and cached_fetch[1] is not None: 

238 cached_content = cached_fetch[1] 

239 result["full_content"] = cached_content 

240 results.append(result) 

241 continue 

242 

243 if time.monotonic() > deadline: 

244 results.append(result) 

245 results.extend( 

246 self._drain_without_fetching( 

247 pending[position + 1 :], fetch_cache 

248 ) 

249 ) 

250 return results 

251 

252 try: 

253 if cached_fetch is None: 253 ↛ 297line 253 didn't jump to line 297 because the condition on line 253 was always true

254 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

255 self.engine_type 

256 ) 

257 detail_response = safe_get( 

258 f"{self.DETAIL_URL}/{quote(document_number, safe='')}.json", 

259 timeout=self._request_timeout(deadline), 

260 ) 

261 if self._ended_off_host(detail_response): 

262 # Same reasoning as the list request: neither a 429 

263 # nor a body from another host is Federal 

264 # Register's. 

265 logger.warning( 

266 "Federal Register detail request ended on an " 

267 "unexpected host" 

268 ) 

269 fetch_cache[document_number] = (None, None) 

270 results.append(result) 

271 continue 

272 self._raise_if_rate_limited(detail_response) 

273 detail_response.raise_for_status() 

274 detail = detail_response.json() 

275 if not isinstance(detail, dict): 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true

276 logger.warning( 

277 "Federal Register detail response did not contain an object" 

278 ) 

279 fetch_cache[document_number] = (None, None) 

280 results.append(result) 

281 continue 

282 

283 raw_text_url = detail.get("raw_text_url") 

284 if not isinstance(raw_text_url, str) or not raw_text_url: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true

285 fetch_cache[document_number] = (None, None) 

286 results.append(result) 

287 continue 

288 if not self._is_allowed_raw_text_url(raw_text_url): 

289 logger.warning( 

290 "Federal Register raw-text URL had an unexpected host" 

291 ) 

292 fetch_cache[document_number] = (None, None) 

293 results.append(result) 

294 continue 

295 fetch_cache[document_number] = (raw_text_url, None) 

296 else: 

297 raw_text_url = cached_fetch[0] 

298 

299 if raw_text_url is None: 299 ↛ 300line 299 didn't jump to line 300 because the condition on line 299 was never true

300 results.append(result) 

301 continue 

302 

303 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

304 self.engine_type 

305 ) 

306 try: 

307 text_response = safe_get( 

308 raw_text_url, 

309 timeout=self._request_timeout(deadline), 

310 require_https=True, 

311 ) 

312 except ValueError as refusal: 

313 # safe_get refuses (rather than fails) with ValueError: 

314 # a redirect hop to cleartext, SSRF validation, or an 

315 # oversized body. Log that as a refusal, not a parse 

316 # failure, and never echo the URL (its query string can 

317 # carry tokens). The result keeps its preview fields. 

318 if "downgrade to cleartext" in str(refusal): 318 ↛ 324line 318 didn't jump to line 324 because the condition on line 318 was always true

319 logger.warning( 

320 "Federal Register raw-text fetch refused: " 

321 "redirect to non-https" 

322 ) 

323 else: 

324 logger.warning( 

325 "Federal Register raw-text fetch refused by the " 

326 "request guard" 

327 ) 

328 fetch_cache[document_number] = (None, None) 

329 results.append(result) 

330 continue 

331 # Host check first: a 429 (and its Retry-After) from an 

332 # off-host redirect target says nothing about Federal 

333 # Register's own rate limit. 

334 if not self._is_allowed_raw_text_url( 

335 getattr(text_response, "url", None) 

336 ): 

337 logger.warning( 

338 "Federal Register raw-text fetch ended on an " 

339 "unexpected host" 

340 ) 

341 fetch_cache[document_number] = (None, None) 

342 results.append(result) 

343 continue 

344 self._raise_if_rate_limited(text_response) 

345 text_response.raise_for_status() 

346 full_content = self._truncate_full_content( 

347 self._normalize_full_content(text_response.text) 

348 ) 

349 result["full_content"] = full_content 

350 fetch_cache[document_number] = (raw_text_url, full_content) 

351 except RateLimitError: 

352 # The previews are already paid for. Re-raising would make 

353 # ``BaseSearchEngine.run()`` retry the whole search and, 

354 # once retries ran out, return [] — losing every preview 

355 # over an optional enrichment. So a 429 here only stops 

356 # further full-content fetches, exactly like the deadline. 

357 # The availability cooldown is not recorded: usable results 

358 # supersede a failure signal, and the next search's preview 

359 # request raises its own RateLimitError if the limit holds. 

360 # The adaptive rate limiter must still learn of the 429, 

361 # though, or a sustained limit is re-hit by every search 

362 # without the wait ever growing: the flag makes ``run()`` 

363 # record this attempt as a rate-limit failure instead of a 

364 # success. 

365 self._rate_limited_after_previews = True 

366 results.append(result) 

367 results.extend( 

368 self._drain_without_fetching( 

369 pending[position + 1 :], 

370 fetch_cache, 

371 reason="was rate limited (HTTP 429)", 

372 ) 

373 ) 

374 return results 

375 except _FullContentDeadlineReached: 

376 results.append(result) 

377 results.extend( 

378 self._drain_without_fetching( 

379 pending[position + 1 :], fetch_cache 

380 ) 

381 ) 

382 return results 

383 except requests.exceptions.RequestException as error: 

384 status_code = getattr( 

385 getattr(error, "response", None), "status_code", None 

386 ) 

387 logger.warning( 

388 "Federal Register full-content request failed " 

389 f"(status={status_code if status_code is not None else 'unknown'})" 

390 ) 

391 except (AttributeError, KeyError, TypeError, ValueError): 

392 logger.warning("Federal Register full-content parsing failed") 

393 results.append(result) 

394 return results 

395 

396 @classmethod 

397 def _normalize_document_types(cls, document_types: Any) -> List[str]: 

398 """Strip, uppercase, de-duplicate and validate *document_types*. 

399 

400 Values outside ``VALID_DOCUMENT_TYPES`` are dropped with a warning 

401 rather than sent: the API turns an unknown type into an empty 

402 result, so one typo would otherwise make every search return 

403 nothing. If every configured value is invalid the filter is 

404 removed, i.e. all document types are searched. 

405 """ 

406 normalized: List[str] = [] 

407 invalid_count = 0 

408 for value in cls._ensure_list(document_types): 

409 candidate = value.strip().upper() if isinstance(value, str) else "" 

410 if candidate not in cls.VALID_DOCUMENT_TYPES: 

411 invalid_count += 1 

412 elif candidate not in normalized: 

413 normalized.append(candidate) 

414 if invalid_count: 

415 logger.warning( 

416 f"Ignoring {invalid_count} invalid Federal Register document " 

417 f"type(s); valid values are {', '.join(cls.VALID_DOCUMENT_TYPES)}" 

418 + ("" if normalized else ". Searching all document types.") 

419 ) 

420 return normalized 

421 

422 @staticmethod 

423 def _is_zero_match(data: Dict[str, Any]) -> bool: 

424 """True for the API's no-match reply: an integer ``count`` of 0.""" 

425 count = data.get("count") 

426 return ( 

427 isinstance(count, int) 

428 and not isinstance(count, bool) 

429 and (count == 0) 

430 ) 

431 

432 @staticmethod 

433 def _raise_if_rate_limited(response: Any) -> None: 

434 """Raise ``RateLimitError`` when *response* is an HTTP 429. 

435 

436 Decided by the status code alone. The base class's 

437 ``_raise_if_rate_limit`` also matches phrases such as "throttl" or 

438 "ratelimit" in an exception's text, and a ``requests`` exception's 

439 text carries the request URL — here, the user's query — so a 400, 

440 5xx or connection error on a query like "FCC throttling rules" 

441 would be misread as a rate limit (retries, a 300-second cooldown). 

442 A 429's ``Retry-After`` is kept on the error; for the preview 

443 request engine availability uses it as the cooldown (in the 

444 full-content phase ``_get_full_content`` catches the error and only 

445 stops fetching). (A 503 is not a rate limit here: 

446 ``_record_search_failure`` classifies it as provider-unavailable.) 

447 """ 

448 if getattr(response, "status_code", None) != 429: 

449 return 

450 raise RateLimitError( 

451 "Federal Register rate limit reached (HTTP 429)", 

452 retry_after=retry_after_from_error( 

453 requests.HTTPError(response=response) 

454 ), 

455 ) 

456 

457 def _raise_if_list_ended_off_host(self, response: Any) -> None: 

458 """Raise ``_UnexpectedResponseHost`` for an off-host list response.""" 

459 if self._ended_off_host(response): 

460 raise _UnexpectedResponseHost( 

461 "Federal Register list request ended on an unexpected host" 

462 ) 

463 

464 def _ended_off_host(self, response: Any) -> bool: 

465 """True when *response*'s final URL is not on a Federal Register host. 

466 

467 ``safe_get`` follows redirects, so a response can come from 

468 wherever the last hop pointed. A response whose ``url`` is not a 

469 string (a test double that never set one) is taken to come from 

470 the requested Federal Register URL; ``requests`` always sets it. 

471 """ 

472 final_url = getattr(response, "url", None) 

473 return isinstance(final_url, str) and not self._is_allowed_raw_text_url( 

474 final_url 

475 ) 

476 

477 @staticmethod 

478 def _preview_fields(item: Dict[str, Any]) -> Dict[str, Any]: 

479 """Copy an item's public fields, dropping internal ``_`` keys.""" 

480 return { 

481 key: value for key, value in item.items() if not key.startswith("_") 

482 } 

483 

484 def _drain_without_fetching( 

485 self, 

486 items: List[Dict[str, Any]], 

487 fetch_cache: dict[str, tuple[str | None, str | None]], 

488 reason: str = "exceeded its deadline", 

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

490 """Return the items an expired deadline or a 429 stops us fetching. 

491 

492 ``BaseSearchEngine._get_full_content`` returns one result per input 

493 item, and ``run()`` records the returned count as the search's 

494 result count, so running out of time may only cost the 

495 ``full_content`` key — never the title/link/snippet/agencies the 

496 preview phase already paid for. Text already in ``fetch_cache`` 

497 costs nothing to hand back, so it still travels. 

498 """ 

499 logger.info( 

500 f"Full-content phase {reason}, returning the remaining " 

501 "items without full content" 

502 ) 

503 drained = [] 

504 for item in items: 

505 result = self._preview_fields(item) 

506 document_number = result.get("id") 

507 cached_fetch = ( 

508 fetch_cache.get(document_number) 

509 if isinstance(document_number, str) 

510 else None 

511 ) 

512 if cached_fetch is not None and cached_fetch[1] is not None: 512 ↛ 513line 512 didn't jump to line 513 because the condition on line 512 was never true

513 result["full_content"] = cached_fetch[1] 

514 drained.append(result) 

515 return drained 

516 

517 def _request_timeout(self, deadline: float) -> int: 

518 """Seconds to allow the next request, bounded by *deadline*. 

519 

520 Raises ``_FullContentDeadlineReached`` once the budget is spent. 

521 The one-second floor keeps a sub-second remainder from truncating 

522 to ``0``: ``int(0.4)`` is ``0``, and a zero timeout is not "no 

523 limit" but an immediate failure, so the item burned its turn for 

524 nothing. 

525 """ 

526 remaining = deadline - time.monotonic() 

527 if remaining <= 0: 

528 raise _FullContentDeadlineReached 

529 return min(self.REQUEST_TIMEOUT, max(1, int(remaining))) 

530 

531 def _is_allowed_raw_text_url(self, url: Any) -> bool: 

532 """True when *url* is an ``https`` URL on a Federal Register host. 

533 

534 Parsed with ``urllib3.util.parse_url`` — the parser ``requests`` 

535 resolves the destination with — because ``urlparse`` disagrees 

536 with it on backslash authorities (GHSA-g23j-2vwm-5c25), the same 

537 reason ``security/ssrf_validator.py`` validates against urllib3: 

538 ``https://evil.example\\@www.federalregister.gov/x`` has host 

539 ``www.federalregister.gov`` to ``urlparse`` but connects to 

540 ``evil.example``. The scheme and the RFC 3986 character class are 

541 checked here too — the same rule ``validate_url`` applies — so 

542 ``file://``, ``ftp://`` and every parser-differential payload are 

543 refused by this guard alone rather than by a downstream layer. 

544 """ 

545 if not isinstance(url, str) or not url: 

546 return False 

547 if RFC_FORBIDDEN_URL_CHARS_RE.search(url): 

548 return False 

549 try: 

550 parsed = parse_url(url) 

551 except LocationParseError: 

552 return False 

553 if (parsed.scheme or "").lower() != "https": 

554 return False 

555 return (parsed.host or "").lower() in self.RAW_TEXT_HOSTS 

556 

557 @staticmethod 

558 def _normalize_full_content(text: str) -> str: 

559 """Unwrap the provider's HTML/pre representation without reflowing it. 

560 

561 ``raw_text_url`` can serve an HTML document as ``text/plain``. 

562 Its preformatted body carries paragraph breaks and aligned tables, 

563 so keep its whitespace and inline text. Ordinary plain text must 

564 not be HTML-decoded or lose literal angle-bracketed expressions. 

565 """ 

566 text = text.replace("\x00", "") 

567 if not re.match( 

568 r"\s*(?:<!doctype\s+html\b[^>]*>\s*)?<html(?:\s|>)", 

569 text.lstrip("\ufeff"), 

570 re.IGNORECASE, 

571 ): 

572 return text 

573 

574 soup = BeautifulSoup(text, "html.parser") 

575 if soup.body is None: 

576 return text 

577 pre_blocks = soup.body.find_all("pre", recursive=False) 

578 # An unfamiliar layout must not silently lose document sections. 

579 if len(pre_blocks) != 1: 

580 return text 

581 document = pre_blocks[0] 

582 if soup.body.get_text().strip() != document.get_text().strip(): 

583 return text 

584 for line_break in document.find_all("br"): 

585 line_break.replace_with("\n") 

586 for bullet in document.find_all("bullet"): 

587 # GPO's nonstandard <bullet> tags are unclosed. Replacing the 

588 # tag itself would discard the remainder nested inside it. 

589 bullet.insert_before("•") 

590 bullet.unwrap() 

591 return document.get_text() 

592 

593 def _truncate_full_content(self, text: str) -> str: 

594 """Bound one document's text before it reaches an LLM prompt. 

595 

596 Mirrors ``search_engine_gutenberg.py``'s ``max_content_chars``: 

597 the raw-text endpoint serves whole rules, and the string is 

598 inlined verbatim into the citation prompt. 

599 """ 

600 if len(text) > self.max_content_chars: 

601 return text[: self.max_content_chars] + "\n\n[... truncated ...]" 

602 return text