Edit on GitHub

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
quotas