262 lines
7.9 KiB
Python
262 lines
7.9 KiB
Python
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.api.v1 import settings as settings_api
|
|
from app.api.v1.settings import (
|
|
WebSearchIntegrationUpdate,
|
|
_build_web_search_payload,
|
|
_mask_secret,
|
|
_normalize_web_search_payload,
|
|
_resolve_web_search_api_key,
|
|
)
|
|
from app.services.ai_tools.schemas import WebSearchConfig, WebSearchProviderConfig
|
|
from app.services.ai_tools.web_search import WebSearchClient
|
|
from app.services.credential_guides import generate_credential_guide
|
|
from app.services.location.llm_fallback import (
|
|
collect_llm_location_fallback_candidate,
|
|
collect_location_search_evidence,
|
|
)
|
|
from app.services.location.models import LocationQuery
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_web_search_env_files(monkeypatch, tmp_path):
|
|
env_file = tmp_path / ".env"
|
|
monkeypatch.setattr(settings_api, "WEB_SEARCH_ENV_FILES", (env_file,))
|
|
return env_file
|
|
|
|
|
|
def test_normalize_web_search_payload_adds_default_provider():
|
|
payload = _normalize_web_search_payload({})
|
|
|
|
assert payload["default_provider"] == "tavily"
|
|
assert payload["providers"]["tavily"]["base_url"] == "https://api.tavily.com"
|
|
|
|
|
|
def test_web_search_key_prefers_provider_env(isolated_web_search_env_files):
|
|
isolated_web_search_env_files.write_text(
|
|
"TAVILY_API_KEY=tavily-env-key\nWEB_SEARCH_API_KEY=generic-search-key\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
value, source = _resolve_web_search_api_key("tavily", {"api_key": ""})
|
|
|
|
assert value == "tavily-env-key"
|
|
assert source == "env_file"
|
|
|
|
|
|
def test_build_web_search_payload_keeps_saved_key_when_preview_submitted():
|
|
current = {
|
|
"web_search": {
|
|
"default_provider": "tavily",
|
|
"providers": {
|
|
"tavily": {
|
|
"provider": "tavily",
|
|
"api_key": "tvly-old-secret",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
update = WebSearchIntegrationUpdate(
|
|
enabled=True,
|
|
provider="tavily",
|
|
base_url="https://api.tavily.com",
|
|
api_key=_mask_secret("tvly-old-secret")["preview"],
|
|
)
|
|
|
|
payload = _build_web_search_payload(current, update)
|
|
|
|
assert payload["enabled"] is True
|
|
assert payload["providers"]["tavily"]["api_key"] == "tvly-old-secret"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tavily_adapter_normalizes_results(monkeypatch):
|
|
config = WebSearchConfig(
|
|
enabled=True,
|
|
default_provider="tavily",
|
|
provider="tavily",
|
|
providers={
|
|
"tavily": WebSearchProviderConfig(
|
|
provider="tavily",
|
|
base_url="https://api.tavily.com",
|
|
api_key="key",
|
|
)
|
|
},
|
|
)
|
|
client = WebSearchClient(config)
|
|
|
|
async def fake_request_json(*args, **kwargs):
|
|
return {
|
|
"query": "Alem.Cloud",
|
|
"results": [
|
|
{
|
|
"title": "Alem.Cloud official",
|
|
"url": "https://example.test/alem",
|
|
"content": "Alem.Cloud is in Astana.",
|
|
"score": 0.9,
|
|
}
|
|
],
|
|
}
|
|
|
|
monkeypatch.setattr(client, "_request_json", fake_request_json)
|
|
|
|
results = await client.search("Alem.Cloud")
|
|
|
|
assert results[0].source_provider == "tavily"
|
|
assert results[0].url == "https://example.test/alem"
|
|
assert "Astana" in results[0].snippet
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_searxng_adapter_allows_empty_api_key(monkeypatch):
|
|
config = WebSearchConfig(
|
|
enabled=True,
|
|
default_provider="searxng",
|
|
provider="searxng",
|
|
providers={
|
|
"searxng": WebSearchProviderConfig(
|
|
provider="searxng",
|
|
base_url="http://localhost:8080",
|
|
api_key="",
|
|
)
|
|
},
|
|
)
|
|
client = WebSearchClient(config)
|
|
|
|
async def fake_request_json(*args, **kwargs):
|
|
return {
|
|
"results": [
|
|
{
|
|
"title": "TAIPEI-1",
|
|
"url": "https://example.test/taipei",
|
|
"content": "TAIPEI-1 is in Taipei.",
|
|
"score": 2,
|
|
"engine": "duckduckgo",
|
|
}
|
|
],
|
|
}
|
|
|
|
monkeypatch.setattr(client, "_request_json", fake_request_json)
|
|
|
|
results = await client.search("TAIPEI-1")
|
|
|
|
assert results[0].source_provider == "searxng"
|
|
assert results[0].metadata["engine"] == "duckduckgo"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_location_search_evidence_returns_failure_on_empty_results(monkeypatch):
|
|
class EmptySearchClient:
|
|
async def search(self, *args, **kwargs):
|
|
return []
|
|
|
|
result = await collect_location_search_evidence(
|
|
web_search_client=EmptySearchClient(),
|
|
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
|
entity_type="compute_center",
|
|
)
|
|
|
|
assert result.evidence == []
|
|
assert "no usable" in result.failure_reason
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_llm_location_fallback_skips_when_search_evidence_empty():
|
|
class ExplodingAIClient:
|
|
async def analyze(self, *_args, **_kwargs):
|
|
raise AssertionError("LLM should not be called without evidence")
|
|
|
|
result = await collect_llm_location_fallback_candidate(
|
|
provider_client=ExplodingAIClient(),
|
|
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
|
entity_type="compute_center",
|
|
search_evidence=[],
|
|
)
|
|
|
|
assert result.candidates == []
|
|
assert "no WebSearch evidence" in result.failure_reason
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_credential_guide_keeps_default_without_search_evidence():
|
|
class EmptySearchClient:
|
|
async def search(self, *args, **kwargs):
|
|
return []
|
|
|
|
class ExplodingAIClient:
|
|
async def analyze(self, *_args, **_kwargs):
|
|
raise AssertionError("AI should not be called without search evidence")
|
|
|
|
async def fake_get_store(_db):
|
|
return None, {}
|
|
|
|
import app.services.credential_guides as credential_guides
|
|
|
|
original = credential_guides._get_guide_store
|
|
credential_guides._get_guide_store = fake_get_store
|
|
try:
|
|
guide = await generate_credential_guide(
|
|
object(),
|
|
"barentswatch",
|
|
ExplodingAIClient(),
|
|
EmptySearchClient(),
|
|
)
|
|
finally:
|
|
credential_guides._get_guide_store = original
|
|
|
|
assert guide["source"] == "default"
|
|
assert guide["verification_status"] == "unverified_no_search_evidence"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_credential_guide_uses_search_evidence(monkeypatch):
|
|
class SearchClient:
|
|
async def search(self, *args, **kwargs):
|
|
from app.services.ai_tools.schemas import SearchEvidence
|
|
|
|
return [
|
|
SearchEvidence(
|
|
title="Official docs",
|
|
url="https://docs.example.test",
|
|
snippet="Create an AIS client.",
|
|
source_provider="tavily",
|
|
)
|
|
]
|
|
|
|
class AIClient:
|
|
async def analyze(self, payload):
|
|
assert payload.context["search_evidence"]
|
|
return SimpleNamespace(content="## Generated\n\nSources included.")
|
|
|
|
saved = {}
|
|
|
|
async def fake_get_store(_db):
|
|
return None, saved
|
|
|
|
async def fake_save(db, provider, title, markdown, **metadata):
|
|
return {
|
|
"provider": provider,
|
|
"title": title,
|
|
"markdown": markdown,
|
|
"source": "ai",
|
|
**metadata,
|
|
}
|
|
|
|
import app.services.credential_guides as credential_guides
|
|
|
|
monkeypatch.setattr(credential_guides, "_get_guide_store", fake_get_store)
|
|
monkeypatch.setattr(credential_guides, "save_credential_guide", fake_save)
|
|
|
|
guide = await generate_credential_guide(
|
|
object(),
|
|
"barentswatch",
|
|
AIClient(),
|
|
SearchClient(),
|
|
)
|
|
|
|
assert guide["source"] == "ai"
|
|
assert guide["verification_status"] == "verified_with_search_evidence"
|
|
assert guide["sources"][0]["url"] == "https://docs.example.test"
|