feat(capabilities): add web.discover schemas and provider seam

This commit is contained in:
CREDO23 2026-07-01 17:08:58 +02:00
parent a413539f6a
commit fa1055dd4c
6 changed files with 168 additions and 0 deletions

View file

@ -0,0 +1,3 @@
"""``web.discover`` verb: a query → candidate URLs the agent can feed to ``web.scrape``."""
from __future__ import annotations

View file

@ -0,0 +1,40 @@
"""``web.discover`` executor: route a query to the first configured search provider."""
from __future__ import annotations
from collections.abc import Sequence
from app.capabilities.types import Executor
from app.capabilities.web.discover.providers.base import DiscoverProvider
from app.capabilities.web.discover.schemas import DiscoverInput, DiscoverOutput
class NoDiscoverProviderError(RuntimeError):
"""Raised when no search provider is configured (no platform key/host set)."""
def build_discover_executor(
providers: Sequence[DiscoverProvider] | None = None,
) -> Executor:
"""Bind the executor to a provider set (defaults to the real env-keyed providers)."""
registry = list(providers) if providers is not None else _default_providers()
async def execute(payload: DiscoverInput) -> DiscoverOutput:
provider = next((p for p in registry if p.is_available()), None)
if provider is None:
raise NoDiscoverProviderError(
"web.discover has no configured search provider "
"(set a SearXNG host or a Linkup/Baidu key)."
)
hits = await provider.search(payload.query, payload.top_k)
return DiscoverOutput(hits=hits)
return execute
def _default_providers() -> list[DiscoverProvider]:
from app.capabilities.web.discover.providers.baidu import BaiduProvider
from app.capabilities.web.discover.providers.linkup import LinkupProvider
from app.capabilities.web.discover.providers.searxng import SearxngProvider
return [SearxngProvider(), LinkupProvider(), BaiduProvider()]

View file

@ -0,0 +1,3 @@
"""``web.discover`` search providers (env-keyed: SearXNG, Linkup, Baidu)."""
from __future__ import annotations

View file

@ -0,0 +1,22 @@
"""The seam every ``web.discover`` provider implements."""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from app.capabilities.web.discover.schemas import DiscoverHit
@runtime_checkable
class DiscoverProvider(Protocol):
"""An env-keyed search backend that suggests candidate URLs for a query."""
name: str
def is_available(self) -> bool:
"""True when this provider's platform key/host is configured."""
...
async def search(self, query: str, top_k: int) -> list[DiscoverHit]:
"""Return up to ``top_k`` candidate hits for ``query``."""
...

View file

@ -0,0 +1,21 @@
"""``web.discover`` I/O contracts."""
from __future__ import annotations
from pydantic import BaseModel
class DiscoverInput(BaseModel):
query: str
top_k: int = 10
class DiscoverHit(BaseModel):
url: str
title: str
snippet: str | None = None
provider: str
class DiscoverOutput(BaseModel):
hits: list[DiscoverHit]

View file

@ -0,0 +1,79 @@
"""`web.discover` executor: pick the first configured provider; self-disable when none.
Boundary mocked: the providers (injected fakes). NOT mocked: the executor's
provider-selection and self-disable behavior.
"""
from __future__ import annotations
import pytest
from app.capabilities.web.discover.executor import (
NoDiscoverProviderError,
build_discover_executor,
)
from app.capabilities.web.discover.schemas import (
DiscoverHit,
DiscoverInput,
DiscoverOutput,
)
pytestmark = pytest.mark.unit
class _FakeProvider:
def __init__(
self, name: str, available: bool, hits: list[DiscoverHit] | None = None
):
self.name = name
self._available = available
self._hits = hits or []
self.calls: list[tuple[str, int]] = []
def is_available(self) -> bool:
return self._available
async def search(self, query: str, top_k: int) -> list[DiscoverHit]:
self.calls.append((query, top_k))
return self._hits
def _hit(url: str, provider: str) -> DiscoverHit:
return DiscoverHit(url=url, title=url, snippet="s", provider=provider)
async def test_uses_the_first_available_provider():
first = _FakeProvider(
"searxng", available=True, hits=[_hit("https://a.com", "searxng")]
)
second = _FakeProvider(
"linkup", available=True, hits=[_hit("https://b.com", "linkup")]
)
execute = build_discover_executor(providers=[first, second])
out = await execute(DiscoverInput(query="acme pricing", top_k=5))
assert isinstance(out, DiscoverOutput)
assert [h.url for h in out.hits] == ["https://a.com"]
assert first.calls == [("acme pricing", 5)]
assert second.calls == [] # first available short-circuits
async def test_skips_unavailable_providers():
off = _FakeProvider("searxng", available=False)
on = _FakeProvider("linkup", available=True, hits=[_hit("https://b.com", "linkup")])
execute = build_discover_executor(providers=[off, on])
out = await execute(DiscoverInput(query="q"))
assert [h.provider for h in out.hits] == ["linkup"]
assert off.calls == []
async def test_self_disables_when_no_provider_is_configured():
execute = build_discover_executor(
providers=[_FakeProvider("searxng", available=False)]
)
with pytest.raises(NoDiscoverProviderError):
await execute(DiscoverInput(query="q"))