agent_search_gateway.orchestrators.search
Keyword and LLM search workflow orchestration.
1"""Keyword and LLM search workflow orchestration.""" 2 3import asyncio 4import logging 5import time 6from collections.abc import Callable, Sequence 7from dataclasses import dataclass 8 9from ..academic.aggregator import PaperAggregator 10from ..concurrency import ProviderQuotaManager 11from ..errors import ErrorCode, ExecutionFailure, InputFailure 12from ..llm.stages import LLMStages, cheap_check 13from ..llm_search_parser import parse_search_markdown 14from ..models import LLMInvocation, LLMSearchScope, PaperRecord, SearchRecord 15from ..observability import elapsed_ms, log_event, target_url_for_log 16from ..paper_search_parser import parse_paper_markdown 17from ..providers.contracts import ( 18 KeywordSearchHit, 19 KeywordSearchProvider, 20 OAResolver, 21 PaperSearchHit, 22) 23from ..request_ids import validate_request_id 24from ..result_writer import ResultWriter 25from ..url_normalization import NormalizedURL, normalize_url 26from ..url_store import URLStore 27from .paper import finalize_paper_hits 28 29 30@dataclass(frozen=True, slots=True) 31class _StagedKeyword: 32 url: NormalizedURL 33 abstract: str 34 provider: str 35 raw_content: str = "" 36 content: str = "" 37 38 39class SearchOrchestrator: 40 def __init__( 41 self, 42 *, 43 keyword_providers: Sequence[KeywordSearchProvider], 44 llm_invocations: Sequence[LLMInvocation], 45 quotas: ProviderQuotaManager, 46 stages: LLMStages, 47 store: URLStore, 48 result_writer: ResultWriter, 49 paper_aggregator: PaperAggregator | None = None, 50 paper_resolver: OAResolver | None = None, 51 logger: logging.Logger | None = None, 52 monotonic: Callable[[], float] = time.monotonic, 53 ) -> None: 54 self._keyword_providers = tuple(keyword_providers) 55 self._llm_invocations = tuple(llm_invocations) 56 self.quotas = quotas 57 self._stages = stages 58 self._store = store 59 self._result_writer = result_writer 60 self._paper_aggregator = paper_aggregator or PaperAggregator( 61 tuple(f"llm:{invocation.provider}" for invocation in self._llm_invocations) 62 ) 63 self._paper_resolver = paper_resolver 64 self._logger = logger or logging.getLogger(__name__) 65 self._monotonic = monotonic 66 67 async def keyword_search(self, query: str, *, request_id: str) -> str: 68 validate_request_id(request_id) 69 normalized_query = query.strip() 70 if not normalized_query: 71 raise InputFailure(ErrorCode.EMPTY_QUERY, "Query must not be empty") 72 if not self._keyword_providers: 73 raise ExecutionFailure( 74 ErrorCode.NO_KEYWORD_SEARCH_PROVIDERS, 75 "No keyword search providers are enabled", 76 ) 77 78 outcomes = await asyncio.gather( 79 *( 80 self._run_keyword_pipeline(provider, normalized_query) 81 for provider in self._keyword_providers 82 ), 83 return_exceptions=True, 84 ) 85 completed = [outcome for outcome in outcomes if isinstance(outcome, list)] 86 if not completed: 87 raise ExecutionFailure( 88 ErrorCode.ALL_PROVIDERS_FAILED, 89 "All keyword search provider pipelines failed", 90 ) 91 92 ordered_urls: list[NormalizedURL] = [] 93 seen: set[NormalizedURL] = set() 94 for outcome in outcomes: 95 if not isinstance(outcome, list): 96 continue 97 for staged in outcome: 98 self._store.admit( 99 staged.url, 100 staged.abstract, 101 raw_content=staged.raw_content, 102 content=staged.content, 103 ) 104 if staged.url not in seen: 105 seen.add(staged.url) 106 ordered_urls.append(staged.url) 107 else: 108 log_event( 109 self._logger, 110 logging.DEBUG, 111 "candidate_rejected", 112 provider=staged.provider, 113 url=target_url_for_log(str(staged.url)), 114 reason="duplicate", 115 ) 116 117 records = [self._record_from_store(url) for url in ordered_urls] 118 path = self._result_writer.write_results("keyword", records, request_id=request_id) 119 log_event( 120 self._logger, 121 logging.DEBUG, 122 "results_written", 123 kind="keyword", 124 path=str(path), 125 results=len(records), 126 ) 127 return str(path) 128 129 async def llm_search( 130 self, 131 prompt: str, 132 *, 133 request_id: str, 134 scope: LLMSearchScope = "web", 135 ) -> str: 136 validate_request_id(request_id) 137 normalized_prompt = prompt.strip() 138 if not normalized_prompt: 139 raise InputFailure(ErrorCode.EMPTY_QUERY, "Prompt must not be empty") 140 if scope not in {"web", "paper", "all"}: 141 raise InputFailure(ErrorCode.BAD_REQUEST, "LLM search scope is invalid") 142 if not self._llm_invocations: 143 raise ExecutionFailure( 144 ErrorCode.NO_LLM_SEARCH_PROVIDERS, 145 "No LLM search providers are configured", 146 ) 147 148 if scope == "web": 149 web_records = await self._llm_web_records(normalized_prompt) 150 path = self._result_writer.write_results("llm", web_records, request_id=request_id) 151 self._log_results_written(path=str(path), results=len(web_records)) 152 return str(path) 153 154 if scope == "paper": 155 paper_records = await self._llm_paper_records(normalized_prompt) 156 path = self._result_writer.write_paper_results( 157 "llm", 158 paper_records, 159 request_id=request_id, 160 ) 161 self._log_results_written(path=str(path), results=len(paper_records)) 162 return str(path) 163 164 web_outcome, paper_outcome = await asyncio.gather( 165 self._llm_web_records(normalized_prompt), 166 self._llm_paper_records(normalized_prompt), 167 return_exceptions=True, 168 ) 169 if isinstance(web_outcome, BaseException): 170 self._log_branch_failure("web", web_outcome) 171 mixed_web_records: list[SearchRecord] = [] 172 else: 173 mixed_web_records = web_outcome 174 if isinstance(paper_outcome, BaseException): 175 self._log_branch_failure("paper", paper_outcome) 176 mixed_paper_records: list[PaperRecord] = [] 177 else: 178 mixed_paper_records = paper_outcome 179 if isinstance(web_outcome, BaseException) and isinstance( 180 paper_outcome, 181 BaseException, 182 ): 183 raise ExecutionFailure( 184 ErrorCode.ALL_PROVIDERS_FAILED, 185 "All LLM search branches failed", 186 ) 187 path = self._result_writer.write_mixed_results( 188 mixed_web_records, 189 mixed_paper_records, 190 request_id=request_id, 191 ) 192 self._log_results_written( 193 path=str(path), 194 results=len(mixed_web_records) + len(mixed_paper_records), 195 ) 196 return str(path) 197 198 async def _llm_web_records(self, prompt: str) -> list[SearchRecord]: 199 outcomes = await asyncio.gather( 200 *(self._run_llm_pipeline(invocation, prompt) for invocation in self._llm_invocations), 201 return_exceptions=True, 202 ) 203 if not any(isinstance(outcome, list) for outcome in outcomes): 204 raise ExecutionFailure( 205 ErrorCode.ALL_PROVIDERS_FAILED, 206 "All LLM web search provider pipelines failed", 207 ) 208 ordered_urls: list[NormalizedURL] = [] 209 seen: set[NormalizedURL] = set() 210 for outcome in outcomes: 211 if not isinstance(outcome, list): 212 continue 213 for result in outcome: 214 self._store.admit(result.url, result.abstract) 215 if result.url not in seen: 216 seen.add(result.url) 217 ordered_urls.append(result.url) 218 return [self._record_from_store(url) for url in ordered_urls] 219 220 async def _llm_paper_records(self, prompt: str) -> list[PaperRecord]: 221 outcomes = await asyncio.gather( 222 *( 223 self._run_llm_paper_pipeline(invocation, prompt) 224 for invocation in self._llm_invocations 225 ), 226 return_exceptions=True, 227 ) 228 if not any(isinstance(outcome, list) for outcome in outcomes): 229 raise ExecutionFailure( 230 ErrorCode.ALL_PROVIDERS_FAILED, 231 "All LLM paper search provider pipelines failed", 232 ) 233 hits: list[PaperSearchHit] = [] 234 for outcome in outcomes: 235 if isinstance(outcome, list): 236 hits.extend(outcome) 237 return await finalize_paper_hits( 238 hits, 239 aggregator=self._paper_aggregator, 240 resolver=self._paper_resolver, 241 store=self._store, 242 ) 243 244 def _log_results_written(self, *, path: str, results: int) -> None: 245 log_event( 246 self._logger, 247 logging.DEBUG, 248 "results_written", 249 kind="llm", 250 path=path, 251 results=results, 252 ) 253 254 def _log_branch_failure(self, scope: str, exc: BaseException) -> None: 255 log_event( 256 self._logger, 257 logging.DEBUG, 258 "llm_search_branch_failed", 259 scope=scope, 260 error_type=type(exc).__name__, 261 ) 262 263 async def _run_llm_pipeline( 264 self, 265 invocation: LLMInvocation, 266 prompt: str, 267 ) -> list[SearchRecord]: 268 started = self._monotonic() 269 log_event( 270 self._logger, 271 logging.DEBUG, 272 "provider_started", 273 provider=invocation.provider, 274 stage="llm_search", 275 model=invocation.model, 276 ) 277 try: 278 markdown = await self._stages.llm_search_markdown(invocation, prompt) 279 records = parse_search_markdown(markdown) 280 except asyncio.CancelledError: 281 raise 282 except ExecutionFailure as exc: 283 self._log_provider_failure(invocation.provider, "llm_search", started, exc) 284 raise 285 except Exception as exc: 286 self._log_provider_failure(invocation.provider, "llm_search", started, exc) 287 raise ExecutionFailure( 288 ErrorCode.ALL_PROVIDERS_FAILED, 289 f"LLM search provider {invocation.provider} returned invalid data", 290 ) from exc 291 log_event( 292 self._logger, 293 logging.DEBUG, 294 "provider_completed", 295 provider=invocation.provider, 296 stage="llm_search", 297 model=invocation.model, 298 output_chars=len(markdown), 299 results=len(records), 300 elapsed_ms=elapsed_ms(self._monotonic, started), 301 ) 302 return records 303 304 async def _run_llm_paper_pipeline( 305 self, 306 invocation: LLMInvocation, 307 prompt: str, 308 ) -> list[PaperSearchHit]: 309 started = self._monotonic() 310 log_event( 311 self._logger, 312 logging.DEBUG, 313 "provider_started", 314 provider=invocation.provider, 315 stage="llm_paper_search", 316 model=invocation.model, 317 ) 318 try: 319 markdown = await self._stages.llm_paper_search_markdown(invocation, prompt) 320 hits = parse_paper_markdown(markdown, provider=invocation.provider) 321 except asyncio.CancelledError: 322 raise 323 except ExecutionFailure as exc: 324 self._log_provider_failure(invocation.provider, "llm_paper_search", started, exc) 325 raise 326 except Exception as exc: 327 self._log_provider_failure(invocation.provider, "llm_paper_search", started, exc) 328 raise ExecutionFailure( 329 ErrorCode.ALL_PROVIDERS_FAILED, 330 f"LLM paper search provider {invocation.provider} returned invalid data", 331 ) from exc 332 log_event( 333 self._logger, 334 logging.DEBUG, 335 "provider_completed", 336 provider=invocation.provider, 337 stage="llm_paper_search", 338 model=invocation.model, 339 output_chars=len(markdown), 340 results=len(hits), 341 elapsed_ms=elapsed_ms(self._monotonic, started), 342 ) 343 return hits 344 345 async def _run_keyword_pipeline( 346 self, 347 provider: KeywordSearchProvider, 348 query: str, 349 ) -> list[_StagedKeyword]: 350 started = self._monotonic() 351 log_event( 352 self._logger, 353 logging.DEBUG, 354 "provider_started", 355 provider=provider.name, 356 stage="search", 357 ) 358 try: 359 async with self.quotas.get_web(provider.name).lease(): 360 hits = await provider.search(query) 361 if not isinstance(hits, list): 362 raise TypeError("provider search result must be a list") 363 except asyncio.CancelledError: 364 raise 365 except ExecutionFailure as exc: 366 self._log_provider_failure(provider.name, "search", started, exc) 367 raise 368 except Exception as exc: 369 self._log_provider_failure(provider.name, "search", started, exc) 370 raise ExecutionFailure( 371 ErrorCode.ALL_PROVIDERS_FAILED, 372 f"Keyword provider {provider.name} returned invalid data", 373 ) from exc 374 375 staged: list[_StagedKeyword] = [] 376 hit_failure: ExecutionFailure | None = None 377 failed_hits = 0 378 try: 379 for hit in hits: 380 try: 381 staged_hit = await self._stage_keyword_hit(hit, provider=provider.name) 382 except ExecutionFailure as exc: 383 # Keyword hits are independent; one failed judge must not discard sibling hits. 384 if hit_failure is None: 385 hit_failure = exc 386 failed_hits += 1 387 continue 388 if staged_hit is not None: 389 staged.append(staged_hit) 390 except asyncio.CancelledError: 391 raise 392 except Exception as exc: 393 self._log_provider_failure(provider.name, "hit_staging", started, exc) 394 raise ExecutionFailure( 395 ErrorCode.ALL_PROVIDERS_FAILED, 396 f"Keyword provider {provider.name} returned invalid data", 397 ) from exc 398 399 if hit_failure is not None: 400 if not staged: 401 self._log_provider_failure(provider.name, "judge", started, hit_failure) 402 raise hit_failure 403 self._log_provider_partial_failure( 404 provider.name, 405 "judge", 406 started, 407 hit_failure, 408 failed_hits=failed_hits, 409 ) 410 log_event( 411 self._logger, 412 logging.DEBUG, 413 "provider_completed", 414 provider=provider.name, 415 stage="search", 416 hits=len(hits), 417 results=len(staged), 418 elapsed_ms=elapsed_ms(self._monotonic, started), 419 ) 420 return staged 421 422 async def _stage_keyword_hit( 423 self, 424 hit: KeywordSearchHit, 425 *, 426 provider: str, 427 ) -> _StagedKeyword | None: 428 if not isinstance(hit, KeywordSearchHit): 429 raise TypeError("keyword hit has invalid type") 430 for value in (hit.url, hit.title, hit.snippet, hit.raw_content, hit.content): 431 if not isinstance(value, str): 432 raise TypeError("keyword hit fields must be strings") 433 434 abstract = hit.snippet.strip() or hit.title.strip() 435 if not abstract: 436 try: 437 logged_url = target_url_for_log(str(normalize_url(hit.url))) 438 except InputFailure: 439 logged_url = target_url_for_log(hit.url) 440 log_event( 441 self._logger, 442 logging.DEBUG, 443 "candidate_rejected", 444 provider=provider, 445 url=logged_url, 446 reason="empty_abstract", 447 ) 448 return None 449 url = normalize_url(hit.url) 450 log_event( 451 self._logger, 452 logging.DEBUG, 453 "candidate_accepted", 454 provider=provider, 455 url=target_url_for_log(str(url)), 456 abstract_chars=len(abstract), 457 ) 458 current = self._store.get(url) 459 if current is not None and not current.available: 460 self._log_body_decision(provider, url, "body_skipped", "stored_unavailable") 461 return _StagedKeyword(url=url, abstract=abstract, provider=provider) 462 463 had_body = bool(hit.raw_content or hit.content) 464 raw_content = hit.raw_content if hit.raw_content.strip() else "" 465 content = hit.content if hit.content.strip() else "" 466 candidate = content or raw_content 467 if not candidate: 468 reason = "cheap_check" if had_body else "no_body" 469 event = "body_rejected" if had_body else "body_skipped" 470 self._log_body_decision(provider, url, event, reason) 471 return _StagedKeyword(url=url, abstract=abstract, provider=provider) 472 if not cheap_check(candidate): 473 self._log_body_decision(provider, url, "body_rejected", "cheap_check") 474 return _StagedKeyword(url=url, abstract=abstract, provider=provider) 475 476 decision = await self._stages.judge(candidate) 477 if not decision.ok: 478 self._log_body_decision(provider, url, "body_rejected", "judge_rejected") 479 return _StagedKeyword(url=url, abstract=abstract, provider=provider) 480 log_event( 481 self._logger, 482 logging.DEBUG, 483 "body_accepted", 484 provider=provider, 485 url=target_url_for_log(str(url)), 486 raw_chars=len(raw_content), 487 content_chars=len(content), 488 ) 489 return _StagedKeyword( 490 url=url, 491 abstract=abstract, 492 provider=provider, 493 raw_content=raw_content, 494 content=content, 495 ) 496 497 def _log_body_decision( 498 self, 499 provider: str, 500 url: NormalizedURL, 501 event: str, 502 reason: str, 503 ) -> None: 504 log_event( 505 self._logger, 506 logging.DEBUG, 507 event, 508 provider=provider, 509 url=target_url_for_log(str(url)), 510 reason=reason, 511 ) 512 513 def _log_provider_failure( 514 self, 515 provider: str, 516 stage: str, 517 started: float, 518 exc: Exception, 519 ) -> None: 520 log_event( 521 self._logger, 522 logging.DEBUG, 523 "provider_failed", 524 provider=provider, 525 stage=stage, 526 error_type=type(exc).__name__, 527 elapsed_ms=elapsed_ms(self._monotonic, started), 528 ) 529 530 def _log_provider_partial_failure( 531 self, 532 provider: str, 533 stage: str, 534 started: float, 535 exc: Exception, 536 *, 537 failed_hits: int, 538 ) -> None: 539 log_event( 540 self._logger, 541 logging.DEBUG, 542 "provider_partial_failure", 543 provider=provider, 544 stage=stage, 545 error_type=type(exc).__name__, 546 failed_hits=failed_hits, 547 elapsed_ms=elapsed_ms(self._monotonic, started), 548 ) 549 550 def _record_from_store(self, url: NormalizedURL) -> SearchRecord: 551 record = self._store.get(url) 552 if record is None: 553 raise RuntimeError("committed URL disappeared from store") 554 return SearchRecord(record.url, record.abstract)
class
SearchOrchestrator:
40class SearchOrchestrator: 41 def __init__( 42 self, 43 *, 44 keyword_providers: Sequence[KeywordSearchProvider], 45 llm_invocations: Sequence[LLMInvocation], 46 quotas: ProviderQuotaManager, 47 stages: LLMStages, 48 store: URLStore, 49 result_writer: ResultWriter, 50 paper_aggregator: PaperAggregator | None = None, 51 paper_resolver: OAResolver | None = None, 52 logger: logging.Logger | None = None, 53 monotonic: Callable[[], float] = time.monotonic, 54 ) -> None: 55 self._keyword_providers = tuple(keyword_providers) 56 self._llm_invocations = tuple(llm_invocations) 57 self.quotas = quotas 58 self._stages = stages 59 self._store = store 60 self._result_writer = result_writer 61 self._paper_aggregator = paper_aggregator or PaperAggregator( 62 tuple(f"llm:{invocation.provider}" for invocation in self._llm_invocations) 63 ) 64 self._paper_resolver = paper_resolver 65 self._logger = logger or logging.getLogger(__name__) 66 self._monotonic = monotonic 67 68 async def keyword_search(self, query: str, *, request_id: str) -> str: 69 validate_request_id(request_id) 70 normalized_query = query.strip() 71 if not normalized_query: 72 raise InputFailure(ErrorCode.EMPTY_QUERY, "Query must not be empty") 73 if not self._keyword_providers: 74 raise ExecutionFailure( 75 ErrorCode.NO_KEYWORD_SEARCH_PROVIDERS, 76 "No keyword search providers are enabled", 77 ) 78 79 outcomes = await asyncio.gather( 80 *( 81 self._run_keyword_pipeline(provider, normalized_query) 82 for provider in self._keyword_providers 83 ), 84 return_exceptions=True, 85 ) 86 completed = [outcome for outcome in outcomes if isinstance(outcome, list)] 87 if not completed: 88 raise ExecutionFailure( 89 ErrorCode.ALL_PROVIDERS_FAILED, 90 "All keyword search provider pipelines failed", 91 ) 92 93 ordered_urls: list[NormalizedURL] = [] 94 seen: set[NormalizedURL] = set() 95 for outcome in outcomes: 96 if not isinstance(outcome, list): 97 continue 98 for staged in outcome: 99 self._store.admit( 100 staged.url, 101 staged.abstract, 102 raw_content=staged.raw_content, 103 content=staged.content, 104 ) 105 if staged.url not in seen: 106 seen.add(staged.url) 107 ordered_urls.append(staged.url) 108 else: 109 log_event( 110 self._logger, 111 logging.DEBUG, 112 "candidate_rejected", 113 provider=staged.provider, 114 url=target_url_for_log(str(staged.url)), 115 reason="duplicate", 116 ) 117 118 records = [self._record_from_store(url) for url in ordered_urls] 119 path = self._result_writer.write_results("keyword", records, request_id=request_id) 120 log_event( 121 self._logger, 122 logging.DEBUG, 123 "results_written", 124 kind="keyword", 125 path=str(path), 126 results=len(records), 127 ) 128 return str(path) 129 130 async def llm_search( 131 self, 132 prompt: str, 133 *, 134 request_id: str, 135 scope: LLMSearchScope = "web", 136 ) -> str: 137 validate_request_id(request_id) 138 normalized_prompt = prompt.strip() 139 if not normalized_prompt: 140 raise InputFailure(ErrorCode.EMPTY_QUERY, "Prompt must not be empty") 141 if scope not in {"web", "paper", "all"}: 142 raise InputFailure(ErrorCode.BAD_REQUEST, "LLM search scope is invalid") 143 if not self._llm_invocations: 144 raise ExecutionFailure( 145 ErrorCode.NO_LLM_SEARCH_PROVIDERS, 146 "No LLM search providers are configured", 147 ) 148 149 if scope == "web": 150 web_records = await self._llm_web_records(normalized_prompt) 151 path = self._result_writer.write_results("llm", web_records, request_id=request_id) 152 self._log_results_written(path=str(path), results=len(web_records)) 153 return str(path) 154 155 if scope == "paper": 156 paper_records = await self._llm_paper_records(normalized_prompt) 157 path = self._result_writer.write_paper_results( 158 "llm", 159 paper_records, 160 request_id=request_id, 161 ) 162 self._log_results_written(path=str(path), results=len(paper_records)) 163 return str(path) 164 165 web_outcome, paper_outcome = await asyncio.gather( 166 self._llm_web_records(normalized_prompt), 167 self._llm_paper_records(normalized_prompt), 168 return_exceptions=True, 169 ) 170 if isinstance(web_outcome, BaseException): 171 self._log_branch_failure("web", web_outcome) 172 mixed_web_records: list[SearchRecord] = [] 173 else: 174 mixed_web_records = web_outcome 175 if isinstance(paper_outcome, BaseException): 176 self._log_branch_failure("paper", paper_outcome) 177 mixed_paper_records: list[PaperRecord] = [] 178 else: 179 mixed_paper_records = paper_outcome 180 if isinstance(web_outcome, BaseException) and isinstance( 181 paper_outcome, 182 BaseException, 183 ): 184 raise ExecutionFailure( 185 ErrorCode.ALL_PROVIDERS_FAILED, 186 "All LLM search branches failed", 187 ) 188 path = self._result_writer.write_mixed_results( 189 mixed_web_records, 190 mixed_paper_records, 191 request_id=request_id, 192 ) 193 self._log_results_written( 194 path=str(path), 195 results=len(mixed_web_records) + len(mixed_paper_records), 196 ) 197 return str(path) 198 199 async def _llm_web_records(self, prompt: str) -> list[SearchRecord]: 200 outcomes = await asyncio.gather( 201 *(self._run_llm_pipeline(invocation, prompt) for invocation in self._llm_invocations), 202 return_exceptions=True, 203 ) 204 if not any(isinstance(outcome, list) for outcome in outcomes): 205 raise ExecutionFailure( 206 ErrorCode.ALL_PROVIDERS_FAILED, 207 "All LLM web search provider pipelines failed", 208 ) 209 ordered_urls: list[NormalizedURL] = [] 210 seen: set[NormalizedURL] = set() 211 for outcome in outcomes: 212 if not isinstance(outcome, list): 213 continue 214 for result in outcome: 215 self._store.admit(result.url, result.abstract) 216 if result.url not in seen: 217 seen.add(result.url) 218 ordered_urls.append(result.url) 219 return [self._record_from_store(url) for url in ordered_urls] 220 221 async def _llm_paper_records(self, prompt: str) -> list[PaperRecord]: 222 outcomes = await asyncio.gather( 223 *( 224 self._run_llm_paper_pipeline(invocation, prompt) 225 for invocation in self._llm_invocations 226 ), 227 return_exceptions=True, 228 ) 229 if not any(isinstance(outcome, list) for outcome in outcomes): 230 raise ExecutionFailure( 231 ErrorCode.ALL_PROVIDERS_FAILED, 232 "All LLM paper search provider pipelines failed", 233 ) 234 hits: list[PaperSearchHit] = [] 235 for outcome in outcomes: 236 if isinstance(outcome, list): 237 hits.extend(outcome) 238 return await finalize_paper_hits( 239 hits, 240 aggregator=self._paper_aggregator, 241 resolver=self._paper_resolver, 242 store=self._store, 243 ) 244 245 def _log_results_written(self, *, path: str, results: int) -> None: 246 log_event( 247 self._logger, 248 logging.DEBUG, 249 "results_written", 250 kind="llm", 251 path=path, 252 results=results, 253 ) 254 255 def _log_branch_failure(self, scope: str, exc: BaseException) -> None: 256 log_event( 257 self._logger, 258 logging.DEBUG, 259 "llm_search_branch_failed", 260 scope=scope, 261 error_type=type(exc).__name__, 262 ) 263 264 async def _run_llm_pipeline( 265 self, 266 invocation: LLMInvocation, 267 prompt: str, 268 ) -> list[SearchRecord]: 269 started = self._monotonic() 270 log_event( 271 self._logger, 272 logging.DEBUG, 273 "provider_started", 274 provider=invocation.provider, 275 stage="llm_search", 276 model=invocation.model, 277 ) 278 try: 279 markdown = await self._stages.llm_search_markdown(invocation, prompt) 280 records = parse_search_markdown(markdown) 281 except asyncio.CancelledError: 282 raise 283 except ExecutionFailure as exc: 284 self._log_provider_failure(invocation.provider, "llm_search", started, exc) 285 raise 286 except Exception as exc: 287 self._log_provider_failure(invocation.provider, "llm_search", started, exc) 288 raise ExecutionFailure( 289 ErrorCode.ALL_PROVIDERS_FAILED, 290 f"LLM search provider {invocation.provider} returned invalid data", 291 ) from exc 292 log_event( 293 self._logger, 294 logging.DEBUG, 295 "provider_completed", 296 provider=invocation.provider, 297 stage="llm_search", 298 model=invocation.model, 299 output_chars=len(markdown), 300 results=len(records), 301 elapsed_ms=elapsed_ms(self._monotonic, started), 302 ) 303 return records 304 305 async def _run_llm_paper_pipeline( 306 self, 307 invocation: LLMInvocation, 308 prompt: str, 309 ) -> list[PaperSearchHit]: 310 started = self._monotonic() 311 log_event( 312 self._logger, 313 logging.DEBUG, 314 "provider_started", 315 provider=invocation.provider, 316 stage="llm_paper_search", 317 model=invocation.model, 318 ) 319 try: 320 markdown = await self._stages.llm_paper_search_markdown(invocation, prompt) 321 hits = parse_paper_markdown(markdown, provider=invocation.provider) 322 except asyncio.CancelledError: 323 raise 324 except ExecutionFailure as exc: 325 self._log_provider_failure(invocation.provider, "llm_paper_search", started, exc) 326 raise 327 except Exception as exc: 328 self._log_provider_failure(invocation.provider, "llm_paper_search", started, exc) 329 raise ExecutionFailure( 330 ErrorCode.ALL_PROVIDERS_FAILED, 331 f"LLM paper search provider {invocation.provider} returned invalid data", 332 ) from exc 333 log_event( 334 self._logger, 335 logging.DEBUG, 336 "provider_completed", 337 provider=invocation.provider, 338 stage="llm_paper_search", 339 model=invocation.model, 340 output_chars=len(markdown), 341 results=len(hits), 342 elapsed_ms=elapsed_ms(self._monotonic, started), 343 ) 344 return hits 345 346 async def _run_keyword_pipeline( 347 self, 348 provider: KeywordSearchProvider, 349 query: str, 350 ) -> list[_StagedKeyword]: 351 started = self._monotonic() 352 log_event( 353 self._logger, 354 logging.DEBUG, 355 "provider_started", 356 provider=provider.name, 357 stage="search", 358 ) 359 try: 360 async with self.quotas.get_web(provider.name).lease(): 361 hits = await provider.search(query) 362 if not isinstance(hits, list): 363 raise TypeError("provider search result must be a list") 364 except asyncio.CancelledError: 365 raise 366 except ExecutionFailure as exc: 367 self._log_provider_failure(provider.name, "search", started, exc) 368 raise 369 except Exception as exc: 370 self._log_provider_failure(provider.name, "search", started, exc) 371 raise ExecutionFailure( 372 ErrorCode.ALL_PROVIDERS_FAILED, 373 f"Keyword provider {provider.name} returned invalid data", 374 ) from exc 375 376 staged: list[_StagedKeyword] = [] 377 hit_failure: ExecutionFailure | None = None 378 failed_hits = 0 379 try: 380 for hit in hits: 381 try: 382 staged_hit = await self._stage_keyword_hit(hit, provider=provider.name) 383 except ExecutionFailure as exc: 384 # Keyword hits are independent; one failed judge must not discard sibling hits. 385 if hit_failure is None: 386 hit_failure = exc 387 failed_hits += 1 388 continue 389 if staged_hit is not None: 390 staged.append(staged_hit) 391 except asyncio.CancelledError: 392 raise 393 except Exception as exc: 394 self._log_provider_failure(provider.name, "hit_staging", started, exc) 395 raise ExecutionFailure( 396 ErrorCode.ALL_PROVIDERS_FAILED, 397 f"Keyword provider {provider.name} returned invalid data", 398 ) from exc 399 400 if hit_failure is not None: 401 if not staged: 402 self._log_provider_failure(provider.name, "judge", started, hit_failure) 403 raise hit_failure 404 self._log_provider_partial_failure( 405 provider.name, 406 "judge", 407 started, 408 hit_failure, 409 failed_hits=failed_hits, 410 ) 411 log_event( 412 self._logger, 413 logging.DEBUG, 414 "provider_completed", 415 provider=provider.name, 416 stage="search", 417 hits=len(hits), 418 results=len(staged), 419 elapsed_ms=elapsed_ms(self._monotonic, started), 420 ) 421 return staged 422 423 async def _stage_keyword_hit( 424 self, 425 hit: KeywordSearchHit, 426 *, 427 provider: str, 428 ) -> _StagedKeyword | None: 429 if not isinstance(hit, KeywordSearchHit): 430 raise TypeError("keyword hit has invalid type") 431 for value in (hit.url, hit.title, hit.snippet, hit.raw_content, hit.content): 432 if not isinstance(value, str): 433 raise TypeError("keyword hit fields must be strings") 434 435 abstract = hit.snippet.strip() or hit.title.strip() 436 if not abstract: 437 try: 438 logged_url = target_url_for_log(str(normalize_url(hit.url))) 439 except InputFailure: 440 logged_url = target_url_for_log(hit.url) 441 log_event( 442 self._logger, 443 logging.DEBUG, 444 "candidate_rejected", 445 provider=provider, 446 url=logged_url, 447 reason="empty_abstract", 448 ) 449 return None 450 url = normalize_url(hit.url) 451 log_event( 452 self._logger, 453 logging.DEBUG, 454 "candidate_accepted", 455 provider=provider, 456 url=target_url_for_log(str(url)), 457 abstract_chars=len(abstract), 458 ) 459 current = self._store.get(url) 460 if current is not None and not current.available: 461 self._log_body_decision(provider, url, "body_skipped", "stored_unavailable") 462 return _StagedKeyword(url=url, abstract=abstract, provider=provider) 463 464 had_body = bool(hit.raw_content or hit.content) 465 raw_content = hit.raw_content if hit.raw_content.strip() else "" 466 content = hit.content if hit.content.strip() else "" 467 candidate = content or raw_content 468 if not candidate: 469 reason = "cheap_check" if had_body else "no_body" 470 event = "body_rejected" if had_body else "body_skipped" 471 self._log_body_decision(provider, url, event, reason) 472 return _StagedKeyword(url=url, abstract=abstract, provider=provider) 473 if not cheap_check(candidate): 474 self._log_body_decision(provider, url, "body_rejected", "cheap_check") 475 return _StagedKeyword(url=url, abstract=abstract, provider=provider) 476 477 decision = await self._stages.judge(candidate) 478 if not decision.ok: 479 self._log_body_decision(provider, url, "body_rejected", "judge_rejected") 480 return _StagedKeyword(url=url, abstract=abstract, provider=provider) 481 log_event( 482 self._logger, 483 logging.DEBUG, 484 "body_accepted", 485 provider=provider, 486 url=target_url_for_log(str(url)), 487 raw_chars=len(raw_content), 488 content_chars=len(content), 489 ) 490 return _StagedKeyword( 491 url=url, 492 abstract=abstract, 493 provider=provider, 494 raw_content=raw_content, 495 content=content, 496 ) 497 498 def _log_body_decision( 499 self, 500 provider: str, 501 url: NormalizedURL, 502 event: str, 503 reason: str, 504 ) -> None: 505 log_event( 506 self._logger, 507 logging.DEBUG, 508 event, 509 provider=provider, 510 url=target_url_for_log(str(url)), 511 reason=reason, 512 ) 513 514 def _log_provider_failure( 515 self, 516 provider: str, 517 stage: str, 518 started: float, 519 exc: Exception, 520 ) -> None: 521 log_event( 522 self._logger, 523 logging.DEBUG, 524 "provider_failed", 525 provider=provider, 526 stage=stage, 527 error_type=type(exc).__name__, 528 elapsed_ms=elapsed_ms(self._monotonic, started), 529 ) 530 531 def _log_provider_partial_failure( 532 self, 533 provider: str, 534 stage: str, 535 started: float, 536 exc: Exception, 537 *, 538 failed_hits: int, 539 ) -> None: 540 log_event( 541 self._logger, 542 logging.DEBUG, 543 "provider_partial_failure", 544 provider=provider, 545 stage=stage, 546 error_type=type(exc).__name__, 547 failed_hits=failed_hits, 548 elapsed_ms=elapsed_ms(self._monotonic, started), 549 ) 550 551 def _record_from_store(self, url: NormalizedURL) -> SearchRecord: 552 record = self._store.get(url) 553 if record is None: 554 raise RuntimeError("committed URL disappeared from store") 555 return SearchRecord(record.url, record.abstract)
SearchOrchestrator( *, keyword_providers: Sequence[agent_search_gateway.providers.contracts.KeywordSearchProvider], llm_invocations: Sequence[agent_search_gateway.models.LLMInvocation], quotas: agent_search_gateway.concurrency.ProviderQuotaManager, stages: agent_search_gateway.llm.stages.LLMStages, store: agent_search_gateway.url_store.URLStore, result_writer: agent_search_gateway.result_writer.ResultWriter, paper_aggregator: agent_search_gateway.academic.aggregator.PaperAggregator | None = None, paper_resolver: agent_search_gateway.providers.contracts.OAResolver | None = None, logger: logging.Logger | None = None, monotonic: Callable[[], float] = <built-in function monotonic>)
41 def __init__( 42 self, 43 *, 44 keyword_providers: Sequence[KeywordSearchProvider], 45 llm_invocations: Sequence[LLMInvocation], 46 quotas: ProviderQuotaManager, 47 stages: LLMStages, 48 store: URLStore, 49 result_writer: ResultWriter, 50 paper_aggregator: PaperAggregator | None = None, 51 paper_resolver: OAResolver | None = None, 52 logger: logging.Logger | None = None, 53 monotonic: Callable[[], float] = time.monotonic, 54 ) -> None: 55 self._keyword_providers = tuple(keyword_providers) 56 self._llm_invocations = tuple(llm_invocations) 57 self.quotas = quotas 58 self._stages = stages 59 self._store = store 60 self._result_writer = result_writer 61 self._paper_aggregator = paper_aggregator or PaperAggregator( 62 tuple(f"llm:{invocation.provider}" for invocation in self._llm_invocations) 63 ) 64 self._paper_resolver = paper_resolver 65 self._logger = logger or logging.getLogger(__name__) 66 self._monotonic = monotonic
async def
keyword_search(self, query: str, *, request_id: str) -> str:
68 async def keyword_search(self, query: str, *, request_id: str) -> str: 69 validate_request_id(request_id) 70 normalized_query = query.strip() 71 if not normalized_query: 72 raise InputFailure(ErrorCode.EMPTY_QUERY, "Query must not be empty") 73 if not self._keyword_providers: 74 raise ExecutionFailure( 75 ErrorCode.NO_KEYWORD_SEARCH_PROVIDERS, 76 "No keyword search providers are enabled", 77 ) 78 79 outcomes = await asyncio.gather( 80 *( 81 self._run_keyword_pipeline(provider, normalized_query) 82 for provider in self._keyword_providers 83 ), 84 return_exceptions=True, 85 ) 86 completed = [outcome for outcome in outcomes if isinstance(outcome, list)] 87 if not completed: 88 raise ExecutionFailure( 89 ErrorCode.ALL_PROVIDERS_FAILED, 90 "All keyword search provider pipelines failed", 91 ) 92 93 ordered_urls: list[NormalizedURL] = [] 94 seen: set[NormalizedURL] = set() 95 for outcome in outcomes: 96 if not isinstance(outcome, list): 97 continue 98 for staged in outcome: 99 self._store.admit( 100 staged.url, 101 staged.abstract, 102 raw_content=staged.raw_content, 103 content=staged.content, 104 ) 105 if staged.url not in seen: 106 seen.add(staged.url) 107 ordered_urls.append(staged.url) 108 else: 109 log_event( 110 self._logger, 111 logging.DEBUG, 112 "candidate_rejected", 113 provider=staged.provider, 114 url=target_url_for_log(str(staged.url)), 115 reason="duplicate", 116 ) 117 118 records = [self._record_from_store(url) for url in ordered_urls] 119 path = self._result_writer.write_results("keyword", records, request_id=request_id) 120 log_event( 121 self._logger, 122 logging.DEBUG, 123 "results_written", 124 kind="keyword", 125 path=str(path), 126 results=len(records), 127 ) 128 return str(path)
async def
llm_search( self, prompt: str, *, request_id: str, scope: Literal['web', 'paper', 'all'] = 'web') -> str:
130 async def llm_search( 131 self, 132 prompt: str, 133 *, 134 request_id: str, 135 scope: LLMSearchScope = "web", 136 ) -> str: 137 validate_request_id(request_id) 138 normalized_prompt = prompt.strip() 139 if not normalized_prompt: 140 raise InputFailure(ErrorCode.EMPTY_QUERY, "Prompt must not be empty") 141 if scope not in {"web", "paper", "all"}: 142 raise InputFailure(ErrorCode.BAD_REQUEST, "LLM search scope is invalid") 143 if not self._llm_invocations: 144 raise ExecutionFailure( 145 ErrorCode.NO_LLM_SEARCH_PROVIDERS, 146 "No LLM search providers are configured", 147 ) 148 149 if scope == "web": 150 web_records = await self._llm_web_records(normalized_prompt) 151 path = self._result_writer.write_results("llm", web_records, request_id=request_id) 152 self._log_results_written(path=str(path), results=len(web_records)) 153 return str(path) 154 155 if scope == "paper": 156 paper_records = await self._llm_paper_records(normalized_prompt) 157 path = self._result_writer.write_paper_results( 158 "llm", 159 paper_records, 160 request_id=request_id, 161 ) 162 self._log_results_written(path=str(path), results=len(paper_records)) 163 return str(path) 164 165 web_outcome, paper_outcome = await asyncio.gather( 166 self._llm_web_records(normalized_prompt), 167 self._llm_paper_records(normalized_prompt), 168 return_exceptions=True, 169 ) 170 if isinstance(web_outcome, BaseException): 171 self._log_branch_failure("web", web_outcome) 172 mixed_web_records: list[SearchRecord] = [] 173 else: 174 mixed_web_records = web_outcome 175 if isinstance(paper_outcome, BaseException): 176 self._log_branch_failure("paper", paper_outcome) 177 mixed_paper_records: list[PaperRecord] = [] 178 else: 179 mixed_paper_records = paper_outcome 180 if isinstance(web_outcome, BaseException) and isinstance( 181 paper_outcome, 182 BaseException, 183 ): 184 raise ExecutionFailure( 185 ErrorCode.ALL_PROVIDERS_FAILED, 186 "All LLM search branches failed", 187 ) 188 path = self._result_writer.write_mixed_results( 189 mixed_web_records, 190 mixed_paper_records, 191 request_id=request_id, 192 ) 193 self._log_results_written( 194 path=str(path), 195 results=len(mixed_web_records) + len(mixed_paper_records), 196 ) 197 return str(path)