Coverage for src/local_deep_research/utilities/research_content_cache.py: 82%

234 statements  

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

1"""Single shared content cache for an entire research run. 

2 

3Every consumer that needs the full text of a webpage, a RAG chunk, a 

4library document — whether it is the ``web_search`` tool (via 

5``FullSearchResults`` / ``BaseSearchEngine._get_full_content``), the 

6``fetch_content`` tool (via ``ContentFetcher`` + the summary-LLM prompt 

7construction), or the library / collection RAG engines — resolves through 

8this one cache. The initial quick-report phase, the detailed-report 

9subsections and all parallel subagents therefore share previously fetched 

10content instead of re-fetching the same URLs on the spot. 

11 

12Lifetime: one instance per research run, owned by ``AdvancedSearchSystem`` 

13(``system.content_cache``) and threaded explicitly into strategies, tools 

14and engines. Deep funnels that cannot receive an explicit reference 

15(``batch_fetch_and_extract``, ``ContentFetcher.fetch``, 

16``resolve_library_document``) fall back to the research-scoped registry 

17below, keyed by the ``research_id`` carried in the thread-local search 

18context — so freshly created per-call engines still hit the same cache. 

19 

20When no cache is bound (no explicit instance and no research context, e.g. 

21legacy unit tests) all helpers return ``None`` and callers fall back to 

22direct fetching, preserving historical behaviour. 

23""" 

24 

25from __future__ import annotations 

26 

27import threading 

28from typing import Any, Callable 

29 

30 

31def normalize_collection_scope(collection_id: Any) -> str | None: 

32 """Normalise a collection identifier to its ``collection_<id>`` form. 

33 

34 Returns ``None`` for missing/empty values. A value already in 

35 ``collection_<id>`` form is returned unchanged so ``"coll-B"`` and 

36 ``"collection_coll-B"`` collapse to one cache namespace (matching the 

37 ``DocumentChunk.collection_name`` normalisation in 

38 ``LibraryRAGSearchEngine._get_full_content``). Human-readable names 

39 that do not look like ``collection_*`` still get a distinct namespace 

40 (under-merge, never cross-contamination). 

41 """ 

42 if collection_id is None or isinstance(collection_id, bool): 

43 return None 

44 if not isinstance(collection_id, str): 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true

45 collection_id = str(collection_id) 

46 stripped = collection_id.strip() 

47 if not stripped: 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true

48 return None 

49 if stripped.startswith("collection_"): 

50 return stripped 

51 return f"collection_{stripped}" 

52 

53 

54def _normalize_chunk_display(display: str) -> str: 

55 """Merge the two chunk spellings to one cache identity. 

56 

57 ``/library/document/<id>/chunks#chunk-N`` (emitted by 

58 ``build_chunk_anchor_url``) and ``/library/document/<id>#chunk-N`` 

59 (accepted by the resolver/tests) denote the same chunk text. Strip a 

60 trailing ``/chunks`` view when a chunk fragment is present so both 

61 share one entry (under-merge only, no corruption). 

62 """ 

63 if "#chunk-" not in display: 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true

64 return display 

65 path, _, fragment = display.partition("#") 

66 if path.endswith("/chunks"): 

67 path = path[: -len("/chunks")] 

68 return f"{path}#{fragment}" 

69 return display 

70 

71 

72def content_cache_key(url: Any, collection_id: Any = None) -> str: 

73 """Normalise *url* into a stable cache key. 

74 

75 Web URLs collapse tracking params / default ports via 

76 ``content_web_cache_key`` (NOT the bibliography ``canonical_url_key``: 

77 the path keeps its trailing slash, userinfo is preserved, and 

78 fragments are preserved for client-routed SPAs) while 

79 ``https://example.com/p?utm_x=1`` and ``https://example.com/p`` share 

80 one entry (accepting potential over-merge on sites that vary content per 

81 tracking param). Library routes keep their chunk anchor (``#chunk-<n>``) 

82 via ``library_display_url`` because a chunk and its parent document 

83 are *different* content and must not collide. Anything unparseable 

84 falls back to the stripped raw string. 

85 

86 Library *chunk* URLs are additionally scoped by *collection_id*: 

87 the same ``(document, chunk_index)`` can hold different text in two 

88 collections (different chunk settings), so ``collection_A#chunk-3`` 

89 and ``collection_B#chunk-3`` must not share an entry. Document-level 

90 library URLs (``Document.text_content`` is collection-independent) 

91 and web URLs ignore *collection_id*. 

92 """ 

93 if not isinstance(url, str): 

94 return f"raw:{url!r}" 

95 stripped = url.strip() 

96 if not stripped: 

97 return "raw:" 

98 try: 

99 from .url_utils import content_web_cache_key, library_display_url 

100 except Exception: # pragma: no cover - defensive 

101 return f"raw:{stripped}" 

102 try: 

103 display = library_display_url(stripped) 

104 except Exception: # pragma: no cover - defensive 

105 display = None 

106 if display is not None: 

107 if "#chunk-" in display: 

108 normalised = _normalize_chunk_display(display) 

109 scope = normalize_collection_scope(collection_id) 

110 if scope is not None: 

111 return f"lib:{normalised}|collection:{scope}" 

112 return f"lib:{normalised}|collection:__unscoped__" 

113 return f"lib:{display}" 

114 try: 

115 canon = content_web_cache_key(stripped) 

116 except Exception: # pragma: no cover - defensive 

117 canon = "" 

118 if canon: 118 ↛ 120line 118 didn't jump to line 120 because the condition on line 118 was always true

119 return f"web:{canon}" 

120 return f"raw:{stripped}" 

121 

122 

123class ResearchContentCache: 

124 """Thread-safe in-memory store of fetched page / chunk text. 

125 

126 Entries hold the *fullest* fetched content (before per-caller 

127 truncation). Callers slice to their own ``max_length`` on the way out, 

128 so a ``fetch_content`` call capped at 10k chars and a ``web_search`` 

129 full-content call share one entry without data loss: the longer fetch 

130 wins on ``put``. 

131 

132 Memory is bounded: at most ``max_entries`` entries and 

133 ``max_bytes`` of content bytes are retained (evict-oldest/FIFO). 

134 A single oversized page is still stored (it evicts everything else 

135 rather than being dropped) so callers never lose the entry they just 

136 fetched. 

137 """ 

138 

139 # Defaults keep a full research run (quick phase + subsections + 

140 # subagents) in memory while capping multi-user server growth: 

141 # 200 x ~100KB typical pages ~= 20MB; worst case 32MB per run. 

142 DEFAULT_MAX_ENTRIES = 200 

143 DEFAULT_MAX_BYTES = 32 * 1024 * 1024 

144 # Cap for the SSRF-verdict memo (per-run, FIFO). Each entry is a 

145 # small bool keyed by (url, allow_private, block_link_local); 1000 

146 # covers a full run's distinct URLs without unbounded growth. 

147 MAX_VALIDATION_MEMO_ENTRIES = 1000 

148 

149 def __init__( 

150 self, 

151 max_entries: int = DEFAULT_MAX_ENTRIES, 

152 max_bytes: int = DEFAULT_MAX_BYTES, 

153 ) -> None: 

154 self._lock = threading.RLock() 

155 # key -> {"content": str, "title": str|None, "url": str} 

156 self._entries: dict[str, dict[str, Any]] = {} 

157 # key -> threading.Event for in-flight fetches (thundering-herd guard) 

158 self._inflight: dict[str, threading.Event] = {} 

159 self._max_entries = max(1, int(max_entries)) 

160 self._max_bytes = max(1, int(max_bytes)) 

161 self._total_bytes = 0 

162 self._hits = 0 

163 self._misses = 0 

164 self._puts = 0 

165 self._evictions = 0 

166 # (url, allow_private, block_link_local) -> True. Per-run memo 

167 # for the SSRF grant gates so repeat cache hits do not pay a 

168 # getaddrinfo per read (and stay servable when DNS drops 

169 # mid-run after a successful verdict). Only True verdicts are 

170 # pinned: validate_url converts a transient DNS failure 

171 # (socket.gaierror) into a False verdict rather than raising, 

172 # so pinning False would block every later fetch of that URL 

173 # for the rest of the run after one blip — a False verdict only 

174 # skips the cache fast path and the fetcher/downloader 

175 # re-validates on its own, so recomputing False never blocks 

176 # recovery. 

177 self._validation_memo: dict[tuple[str, bool, bool], bool] = {} 

178 

179 # -- basic ops ------------------------------------------------------ 

180 def get(self, url: str, collection_id: Any = None) -> dict[str, Any] | None: 

181 key = content_cache_key(url, collection_id) 

182 with self._lock: 

183 entry = self._entries.get(key) 

184 if entry is not None: 

185 self._hits += 1 

186 return dict(entry) 

187 self._misses += 1 

188 return None 

189 

190 def _evict_if_needed(self) -> None: 

191 """Evict oldest entries until within entry/byte caps (caller holds lock).""" 

192 while ( 

193 len(self._entries) > self._max_entries 

194 or self._total_bytes > self._max_bytes 

195 ) and len(self._entries) > 1: 

196 oldest_key = next(iter(self._entries)) 

197 oldest = self._entries.pop(oldest_key) 

198 self._total_bytes -= len(oldest.get("content", "") or "") 

199 if self._total_bytes < 0: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true

200 self._total_bytes = 0 

201 self._evictions += 1 

202 

203 def put( 

204 self, 

205 url: str, 

206 content: str, 

207 title: str | None = None, 

208 url_out: str | None = None, 

209 author: str | None = None, 

210 published_date: Any | None = None, 

211 collection_id: Any = None, 

212 ) -> None: 

213 """Store *content* for *url*, keeping the longest version on conflict. 

214 

215 Only full pre-truncation text must be stored here — callers slice 

216 to their own ``max_length`` (plus any truncation marker) on the way 

217 out. Storing truncated display output would let a 

218 ``10_000 + marker`` slice overwrite the complete ``10_001``-char 

219 page under longest-wins and lose the tail for later callers with 

220 larger limits. 

221 

222 Library chunk URLs are namespaced by *collection_id* (see 

223 :func:`content_cache_key`): the same chunk index can hold 

224 different text per collection. 

225 """ 

226 if not isinstance(content, str): 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true

227 return 

228 key = content_cache_key(url, collection_id) 

229 with self._lock: 

230 existing = self._entries.get(key) 

231 if existing is not None and len(existing.get("content", "")) >= len( 

232 content 

233 ): 

234 return 

235 if existing is not None: 

236 self._total_bytes -= len(existing.get("content", "") or "") 

237 # Re-insert as newest so FIFO eviction drops truly old URLs. 

238 self._entries.pop(key, None) 

239 entry: dict[str, Any] = { 

240 "content": content, 

241 "title": title, 

242 "url": url_out if url_out is not None else url, 

243 } 

244 if author is not None: 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true

245 entry["author"] = author 

246 if published_date is not None: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true

247 entry["published_date"] = published_date 

248 self._entries[key] = entry 

249 self._total_bytes += len(content) 

250 self._puts += 1 

251 self._evict_if_needed() 

252 

253 def get_or_fetch( 

254 self, 

255 url: str, 

256 fetch_fn: Callable[[], dict[str, Any] | None], 

257 *, 

258 wait_timeout: float = 120.0, 

259 collection_id: Any = None, 

260 ) -> dict[str, Any] | None: 

261 """Return the cached entry for *url* or fetch it exactly once. 

262 

263 Concurrent callers for the same URL coalesce: the first becomes the 

264 fetcher, the rest wait on an event and then read the cache. If the 

265 fetcher fails (exception or ``None``) waiters fall through and try 

266 their own fetch instead of caching the failure. A waiter whose owner 

267 does not finish within ``wait_timeout`` raises ``TimeoutError`` without 

268 cancelling the owner; later callers can still reuse its result. 

269 Results marked ``content_is_complete=False`` are returned without 

270 caching, so a metadata fallback cannot prevent a later full fetch. 

271 

272 The result is :meth:`put` *before* waiters are woken — waking first 

273 would let a waiter re-enter as a second fetcher and double-fetch. 

274 

275 Library chunk URLs are namespaced by *collection_id* (see 

276 :func:`content_cache_key`). 

277 """ 

278 key = content_cache_key(url, collection_id) 

279 while True: 

280 with self._lock: 

281 entry = self._entries.get(key) 

282 if entry is not None: 

283 self._hits += 1 

284 return dict(entry) 

285 self._misses += 1 

286 event = self._inflight.get(key) 

287 if event is None: 

288 event = threading.Event() 

289 self._inflight[key] = event 

290 break 

291 if not event.wait(timeout=wait_timeout): 

292 # Keep the original owner's claim: a timed-out waiter must 

293 # neither recurse forever nor release another thread's fetch. 

294 raise TimeoutError("Timed out waiting for shared content") 

295 # The owner completed without caching a result. Retry election 

296 # iteratively so repeated failures never grow the call stack. 

297 try: 

298 result = fetch_fn() 

299 if result is None: 

300 return None 

301 content = ( 

302 result.get("content") if isinstance(result, dict) else None 

303 ) 

304 if ( 

305 isinstance(content, str) 

306 and result.get("content_is_complete") is not False 

307 ): 

308 # Cache BEFORE waking waiters so they observe the fresh 

309 # entry. The finally below wakes waiters; put failures 

310 # still wake via finally so waiters retry instead of 

311 # hanging on a dead event. 

312 self.put( 

313 url, 

314 content, 

315 title=result.get("title") 

316 if isinstance(result, dict) 

317 else None, 

318 url_out=result.get("url") 

319 if isinstance(result, dict) 

320 else None, 

321 author=result.get("author") 

322 if isinstance(result, dict) 

323 else None, 

324 published_date=result.get("published_date") 

325 if isinstance(result, dict) 

326 else None, 

327 collection_id=collection_id, 

328 ) 

329 with self._lock: 

330 cached = self._entries.get(key) 

331 if cached is not None: 

332 return dict(cached) 

333 return result 

334 finally: 

335 # Always release the in-flight slot and wake waiters — even 

336 # for BaseException (cancellation/KeyboardInterrupt) and put 

337 # failures — or a later caller would block until wait_timeout 

338 # on a dead event. Store-before-wake holds because put runs 

339 # inside try before this finally signals the event. 

340 with self._lock: 

341 self._inflight.pop(key, None) 

342 event.set() 

343 

344 # -- SSRF-verdict memo (per-run, defense-in-depth) ------------------ 

345 @staticmethod 

346 def _validation_memo_key( 

347 url: Any, allow_private_ips: bool, block_link_local: bool 

348 ) -> tuple[str, bool, bool] | None: 

349 if not isinstance(url, str): 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true

350 return None 

351 stripped = url.strip() 

352 if not stripped: 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true

353 return None 

354 return (stripped, bool(allow_private_ips), bool(block_link_local)) 

355 

356 def get_validation( 

357 self, url: str, allow_private_ips: bool, block_link_local: bool 

358 ) -> bool | None: 

359 """Return the memoized SSRF verdict, or None when not memoized.""" 

360 key = self._validation_memo_key( 

361 url, allow_private_ips, block_link_local 

362 ) 

363 if key is None: 363 ↛ 364line 363 didn't jump to line 364 because the condition on line 363 was never true

364 return None 

365 with self._lock: 

366 return self._validation_memo.get(key) 

367 

368 def set_validation( 

369 self, 

370 url: str, 

371 allow_private_ips: bool, 

372 block_link_local: bool, 

373 allowed: bool, 

374 ) -> None: 

375 """Memoize a successful SSRF verdict (FIFO-bounded, per-run). 

376 

377 Only True is stored — see :meth:`validated` for why False is 

378 never pinned. 

379 """ 

380 if not allowed: 

381 return 

382 key = self._validation_memo_key( 

383 url, allow_private_ips, block_link_local 

384 ) 

385 if key is None: 

386 return 

387 with self._lock: 

388 if key not in self._validation_memo: 

389 while ( 

390 len(self._validation_memo) 

391 >= self.MAX_VALIDATION_MEMO_ENTRIES 

392 ): 

393 try: 

394 self._validation_memo.pop( 

395 next(iter(self._validation_memo)) 

396 ) 

397 except StopIteration: 

398 break 

399 self._validation_memo[key] = True 

400 

401 def validated( 

402 self, 

403 url: str, 

404 allow_private_ips: bool, 

405 block_link_local: bool, 

406 compute: Callable[[], bool], 

407 ) -> bool: 

408 """Return memoized success or compute, pinning only True. 

409 

410 Thread-safe: concurrent callers for the same key may compute in 

411 parallel (DNS outside the lock); the first True wins and stays 

412 for the run like ``policy._dns_cache``. False is never stored: 

413 ``validate_url`` maps a transient DNS failure (socket.gaierror) 

414 to False instead of raising, so pinning it would refuse every 

415 later fetch of that URL until the run ends even after DNS heals. 

416 Recomputing False costs one resolution per refused hit (bounded, 

417 perf-only) and keeps the fetcher/downloader recovery path open. 

418 """ 

419 key = self._validation_memo_key( 

420 url, allow_private_ips, block_link_local 

421 ) 

422 if key is None: 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true

423 try: 

424 return bool(compute()) 

425 except Exception: 

426 return False 

427 with self._lock: 

428 if key in self._validation_memo: 

429 return True 

430 try: 

431 verdict = bool(compute()) 

432 except Exception: 

433 return False 

434 if not verdict: 

435 return False 

436 with self._lock: 

437 if key not in self._validation_memo: 437 ↛ 449line 437 didn't jump to line 449 because the condition on line 437 was always true

438 while ( 438 ↛ 442line 438 didn't jump to line 442 because the condition on line 438 was never true

439 len(self._validation_memo) 

440 >= self.MAX_VALIDATION_MEMO_ENTRIES 

441 ): 

442 try: 

443 self._validation_memo.pop( 

444 next(iter(self._validation_memo)) 

445 ) 

446 except StopIteration: 

447 break 

448 self._validation_memo[key] = True 

449 return True 

450 

451 def clear(self) -> None: 

452 with self._lock: 

453 self._entries.clear() 

454 self._inflight.clear() 

455 self._validation_memo.clear() 

456 self._total_bytes = 0 

457 self._hits = 0 

458 self._misses = 0 

459 self._puts = 0 

460 self._evictions = 0 

461 

462 def stats(self) -> dict[str, int]: 

463 with self._lock: 

464 return { 

465 "hits": self._hits, 

466 "misses": self._misses, 

467 "puts": self._puts, 

468 "entries": len(self._entries), 

469 "evictions": self._evictions, 

470 "total_bytes": self._total_bytes, 

471 "validation_memo": len(self._validation_memo), 

472 } 

473 

474 def __len__(self) -> int: 

475 with self._lock: 

476 return len(self._entries) 

477 

478 

479# -- research-scoped registry ------------------------------------------- 

480# Maps research_id -> ResearchContentCache. Owned by AdvancedSearchSystem 

481# (one cache per research run); deep funnels resolve it via the 

482# thread-local search context so per-call engines share it without 

483# signature changes across dozens of files. 

484 

485_registry_lock = threading.RLock() 

486_registry: dict[str, ResearchContentCache] = {} 

487 

488 

489def bind_cache_to_research( 

490 research_id: str | None, cache: ResearchContentCache | None 

491) -> None: 

492 """Bind *cache* to *research_id* (no-op for empty ids / None cache).""" 

493 if not research_id or cache is None: 

494 return 

495 with _registry_lock: 

496 _registry[research_id] = cache 

497 

498 

499def unbind_cache_from_research( 

500 research_id: str | None, *, cache: ResearchContentCache | None = None 

501) -> None: 

502 """Unbind a run, optionally only when *cache* still owns its binding.""" 

503 if not research_id: 503 ↛ 504line 503 didn't jump to line 504 because the condition on line 503 was never true

504 return 

505 with _registry_lock: 

506 if cache is None or _registry.get(research_id) is cache: 

507 _registry.pop(research_id, None) 

508 

509 

510def get_cache_for_research( 

511 research_id: str | None, 

512) -> ResearchContentCache | None: 

513 if not research_id: 

514 return None 

515 with _registry_lock: 

516 return _registry.get(research_id) 

517 

518 

519def get_current_cache() -> ResearchContentCache | None: 

520 """Resolve the active research's cache via the thread-local context.""" 

521 try: 

522 from .thread_context import get_search_context 

523 except Exception: # pragma: no cover - defensive 

524 return None 

525 try: 

526 ctx = get_search_context() 

527 except Exception: # pragma: no cover - defensive 

528 return None 

529 if not isinstance(ctx, dict): 

530 return None 

531 research_id = ctx.get("research_id") 

532 if not research_id or not isinstance(research_id, str): 

533 # Non-string ids (int legacy) are still valid scopes. 

534 if research_id is None or isinstance(research_id, bool): 534 ↛ 536line 534 didn't jump to line 536 because the condition on line 534 was always true

535 return None 

536 research_id = str(research_id) 

537 return get_cache_for_research(research_id) 

538 

539 

540def resolve_cache( 

541 explicit: ResearchContentCache | None, 

542) -> ResearchContentCache | None: 

543 """Prefer *explicit*, else the registry-bound cache for this thread.""" 

544 if explicit is not None: 

545 return explicit 

546 return get_current_cache() 

547 

548 

549__all__ = [ 

550 "ResearchContentCache", 

551 "bind_cache_to_research", 

552 "content_cache_key", 

553 "get_cache_for_research", 

554 "get_current_cache", 

555 "normalize_collection_scope", 

556 "resolve_cache", 

557 "unbind_cache_from_research", 

558]