mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
315 lines
10 KiB
Python
315 lines
10 KiB
Python
"""roboco.services.research — provider adapters + service coverage.
|
|||
|
|
|
||
|
|
Provider HTTP is exercised against ``httpx.MockTransport`` (no network, no
|
||
|
|
extra dependency); the service-level tests use a recording fake to assert the
|
||
|
|
result/byte clamps.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
from collections.abc import Callable
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
import pytest
|
||
|
|
from roboco.config import settings
|
||
|
|
from roboco.services.research import (
|
||
|
|
BraveProvider,
|
||
|
|
ExaProvider,
|
||
|
|
FetchOutcome,
|
||
|
|
NullProvider,
|
||
|
|
ResearchError,
|
||
|
|
ResearchService,
|
||
|
|
ResearchUnsupportedError,
|
||
|
|
SearchOutcome,
|
||
|
|
SearchProvider,
|
||
|
|
TavilyProvider,
|
||
|
|
build_provider,
|
||
|
|
get_research_service,
|
||
|
|
)
|
||
|
|
|
||
|
|
Handler = Callable[[httpx.Request], httpx.Response]
|
||
|
|
|
||
|
|
_QUERY = "agentic frameworks"
|
||
|
|
_N_RESULTS = 3
|
||
|
|
_TOP_SCORE = 0.9
|
||
|
|
_TRUNC_CAP = 10
|
||
|
|
_RESULTS_CAP = 5
|
||
|
|
_REQ_RESULTS = 2
|
||
|
|
_FETCH_CAP = 20
|
||
|
|
|
||
|
|
|
||
|
|
def _client(handler: Handler) -> httpx.AsyncClient:
|
||
|
|
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Tavily
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_tavily_search_parses_results_and_answer() -> None:
|
||
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
||
|
|
assert request.url.host == "api.tavily.com"
|
||
|
|
body = json.loads(request.content)
|
||
|
|
assert body["query"] == _QUERY
|
||
|
|
assert body["max_results"] == _N_RESULTS
|
||
|
|
return httpx.Response(
|
||
|
|
200,
|
||
|
|
json={
|
||
|
|
"query": _QUERY,
|
||
|
|
"answer": "Several exist.",
|
||
|
|
"results": [
|
||
|
|
{
|
||
|
|
"title": "A",
|
||
|
|
"url": "https://a.test",
|
||
|
|
"content": "sa",
|
||
|
|
"score": 0.9,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"title": "B",
|
||
|
|
"url": "https://b.test",
|
||
|
|
"content": "sb",
|
||
|
|
"score": 0.5,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
client = _client(handler)
|
||
|
|
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
|
||
|
|
out = await provider.search(_QUERY, _N_RESULTS)
|
||
|
|
assert out.provider == "tavily"
|
||
|
|
assert out.answer == "Several exist."
|
||
|
|
assert [h.url for h in out.hits] == ["https://a.test", "https://b.test"]
|
||
|
|
assert out.hits[0].score == _TOP_SCORE
|
||
|
|
await client.aclose()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_tavily_fetch_extracts_raw_content() -> None:
|
||
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
||
|
|
assert request.url.path == "/extract"
|
||
|
|
return httpx.Response(
|
||
|
|
200, json={"results": [{"url": "https://a.test", "raw_content": "hello"}]}
|
||
|
|
)
|
||
|
|
|
||
|
|
client = _client(handler)
|
||
|
|
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
|
||
|
|
out = await provider.fetch("https://a.test", 1000)
|
||
|
|
assert out.content == "hello"
|
||
|
|
assert out.truncated is False
|
||
|
|
await client.aclose()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_tavily_fetch_truncates_to_cap() -> None:
|
||
|
|
def handler(_request: httpx.Request) -> httpx.Response:
|
||
|
|
return httpx.Response(
|
||
|
|
200, json={"results": [{"url": "u", "raw_content": "x" * 100}]}
|
||
|
|
)
|
||
|
|
|
||
|
|
client = _client(handler)
|
||
|
|
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
|
||
|
|
out = await provider.fetch("u", _TRUNC_CAP)
|
||
|
|
assert len(out.content) == _TRUNC_CAP
|
||
|
|
assert out.truncated is True
|
||
|
|
await client.aclose()
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Brave
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_brave_search_parses_web_results() -> None:
|
||
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
||
|
|
assert request.url.host == "api.search.brave.com"
|
||
|
|
assert request.headers["X-Subscription-Token"] == "k"
|
||
|
|
return httpx.Response(
|
||
|
|
200,
|
||
|
|
json={
|
||
|
|
"web": {
|
||
|
|
"results": [
|
||
|
|
{"title": "T", "url": "https://t.test", "description": "d"}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
client = _client(handler)
|
||
|
|
provider = BraveProvider(api_key="k", timeout=5.0, client=client)
|
||
|
|
out = await provider.search("q", _RESULTS_CAP)
|
||
|
|
assert out.provider == "brave"
|
||
|
|
assert out.answer is None
|
||
|
|
assert out.hits[0].snippet == "d"
|
||
|
|
await client.aclose()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_brave_fetch_is_unsupported() -> None:
|
||
|
|
provider = BraveProvider(
|
||
|
|
api_key="k", timeout=5.0, client=_client(lambda _r: httpx.Response(200))
|
||
|
|
)
|
||
|
|
with pytest.raises(ResearchUnsupportedError):
|
||
|
|
await provider.fetch("https://x.test", 100)
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Exa
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_exa_search_and_fetch() -> None:
|
||
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
||
|
|
if request.url.path == "/search":
|
||
|
|
return httpx.Response(
|
||
|
|
200,
|
||
|
|
json={
|
||
|
|
"results": [{"title": "E", "url": "https://e.test", "text": "snip"}]
|
||
|
|
},
|
||
|
|
)
|
||
|
|
return httpx.Response(
|
||
|
|
200, json={"results": [{"url": "https://e.test", "text": "full"}]}
|
||
|
|
)
|
||
|
|
|
||
|
|
client = _client(handler)
|
||
|
|
provider = ExaProvider(api_key="k", timeout=5.0, client=client)
|
||
|
|
out = await provider.search("q", _RESULTS_CAP)
|
||
|
|
assert out.hits[0].snippet == "snip"
|
||
|
|
fetched = await provider.fetch("https://e.test", 1000)
|
||
|
|
assert fetched.content == "full"
|
||
|
|
await client.aclose()
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Error handling
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_non_2xx_raises_research_error() -> None:
|
||
|
|
client = _client(lambda _r: httpx.Response(500, text="boom"))
|
||
|
|
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
|
||
|
|
with pytest.raises(ResearchError):
|
||
|
|
await provider.search("q", _RESULTS_CAP)
|
||
|
|
await client.aclose()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_network_error_raises_research_error() -> None:
|
||
|
|
def handler(_request: httpx.Request) -> httpx.Response:
|
||
|
|
raise httpx.ConnectError("down")
|
||
|
|
|
||
|
|
client = _client(handler)
|
||
|
|
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
|
||
|
|
with pytest.raises(ResearchError):
|
||
|
|
await provider.search("q", _RESULTS_CAP)
|
||
|
|
await client.aclose()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_malformed_json_raises_research_error() -> None:
|
||
|
|
client = _client(
|
||
|
|
lambda _r: httpx.Response(
|
||
|
|
200, text="not json", headers={"content-type": "application/json"}
|
||
|
|
)
|
||
|
|
)
|
||
|
|
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
|
||
|
|
with pytest.raises(ResearchError):
|
||
|
|
await provider.search("q", _RESULTS_CAP)
|
||
|
|
await client.aclose()
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# NullProvider + build_provider
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_null_provider_degrades_gracefully() -> None:
|
||
|
|
provider = NullProvider(api_key=None, timeout=5.0)
|
||
|
|
assert provider.configured is False
|
||
|
|
search = await provider.search("q", _RESULTS_CAP)
|
||
|
|
assert search.hits == []
|
||
|
|
assert search.provider == "null"
|
||
|
|
fetched = await provider.fetch("u", _RESULTS_CAP)
|
||
|
|
assert fetched.content == ""
|
||
|
|
|
||
|
|
|
||
|
|
def test_build_provider_selects_by_name() -> None:
|
||
|
|
assert isinstance(build_provider("tavily", "k", 5.0), TavilyProvider)
|
||
|
|
assert isinstance(build_provider("brave", "k", 5.0), BraveProvider)
|
||
|
|
assert isinstance(build_provider("exa", "k", 5.0), ExaProvider)
|
||
|
|
assert isinstance(build_provider("null", "k", 5.0), NullProvider)
|
||
|
|
|
||
|
|
|
||
|
|
def test_build_provider_null_when_no_key_or_unknown() -> None:
|
||
|
|
assert isinstance(build_provider("tavily", None, 5.0), NullProvider)
|
||
|
|
assert isinstance(build_provider("mystery", "k", 5.0), NullProvider)
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# ResearchService — clamps
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
class _RecordingProvider(SearchProvider):
|
||
|
|
name = "rec"
|
||
|
|
|
||
|
|
def __init__(self) -> None:
|
||
|
|
super().__init__(api_key="k", timeout=5.0)
|
||
|
|
self.last_max_results: int | None = None
|
||
|
|
self.last_max_chars: int | None = None
|
||
|
|
self.fetch_content = "z" * 50
|
||
|
|
|
||
|
|
async def search(self, query: str, max_results: int) -> SearchOutcome:
|
||
|
|
self.last_max_results = max_results
|
||
|
|
return SearchOutcome(query=query, hits=[], answer=None, provider=self.name)
|
||
|
|
|
||
|
|
async def fetch(self, url: str, max_chars: int) -> FetchOutcome:
|
||
|
|
self.last_max_chars = max_chars
|
||
|
|
return FetchOutcome(
|
||
|
|
url=url, content=self.fetch_content, truncated=False, provider=self.name
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_service_clamps_max_results() -> None:
|
||
|
|
provider = _RecordingProvider()
|
||
|
|
service = ResearchService(
|
||
|
|
provider, max_results_cap=_RESULTS_CAP, fetch_max_chars_cap=100
|
||
|
|
)
|
||
|
|
await service.search("q", 100)
|
||
|
|
assert provider.last_max_results == _RESULTS_CAP
|
||
|
|
await service.search("q", None)
|
||
|
|
assert provider.last_max_results == _RESULTS_CAP
|
||
|
|
await service.search("q", _REQ_RESULTS)
|
||
|
|
assert provider.last_max_results == _REQ_RESULTS
|
||
|
|
await service.search("q", 0)
|
||
|
|
assert provider.last_max_results == 1
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_service_clamps_and_truncates_fetch() -> None:
|
||
|
|
provider = _RecordingProvider()
|
||
|
|
provider.fetch_content = "y" * 80
|
||
|
|
service = ResearchService(
|
||
|
|
provider, max_results_cap=_RESULTS_CAP, fetch_max_chars_cap=_FETCH_CAP
|
||
|
|
)
|
||
|
|
out = await service.fetch("u", 1000)
|
||
|
|
assert provider.last_max_chars == _FETCH_CAP
|
||
|
|
assert len(out.content) == _FETCH_CAP
|
||
|
|
assert out.truncated is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_research_service_uses_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
|
|
monkeypatch.setattr(settings, "research_provider", "null")
|
||
|
|
monkeypatch.setattr(settings, "research_api_key", None)
|
||
|
|
service = get_research_service()
|
||
|
|
assert service.provider_name == "null"
|
||
|
|
assert service.configured is False
|