agent_search_gateway.orchestrators.fetch
URL fetch workflow orchestration and URL-state mutation.
1"""URL fetch workflow orchestration and URL-state mutation.""" 2 3import logging 4import time 5from collections.abc import Callable 6 7from ..concurrency import PerKeyLockPool, SingleflightGroup 8from ..errors import UNAVAILABLE_MESSAGE, ErrorCode, ExecutionFailure, InputFailure 9from ..llm.stages import LLMStages 10from ..models import URLRecord 11from ..observability import elapsed_ms, log_event, target_url_for_log 12from ..scheduler.fetch import FetchScheduler 13from ..url_normalization import NormalizedURL, normalize_url 14from ..url_store import URLStore 15 16 17class FetchOrchestrator: 18 def __init__( 19 self, 20 *, 21 store: URLStore, 22 scheduler: FetchScheduler, 23 stages: LLMStages, 24 logger: logging.Logger | None = None, 25 monotonic: Callable[[], float] = time.monotonic, 26 ) -> None: 27 self._store = store 28 self._scheduler = scheduler 29 self._stages = stages 30 self._logger = logger or logging.getLogger(__name__) 31 self._monotonic = monotonic 32 self._url_locks: PerKeyLockPool[NormalizedURL] = PerKeyLockPool() 33 self._request_singleflight: SingleflightGroup[tuple[NormalizedURL, str | None], str] = ( 34 SingleflightGroup() 35 ) 36 37 async def url_fetch(self, url: str, focus: str | None = None) -> str: 38 normalized_url = normalize_url(url) 39 normalized_focus = focus.strip() if focus is not None and focus.strip() else None 40 key = (normalized_url, normalized_focus) 41 focus_present = normalized_focus is not None 42 focus_chars = len(normalized_focus or "") 43 return await self._request_singleflight.do( 44 key, 45 lambda: self._serialized_url_fetch(normalized_url, normalized_focus), 46 on_leader=lambda: log_event( 47 self._logger, 48 logging.DEBUG, 49 "singleflight_leader", 50 url=target_url_for_log(str(normalized_url)), 51 focus_present=focus_present, 52 focus_chars=focus_chars, 53 ), 54 on_follower=lambda: log_event( 55 self._logger, 56 logging.DEBUG, 57 "singleflight_joined", 58 url=target_url_for_log(str(normalized_url)), 59 focus_present=focus_present, 60 focus_chars=focus_chars, 61 ), 62 ) 63 64 async def _serialized_url_fetch( 65 self, 66 normalized_url: NormalizedURL, 67 normalized_focus: str | None, 68 ) -> str: 69 lock_started = self._monotonic() 70 async with self._url_locks.acquire(normalized_url): 71 log_event( 72 self._logger, 73 logging.DEBUG, 74 "url_lock_acquired", 75 url=target_url_for_log(str(normalized_url)), 76 wait_ms=elapsed_ms(self._monotonic, lock_started), 77 ) 78 snapshot = self._store.get(normalized_url) 79 if snapshot is None: 80 self._log_state(normalized_url, "url_not_admitted") 81 raise InputFailure(ErrorCode.URL_NOT_ADMITTED, "URL was not admitted by search") 82 if not snapshot.available: 83 self._log_state(normalized_url, "stored_unavailable") 84 return UNAVAILABLE_MESSAGE 85 86 prepared = await self._prepare_content(normalized_url) 87 if not prepared: 88 return UNAVAILABLE_MESSAGE 89 90 refreshed = self._require_snapshot(normalized_url) 91 if not await self._safety_check(normalized_url, refreshed.content): 92 return UNAVAILABLE_MESSAGE 93 if normalized_focus is None: 94 self._log_final(normalized_url, refreshed.content, "content") 95 return refreshed.content 96 summary = await self._focus_summary(refreshed.content, normalized_focus) 97 self._log_final(normalized_url, summary, "focus_summary") 98 return summary 99 100 async def _prepare_content(self, url: NormalizedURL) -> bool: 101 snapshot = self._require_snapshot(url) 102 if snapshot.content: 103 self._log_state(url, "cached_content") 104 return True 105 106 if not snapshot.raw_content: 107 self._log_state(url, "provider_fetch_required") 108 if not self._scheduler.provider_names: 109 raise ExecutionFailure( 110 ErrorCode.NO_URL_FETCH_PROVIDERS, 111 "No URL fetch providers are enabled", 112 ) 113 outcome = await self._scheduler.fetch_until_accepted(url) 114 if outcome.kind == "execution_failure": 115 raise ExecutionFailure( 116 ErrorCode.ALL_PROVIDERS_FAILED, 117 "All URL fetch provider pipelines failed", 118 ) 119 if outcome.kind == "semantic_failure": 120 self._store.mark_unavailable(url) 121 self._log_rejected(url, "fetch_semantic_failure") 122 return False 123 candidate = outcome.candidate 124 if candidate is None: 125 raise RuntimeError("accepted fetch outcome did not contain a candidate") 126 self._store.merge_body( 127 url, 128 raw_content=candidate.raw_content, 129 content=candidate.content, 130 ) 131 log_event( 132 self._logger, 133 logging.DEBUG, 134 "body_accepted", 135 url=target_url_for_log(str(url)), 136 reason="provider_fetch", 137 raw_chars=len(candidate.raw_content), 138 content_chars=len(candidate.content), 139 ) 140 snapshot = self._require_snapshot(url) 141 else: 142 self._log_state(url, "raw_content_available") 143 144 if not snapshot.content: 145 cleaned = await self._content_clean(snapshot.raw_content) 146 self._store.merge_body(url, content=cleaned) 147 log_event( 148 self._logger, 149 logging.DEBUG, 150 "body_accepted", 151 url=target_url_for_log(str(url)), 152 reason="content_cleaned", 153 content_chars=len(cleaned), 154 ) 155 return True 156 157 async def _content_clean(self, raw_content: str) -> str: 158 try: 159 return await self._stages.content_clean(raw_content) 160 except ExecutionFailure as exc: 161 raise ExecutionFailure( 162 ErrorCode.LLM_STAGE_FAILED, 163 "Content-clean LLM stage failed", 164 ) from exc 165 166 async def _safety_check(self, url: NormalizedURL, content: str) -> bool: 167 try: 168 decision = await self._stages.safety(content) 169 except ExecutionFailure as exc: 170 raise ExecutionFailure( 171 ErrorCode.LLM_STAGE_FAILED, 172 "Safety LLM stage failed", 173 ) from exc 174 if decision.ok: 175 return True 176 self._store.mark_unavailable(url) 177 self._log_rejected(url, "safety_rejected") 178 return False 179 180 async def _focus_summary(self, content: str, focus: str) -> str: 181 try: 182 return await self._stages.focus_summary(content, focus) 183 except ExecutionFailure as exc: 184 raise ExecutionFailure( 185 ErrorCode.LLM_STAGE_FAILED, 186 "Focus-summary LLM stage failed", 187 ) from exc 188 189 def _log_state(self, url: NormalizedURL, reason: str) -> None: 190 log_event( 191 self._logger, 192 logging.DEBUG, 193 "body_skipped", 194 url=target_url_for_log(str(url)), 195 reason=reason, 196 ) 197 198 def _log_rejected(self, url: NormalizedURL, reason: str) -> None: 199 log_event( 200 self._logger, 201 logging.DEBUG, 202 "body_rejected", 203 url=target_url_for_log(str(url)), 204 reason=reason, 205 ) 206 207 def _log_final(self, url: NormalizedURL, text: str, reason: str) -> None: 208 log_event( 209 self._logger, 210 logging.DEBUG, 211 "body_accepted", 212 url=target_url_for_log(str(url)), 213 reason=reason, 214 output_chars=len(text), 215 ) 216 217 def _require_snapshot(self, url: NormalizedURL) -> URLRecord: 218 snapshot = self._store.get(url) 219 if snapshot is None: 220 raise RuntimeError("admitted URL disappeared from store") 221 return snapshot
class
FetchOrchestrator:
18class FetchOrchestrator: 19 def __init__( 20 self, 21 *, 22 store: URLStore, 23 scheduler: FetchScheduler, 24 stages: LLMStages, 25 logger: logging.Logger | None = None, 26 monotonic: Callable[[], float] = time.monotonic, 27 ) -> None: 28 self._store = store 29 self._scheduler = scheduler 30 self._stages = stages 31 self._logger = logger or logging.getLogger(__name__) 32 self._monotonic = monotonic 33 self._url_locks: PerKeyLockPool[NormalizedURL] = PerKeyLockPool() 34 self._request_singleflight: SingleflightGroup[tuple[NormalizedURL, str | None], str] = ( 35 SingleflightGroup() 36 ) 37 38 async def url_fetch(self, url: str, focus: str | None = None) -> str: 39 normalized_url = normalize_url(url) 40 normalized_focus = focus.strip() if focus is not None and focus.strip() else None 41 key = (normalized_url, normalized_focus) 42 focus_present = normalized_focus is not None 43 focus_chars = len(normalized_focus or "") 44 return await self._request_singleflight.do( 45 key, 46 lambda: self._serialized_url_fetch(normalized_url, normalized_focus), 47 on_leader=lambda: log_event( 48 self._logger, 49 logging.DEBUG, 50 "singleflight_leader", 51 url=target_url_for_log(str(normalized_url)), 52 focus_present=focus_present, 53 focus_chars=focus_chars, 54 ), 55 on_follower=lambda: log_event( 56 self._logger, 57 logging.DEBUG, 58 "singleflight_joined", 59 url=target_url_for_log(str(normalized_url)), 60 focus_present=focus_present, 61 focus_chars=focus_chars, 62 ), 63 ) 64 65 async def _serialized_url_fetch( 66 self, 67 normalized_url: NormalizedURL, 68 normalized_focus: str | None, 69 ) -> str: 70 lock_started = self._monotonic() 71 async with self._url_locks.acquire(normalized_url): 72 log_event( 73 self._logger, 74 logging.DEBUG, 75 "url_lock_acquired", 76 url=target_url_for_log(str(normalized_url)), 77 wait_ms=elapsed_ms(self._monotonic, lock_started), 78 ) 79 snapshot = self._store.get(normalized_url) 80 if snapshot is None: 81 self._log_state(normalized_url, "url_not_admitted") 82 raise InputFailure(ErrorCode.URL_NOT_ADMITTED, "URL was not admitted by search") 83 if not snapshot.available: 84 self._log_state(normalized_url, "stored_unavailable") 85 return UNAVAILABLE_MESSAGE 86 87 prepared = await self._prepare_content(normalized_url) 88 if not prepared: 89 return UNAVAILABLE_MESSAGE 90 91 refreshed = self._require_snapshot(normalized_url) 92 if not await self._safety_check(normalized_url, refreshed.content): 93 return UNAVAILABLE_MESSAGE 94 if normalized_focus is None: 95 self._log_final(normalized_url, refreshed.content, "content") 96 return refreshed.content 97 summary = await self._focus_summary(refreshed.content, normalized_focus) 98 self._log_final(normalized_url, summary, "focus_summary") 99 return summary 100 101 async def _prepare_content(self, url: NormalizedURL) -> bool: 102 snapshot = self._require_snapshot(url) 103 if snapshot.content: 104 self._log_state(url, "cached_content") 105 return True 106 107 if not snapshot.raw_content: 108 self._log_state(url, "provider_fetch_required") 109 if not self._scheduler.provider_names: 110 raise ExecutionFailure( 111 ErrorCode.NO_URL_FETCH_PROVIDERS, 112 "No URL fetch providers are enabled", 113 ) 114 outcome = await self._scheduler.fetch_until_accepted(url) 115 if outcome.kind == "execution_failure": 116 raise ExecutionFailure( 117 ErrorCode.ALL_PROVIDERS_FAILED, 118 "All URL fetch provider pipelines failed", 119 ) 120 if outcome.kind == "semantic_failure": 121 self._store.mark_unavailable(url) 122 self._log_rejected(url, "fetch_semantic_failure") 123 return False 124 candidate = outcome.candidate 125 if candidate is None: 126 raise RuntimeError("accepted fetch outcome did not contain a candidate") 127 self._store.merge_body( 128 url, 129 raw_content=candidate.raw_content, 130 content=candidate.content, 131 ) 132 log_event( 133 self._logger, 134 logging.DEBUG, 135 "body_accepted", 136 url=target_url_for_log(str(url)), 137 reason="provider_fetch", 138 raw_chars=len(candidate.raw_content), 139 content_chars=len(candidate.content), 140 ) 141 snapshot = self._require_snapshot(url) 142 else: 143 self._log_state(url, "raw_content_available") 144 145 if not snapshot.content: 146 cleaned = await self._content_clean(snapshot.raw_content) 147 self._store.merge_body(url, content=cleaned) 148 log_event( 149 self._logger, 150 logging.DEBUG, 151 "body_accepted", 152 url=target_url_for_log(str(url)), 153 reason="content_cleaned", 154 content_chars=len(cleaned), 155 ) 156 return True 157 158 async def _content_clean(self, raw_content: str) -> str: 159 try: 160 return await self._stages.content_clean(raw_content) 161 except ExecutionFailure as exc: 162 raise ExecutionFailure( 163 ErrorCode.LLM_STAGE_FAILED, 164 "Content-clean LLM stage failed", 165 ) from exc 166 167 async def _safety_check(self, url: NormalizedURL, content: str) -> bool: 168 try: 169 decision = await self._stages.safety(content) 170 except ExecutionFailure as exc: 171 raise ExecutionFailure( 172 ErrorCode.LLM_STAGE_FAILED, 173 "Safety LLM stage failed", 174 ) from exc 175 if decision.ok: 176 return True 177 self._store.mark_unavailable(url) 178 self._log_rejected(url, "safety_rejected") 179 return False 180 181 async def _focus_summary(self, content: str, focus: str) -> str: 182 try: 183 return await self._stages.focus_summary(content, focus) 184 except ExecutionFailure as exc: 185 raise ExecutionFailure( 186 ErrorCode.LLM_STAGE_FAILED, 187 "Focus-summary LLM stage failed", 188 ) from exc 189 190 def _log_state(self, url: NormalizedURL, reason: str) -> None: 191 log_event( 192 self._logger, 193 logging.DEBUG, 194 "body_skipped", 195 url=target_url_for_log(str(url)), 196 reason=reason, 197 ) 198 199 def _log_rejected(self, url: NormalizedURL, reason: str) -> None: 200 log_event( 201 self._logger, 202 logging.DEBUG, 203 "body_rejected", 204 url=target_url_for_log(str(url)), 205 reason=reason, 206 ) 207 208 def _log_final(self, url: NormalizedURL, text: str, reason: str) -> None: 209 log_event( 210 self._logger, 211 logging.DEBUG, 212 "body_accepted", 213 url=target_url_for_log(str(url)), 214 reason=reason, 215 output_chars=len(text), 216 ) 217 218 def _require_snapshot(self, url: NormalizedURL) -> URLRecord: 219 snapshot = self._store.get(url) 220 if snapshot is None: 221 raise RuntimeError("admitted URL disappeared from store") 222 return snapshot
FetchOrchestrator( *, store: agent_search_gateway.url_store.URLStore, scheduler: agent_search_gateway.scheduler.fetch.FetchScheduler, stages: agent_search_gateway.llm.stages.LLMStages, logger: logging.Logger | None = None, monotonic: Callable[[], float] = <built-in function monotonic>)
19 def __init__( 20 self, 21 *, 22 store: URLStore, 23 scheduler: FetchScheduler, 24 stages: LLMStages, 25 logger: logging.Logger | None = None, 26 monotonic: Callable[[], float] = time.monotonic, 27 ) -> None: 28 self._store = store 29 self._scheduler = scheduler 30 self._stages = stages 31 self._logger = logger or logging.getLogger(__name__) 32 self._monotonic = monotonic 33 self._url_locks: PerKeyLockPool[NormalizedURL] = PerKeyLockPool() 34 self._request_singleflight: SingleflightGroup[tuple[NormalizedURL, str | None], str] = ( 35 SingleflightGroup() 36 )
async def
url_fetch(self, url: str, focus: str | None = None) -> str:
38 async def url_fetch(self, url: str, focus: str | None = None) -> str: 39 normalized_url = normalize_url(url) 40 normalized_focus = focus.strip() if focus is not None and focus.strip() else None 41 key = (normalized_url, normalized_focus) 42 focus_present = normalized_focus is not None 43 focus_chars = len(normalized_focus or "") 44 return await self._request_singleflight.do( 45 key, 46 lambda: self._serialized_url_fetch(normalized_url, normalized_focus), 47 on_leader=lambda: log_event( 48 self._logger, 49 logging.DEBUG, 50 "singleflight_leader", 51 url=target_url_for_log(str(normalized_url)), 52 focus_present=focus_present, 53 focus_chars=focus_chars, 54 ), 55 on_follower=lambda: log_event( 56 self._logger, 57 logging.DEBUG, 58 "singleflight_joined", 59 url=target_url_for_log(str(normalized_url)), 60 focus_present=focus_present, 61 focus_chars=focus_chars, 62 ), 63 )