release: bump version to 0.50.0
This commit is contained in:
@@ -2,9 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import bgp as bgp_api
|
||||
from app.services import bgp_collector_locations
|
||||
from app.services.location.llm_fallback import LocationLLMFallbackResult
|
||||
from app.services.bgp_collector_locations import (
|
||||
RIPE_RIS_COLLECTOR_COORDS,
|
||||
collect_bgp_collector_location_candidates,
|
||||
@@ -106,6 +110,58 @@ def test_collect_bgp_collector_candidates_uses_nominatim_when_registry_misses(mo
|
||||
assert online[0].needs_confirmation is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_bgp_collector_location_uses_llm_when_candidates_empty(monkeypatch):
|
||||
llm_candidate = bgp_collector_locations.LocationCandidate(
|
||||
latitude=45.764,
|
||||
longitude=4.8357,
|
||||
display_name="Lyon, France",
|
||||
precision="city",
|
||||
confidence=0.74,
|
||||
query="llm_factcheck:bgp_collector:rrc-mystery",
|
||||
source="llm_location_factcheck",
|
||||
source_note="LLM location factcheck fallback",
|
||||
matched_fields=("collector",),
|
||||
needs_confirmation=True,
|
||||
city="Lyon",
|
||||
country="France",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
bgp_api,
|
||||
"get_bgp_collector_location_dict",
|
||||
lambda _collector: {},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
bgp_api,
|
||||
"collect_bgp_collector_location_candidates",
|
||||
lambda **_kwargs: ([], ["Lyon, France"]),
|
||||
)
|
||||
|
||||
async def _fallback(**_kwargs):
|
||||
return LocationLLMFallbackResult(
|
||||
candidates=[llm_candidate],
|
||||
attempted_queries=["llm_factcheck:bgp_collector:rrc-mystery"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(bgp_api, "get_ai_provider_client", AsyncMock(return_value=object()))
|
||||
monkeypatch.setattr(bgp_api, "collect_llm_location_fallback_candidate", _fallback)
|
||||
|
||||
response = await bgp_api.collect_bgp_collector_location(
|
||||
"rrc-mystery",
|
||||
bgp_api.CollectBGPCollectorLocationRequest(city="Lyon", country="France"),
|
||||
current_user=object(),
|
||||
db=AsyncMock(),
|
||||
)
|
||||
|
||||
assert response["success"] is True
|
||||
assert response["best_candidate"]["source"] == "llm_location_factcheck"
|
||||
assert response["best_candidate"]["needs_confirmation"] is True
|
||||
assert response["attempted_queries"] == [
|
||||
"Lyon, France",
|
||||
"llm_factcheck:bgp_collector:rrc-mystery",
|
||||
]
|
||||
|
||||
|
||||
# ── BGP event resolver ─────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ async def test_public_catalog_only_for_anonymous_user():
|
||||
"overview",
|
||||
"quickstart",
|
||||
"manual",
|
||||
"faq",
|
||||
"location-pipeline-user",
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ from app.services.location import (
|
||||
ResolverOutput,
|
||||
SourceCoordinatesResolver,
|
||||
)
|
||||
from app.schemas.ai import SituationalAnalysisResponse
|
||||
import app.services.location.llm_fallback as llm_fallback
|
||||
from app.services.location.llm_fallback import collect_llm_location_fallback_candidate
|
||||
|
||||
|
||||
# ── Test fixtures ────────────────────────────────────────────────────
|
||||
@@ -427,3 +430,528 @@ def test_pluggability_custom_resolver_works_without_changing_pipeline():
|
||||
)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].source == "peeringdb_stub"
|
||||
|
||||
|
||||
# ── LLM fallback helper ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeAIProviderClient:
|
||||
def __init__(self, content: str | list[str]):
|
||||
self.contents = content if isinstance(content, list) else [content]
|
||||
self.calls = 0
|
||||
|
||||
async def analyze(self, payload, request_id=None):
|
||||
self.calls += 1
|
||||
content = self.contents[min(self.calls - 1, len(self.contents) - 1)]
|
||||
return SituationalAnalysisResponse(
|
||||
provider="test",
|
||||
model="test-model",
|
||||
content=content,
|
||||
raw_response={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_returns_candidate_from_strict_json():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 45.764,
|
||||
"longitude": 4.8357,
|
||||
"precision": "city",
|
||||
"confidence": 0.74,
|
||||
"city": "Lyon",
|
||||
"region": "Auvergne-Rhone-Alpes",
|
||||
"country": "France",
|
||||
"matched_location_name": "Lyon, France",
|
||||
"evidence": ["operator and city point to Lyon"],
|
||||
"reasoning_summary": "Best supported city-level match.",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(
|
||||
name="Mystery GPU Cluster",
|
||||
city="Lyon",
|
||||
country="France",
|
||||
extra={"operator": "Mystery Operator"},
|
||||
),
|
||||
entity_type="compute_center",
|
||||
attempted_queries=("Mystery Operator, Lyon, France",),
|
||||
)
|
||||
|
||||
assert client.calls == 1
|
||||
assert result.failure_reason is None
|
||||
assert result.attempted_queries == ["llm_factcheck:compute_center:Mystery GPU Cluster"]
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.source == "llm_location_factcheck"
|
||||
assert candidate.needs_confirmation is True
|
||||
assert candidate.precision == "city"
|
||||
assert candidate.city == "Lyon"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_accepts_common_precision_aliases():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"candidate": {
|
||||
"latitude": 43.2389,
|
||||
"longitude": 76.8897,
|
||||
"precision": "city-level",
|
||||
"confidence": "0.68",
|
||||
"city": "Almaty",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Almaty, Kazakhstan",
|
||||
"evidence": ["NITEC context points to Almaty"],
|
||||
"reasoning_summary": "City-level fallback.",
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].precision == "city"
|
||||
assert result.candidates[0].confidence >= 0.55
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_accepts_lat_lng_aliases():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"lat": 51.1694,
|
||||
"lng": 71.4491,
|
||||
"precision": "city",
|
||||
"confidence": 0.62,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Official source",
|
||||
"source_type": "official",
|
||||
"entity_match": True,
|
||||
"text": "Alem.Cloud is in Astana.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].latitude == pytest.approx(51.1694)
|
||||
assert result.candidates[0].longitude == pytest.approx(71.4491)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_geocodes_city_when_coordinates_missing(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "51.1694",
|
||||
"lon": "71.4491",
|
||||
"display_name": "Astana, Kazakhstan",
|
||||
"address": {"city": "Astana", "country": "Kazakhstan"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"precision": "city",
|
||||
"confidence": 0.62,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Official source",
|
||||
"source_type": "official",
|
||||
"entity_match": True,
|
||||
"text": "Alem.Cloud is in Astana.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.latitude == pytest.approx(51.1694)
|
||||
assert candidate.longitude == pytest.approx(71.4491)
|
||||
assert "Nominatim city fallback" in candidate.source_note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_geocodes_matched_location_without_city(monkeypatch):
|
||||
def _fake_geocode(query):
|
||||
if "Falun" not in query:
|
||||
return None
|
||||
return {
|
||||
"lat": "60.6065",
|
||||
"lon": "15.6355",
|
||||
"display_name": "Falun, Dalarna County, Sweden",
|
||||
"address": {"city": "Falun", "state": "Dalarna County", "country": "Sweden"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(llm_fallback, "_geocode_llm_city", _fake_geocode)
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"precision": "city",
|
||||
"confidence": 0.64,
|
||||
"country": "Sweden",
|
||||
"matched_location_name": "Falun, Sweden",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Credible public source",
|
||||
"source_type": "news",
|
||||
"entity_match": True,
|
||||
"text": "DeepL Mercury supercomputer is located in Falun.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="DeepL Mercury", country="Sweden"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Falun"
|
||||
assert candidate.country == "瑞典"
|
||||
assert candidate.latitude == pytest.approx(60.6065)
|
||||
assert candidate.longitude == pytest.approx(15.6355)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_repairs_non_json_answer(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "25.033",
|
||||
"lon": "121.5654",
|
||||
"display_name": "Taipei, Taiwan",
|
||||
"address": {"city": "Taipei", "country": "Taiwan"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
[
|
||||
"TAIPEI-1 appears to be located in Taipei, Taiwan, based on NVIDIA context.",
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
"precision": "city",
|
||||
"confidence": 0.62,
|
||||
"city": "Taipei",
|
||||
"country": "Taiwan",
|
||||
"matched_location_name": "Taipei, Taiwan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "NVIDIA context",
|
||||
"source_type": "generic",
|
||||
"entity_match": True,
|
||||
"text": "TAIPEI-1 appears to be located in Taipei.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "City-level location extracted from prose.",
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert client.calls == 2
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].city == "Taipei"
|
||||
assert result.candidates[0].source == "llm_location_factcheck"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_accepts_taipei_name_hint_with_weak_wording(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "25.033",
|
||||
"lon": "121.5654",
|
||||
"display_name": "Taipei, Taiwan",
|
||||
"address": {"city": "Taipei", "country": "Taiwan"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
"precision": "city",
|
||||
"confidence": 0.43,
|
||||
"city": "Taipei",
|
||||
"country": "Taiwan",
|
||||
"matched_location_name": "Taipei, Taiwan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "NVIDIA context",
|
||||
"source_type": "generic",
|
||||
"entity_match": True,
|
||||
"text": "TAIPEI-1 points to Taipei city-level placement.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "Weak city-level evidence, but the entity name and geography align.",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Taipei"
|
||||
assert candidate.confidence >= 0.55
|
||||
breakdown = candidate.suggested_registry_entry["llm_score_breakdown"]
|
||||
assert breakdown["weak_evidence_penalty"] <= 0.15
|
||||
assert breakdown["conflict_penalty"] == 0
|
||||
assert breakdown["name_location_hint"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_geocodes_city_from_entity_name_when_llm_unparseable(monkeypatch):
|
||||
def _fake_geocode(query):
|
||||
if query != "Taipei, 中国(台湾)":
|
||||
return None
|
||||
return {
|
||||
"lat": "25.033",
|
||||
"lon": "121.5654",
|
||||
"display_name": "Taipei, Taiwan",
|
||||
"address": {"city": "Taipei", "country": "Taiwan"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(llm_fallback, "_geocode_llm_city", _fake_geocode)
|
||||
client = _FakeAIProviderClient(["not a location answer", "still not json"])
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="TAIPEI-1", country="中国(台湾)"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert client.calls == 2
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Taipei"
|
||||
assert candidate.latitude == pytest.approx(25.033)
|
||||
assert candidate.longitude == pytest.approx(121.5654)
|
||||
assert "Entity name city hint" in candidate.source_note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_extracts_city_from_non_json_when_repair_fails(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "60.6065",
|
||||
"lon": "15.6355",
|
||||
"display_name": "Falun, Sweden",
|
||||
"address": {"city": "Falun", "country": "Sweden"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
[
|
||||
"DeepL Mercury 超級電腦位於瑞典的 法倫 (Falun)。",
|
||||
"still not json",
|
||||
]
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="DeepL Mercury", country="Sweden"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert client.calls == 2
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].city == "Falun"
|
||||
assert result.candidates[0].needs_confirmation is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_combines_model_score_with_evidence_score():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 51.1694,
|
||||
"longitude": 71.4491,
|
||||
"precision": "city",
|
||||
"confidence": 0.38,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Kazakhstan National Supercomputing Center",
|
||||
"url": "https://example.test/alem-cloud",
|
||||
"source_type": "official",
|
||||
"entity_match": True,
|
||||
"text": "Alem.Cloud is located in Astana.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "Evidence supports city-level location but not exact facility coordinates.",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Astana"
|
||||
assert candidate.confidence >= 0.55
|
||||
assert candidate.suggested_registry_entry["llm_model_confidence"] == pytest.approx(0.38)
|
||||
assert candidate.suggested_registry_entry["llm_combined_confidence"] == pytest.approx(
|
||||
candidate.confidence
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_rejects_low_combined_score():
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 51.1694,
|
||||
"longitude": 71.4491,
|
||||
"precision": "city",
|
||||
"confidence": 0.38,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": ["some page mentions Kazakhstan"],
|
||||
"reasoning_summary": "Weak and ambiguous city evidence.",
|
||||
"ambiguity": "weak city evidence",
|
||||
}
|
||||
)
|
||||
),
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "combined evidence score" in result.failure_reason
|
||||
assert "below minimum 0.55" in result.failure_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_rejects_explicit_conflicts():
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 25.033,
|
||||
"longitude": 121.5654,
|
||||
"precision": "city",
|
||||
"confidence": 0.70,
|
||||
"city": "Taipei",
|
||||
"country": "Taiwan",
|
||||
"matched_location_name": "Taipei, Taiwan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Conflicting source",
|
||||
"source_type": "generic",
|
||||
"entity_match": True,
|
||||
"has_conflict": True,
|
||||
"text": "One source says Taipei, another contradicts it.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "Conflicting evidence prevents confirmation.",
|
||||
}
|
||||
)
|
||||
),
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "conflict=" in result.failure_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
"not json",
|
||||
json.dumps({"latitude": 0, "longitude": 0, "precision": "city", "confidence": 0.9}),
|
||||
json.dumps({"latitude": 45, "longitude": 4, "precision": "country", "confidence": 0.9}),
|
||||
json.dumps({"latitude": 45, "longitude": 4, "precision": "city", "confidence": 0.2}),
|
||||
],
|
||||
)
|
||||
async def test_llm_location_fallback_rejects_unsafe_outputs(content):
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(content),
|
||||
query=LocationQuery(name="Unsafe", country="France"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert result.failure_reason
|
||||
assert result.attempted_queries == ["llm_factcheck:compute_center:Unsafe"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_failure_explains_rejection_reason():
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(
|
||||
json.dumps({
|
||||
"latitude": 45,
|
||||
"longitude": 4,
|
||||
"precision": "region",
|
||||
"confidence": 0.9,
|
||||
})
|
||||
),
|
||||
query=LocationQuery(name="Unsafe", country="France"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "precision" in result.failure_reason
|
||||
assert "region" in result.failure_reason
|
||||
|
||||
242
backend/tests/test_motion_agent.py
Normal file
242
backend/tests/test_motion_agent.py
Normal file
@@ -0,0 +1,242 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from motion_agent.cameras import (
|
||||
MotionAgentCameraError,
|
||||
MotionAgentDependencyError,
|
||||
UrlCameraInput,
|
||||
UrlCameraSpec,
|
||||
UsbCameraInput,
|
||||
UsbCameraSpec,
|
||||
)
|
||||
import motion_agent.cameras as motion_cameras
|
||||
from motion_agent.config import MotionAgentConfig
|
||||
from motion_agent.events import GestureEvent, HeartbeatEvent, SkeletonEvent, SkeletonJoint
|
||||
from motion_agent.recognizer import GestureObservation
|
||||
from motion_agent.server import MotionAgentServer
|
||||
from motion_agent.state import GestureStateMachine
|
||||
from motion_agent import cli as motion_cli
|
||||
|
||||
|
||||
def test_gesture_event_serializes_stable_protocol_fields():
|
||||
event = GestureEvent(
|
||||
gesture="rotate_left",
|
||||
confidence=0.91,
|
||||
intensity=0.75,
|
||||
timestamp_ms=1000,
|
||||
seq=7,
|
||||
mode="single",
|
||||
)
|
||||
|
||||
payload = json.loads(event.to_json())
|
||||
|
||||
assert payload["type"] == "gesture"
|
||||
assert payload["gesture"] == "rotate_left"
|
||||
assert payload["phase"] == "discrete"
|
||||
assert payload["confidence"] == 0.91
|
||||
assert payload["intensity"] == 0.75
|
||||
assert payload["timestamp_ms"] == 1000
|
||||
assert payload["seq"] == 7
|
||||
assert payload["source"] == "motion-agent"
|
||||
assert payload["mode"] == "single"
|
||||
assert payload["payload"] == {}
|
||||
|
||||
|
||||
def test_state_machine_ignores_low_confidence_observations():
|
||||
state = GestureStateMachine(confidence_threshold=0.8, cooldown_ms=400)
|
||||
|
||||
event = state.accept(
|
||||
GestureObservation(
|
||||
gesture="confirm",
|
||||
confidence=0.79,
|
||||
intensity=1,
|
||||
timestamp_ms=1000,
|
||||
)
|
||||
)
|
||||
|
||||
assert event is None
|
||||
|
||||
|
||||
def test_state_machine_applies_per_gesture_cooldown():
|
||||
state = GestureStateMachine(confidence_threshold=0.7, cooldown_ms=400)
|
||||
|
||||
first = state.accept(
|
||||
GestureObservation("rotate_right", confidence=0.9, intensity=0.8, timestamp_ms=1000)
|
||||
)
|
||||
repeated = state.accept(
|
||||
GestureObservation("rotate_right", confidence=0.95, intensity=0.9, timestamp_ms=1200)
|
||||
)
|
||||
later = state.accept(
|
||||
GestureObservation("rotate_right", confidence=0.95, intensity=0.9, timestamp_ms=1500)
|
||||
)
|
||||
|
||||
assert first is not None
|
||||
assert first.seq == 1
|
||||
assert repeated is None
|
||||
assert later is not None
|
||||
assert later.seq == 2
|
||||
|
||||
|
||||
def test_motion_server_status_includes_dry_run_camera_and_heartbeat():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
|
||||
status = json.loads(server.status_event().to_json())
|
||||
heartbeat = json.loads(HeartbeatEvent(timestamp_ms=123).to_json())
|
||||
|
||||
assert status["type"] == "status"
|
||||
assert status["camera_count"] == 1
|
||||
assert status["active_camera_ids"] == ["dry-run:null-camera"]
|
||||
assert status["recognizer"] == "dry-run"
|
||||
assert heartbeat == {
|
||||
"timestamp_ms": 123,
|
||||
"source": "motion-agent",
|
||||
"type": "heartbeat",
|
||||
}
|
||||
|
||||
|
||||
def test_skeleton_event_serializes_without_raw_image_fields():
|
||||
event = SkeletonEvent(
|
||||
joints=[SkeletonJoint("left_wrist", 0.42, 0.61, 0.98)],
|
||||
bones=[("left_shoulder", "left_elbow"), ("left_elbow", "left_wrist")],
|
||||
matched_gesture="rotate_left",
|
||||
confidence=0.91,
|
||||
camera_id="usb:0",
|
||||
timestamp_ms=1000,
|
||||
mode="single",
|
||||
)
|
||||
|
||||
payload = json.loads(event.to_json())
|
||||
|
||||
assert payload["type"] == "skeleton"
|
||||
assert payload["matched_gesture"] == "rotate_left"
|
||||
assert payload["confidence"] == 0.91
|
||||
assert payload["camera_id"] == "usb:0"
|
||||
assert payload["joints"] == [
|
||||
{"id": "left_wrist", "x": 0.42, "y": 0.61, "confidence": 0.98}
|
||||
]
|
||||
assert payload["bones"] == [["left_shoulder", "left_elbow"], ["left_elbow", "left_wrist"]]
|
||||
assert "image" not in payload
|
||||
assert "frame" not in payload
|
||||
|
||||
|
||||
def test_dry_run_recognizer_produces_debug_skeleton():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
|
||||
skeleton = server.recognizer.debug_skeleton(
|
||||
None,
|
||||
camera_id="dry-run:null-camera",
|
||||
mode="single",
|
||||
)
|
||||
|
||||
assert skeleton is not None
|
||||
assert skeleton.type == "skeleton"
|
||||
assert skeleton.camera_id == "dry-run:null-camera"
|
||||
assert skeleton.joints
|
||||
assert skeleton.bones
|
||||
|
||||
|
||||
class ServerRecognizerStub:
|
||||
name = "stub"
|
||||
|
||||
def recognize(self, frame):
|
||||
_ = frame
|
||||
return None
|
||||
|
||||
def debug_skeleton(self, frame, **kwargs):
|
||||
_ = frame, kwargs
|
||||
return None
|
||||
|
||||
|
||||
def test_motion_server_prefers_camera_urls_over_usb_indexes():
|
||||
server = MotionAgentServer(
|
||||
MotionAgentConfig(
|
||||
dry_run=False,
|
||||
camera_indexes=(0,),
|
||||
camera_urls=("rtsp://camera.example/live", "http://camera.example/video"),
|
||||
),
|
||||
recognizer=ServerRecognizerStub(),
|
||||
)
|
||||
|
||||
assert [camera.camera_id for camera in server.cameras] == ["url:0", "url:1"]
|
||||
assert all(isinstance(camera, UrlCameraInput) for camera in server.cameras)
|
||||
|
||||
|
||||
def test_usb_camera_reports_missing_opencv_as_readable_dependency_error(monkeypatch):
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
original_exists = motion_cameras.Path.exists
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "cv2":
|
||||
raise ImportError("cv2 missing")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
monkeypatch.setattr(
|
||||
motion_cameras.Path,
|
||||
"exists",
|
||||
lambda self: True if str(self) in {"/dev", "/dev/video0"} else original_exists(self),
|
||||
)
|
||||
camera = UsbCameraInput(UsbCameraSpec(index=0))
|
||||
|
||||
with pytest.raises(MotionAgentDependencyError, match="Add opencv-python with uv"):
|
||||
camera.open()
|
||||
|
||||
|
||||
def test_usb_camera_reports_missing_device_before_opencv_noise(monkeypatch):
|
||||
original_exists = motion_cameras.Path.exists
|
||||
|
||||
monkeypatch.setattr(
|
||||
motion_cameras.Path,
|
||||
"exists",
|
||||
lambda self: True if str(self) == "/dev" else False if str(self) == "/dev/video0" else original_exists(self),
|
||||
)
|
||||
camera = UsbCameraInput(UsbCameraSpec(index=0))
|
||||
|
||||
with pytest.raises(MotionAgentCameraError, match="/dev/video0"):
|
||||
camera.open()
|
||||
|
||||
|
||||
def test_url_camera_reports_unreachable_stream(monkeypatch):
|
||||
class BrokenCapture:
|
||||
def __init__(self, _url):
|
||||
pass
|
||||
|
||||
def isOpened(self):
|
||||
return False
|
||||
|
||||
class Cv2Stub:
|
||||
VideoCapture = BrokenCapture
|
||||
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "cv2":
|
||||
return Cv2Stub()
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
camera = UrlCameraInput(UrlCameraSpec(url="rtsp://camera.example/live"))
|
||||
|
||||
with pytest.raises(MotionAgentCameraError, match="Unable to open camera URL"):
|
||||
camera.open()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_motion_agent_cli_reports_dependency_error_without_traceback(monkeypatch, capsys):
|
||||
class BrokenServer:
|
||||
def __init__(self, _config):
|
||||
raise MotionAgentDependencyError("missing cv stack")
|
||||
|
||||
monkeypatch.setattr(motion_cli, "MotionAgentServer", BrokenServer)
|
||||
|
||||
exit_code = await motion_cli.async_main([])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 2
|
||||
assert "Motion agent failed: missing cv stack" in captured.err
|
||||
assert "Traceback" not in captured.err
|
||||
169
backend/tests/test_settings_ai_provider.py
Normal file
169
backend/tests/test_settings_ai_provider.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import settings as settings_api
|
||||
from app.api.v1.settings import (
|
||||
AIProviderIntegrationUpdate,
|
||||
_build_ai_provider_payload,
|
||||
_mask_secret,
|
||||
_normalize_ai_provider_payload,
|
||||
_resolve_provider_api_key,
|
||||
get_runtime_ai_provider_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_ai_provider_env_file(monkeypatch, tmp_path):
|
||||
env_file = tmp_path / ".env"
|
||||
monkeypatch.setattr(settings_api, "AI_PROVIDER_ENV_FILE", env_file)
|
||||
return env_file
|
||||
|
||||
|
||||
def test_legacy_ai_provider_payload_maps_to_provider_config():
|
||||
payload = _normalize_ai_provider_payload(
|
||||
{
|
||||
"provider": "openai",
|
||||
"provider_api": "openai-completions",
|
||||
"base_url": "https://api.openai.example/v1",
|
||||
"model": "gpt-test",
|
||||
"api_key": "old-openai-key",
|
||||
"max_tokens": 2048,
|
||||
"anthropic_version": "2023-06-01",
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["default_provider"] == "openai"
|
||||
assert payload["providers"]["openai"]["api_key"] == "old-openai-key"
|
||||
assert payload["providers"]["openai"]["model"] == "gpt-test"
|
||||
assert payload["providers"]["openai"]["base_url"] == "https://api.openai.example/v1"
|
||||
|
||||
|
||||
def test_provider_key_prefers_specific_env_file_key(isolated_ai_provider_env_file):
|
||||
isolated_ai_provider_env_file.write_text(
|
||||
"OPENAI_API_KEY=openai-env-file-key\nAI_API_KEY=generic-env-file-key\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
value, source = _resolve_provider_api_key("openai", {"api_key": ""})
|
||||
|
||||
assert value == "openai-env-file-key"
|
||||
assert source == "env_file"
|
||||
|
||||
|
||||
def test_provider_key_falls_back_to_generic_ai_api_key(isolated_ai_provider_env_file):
|
||||
isolated_ai_provider_env_file.write_text(
|
||||
"AI_API_KEY=generic-env-file-key\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
value, source = _resolve_provider_api_key("openai", {"api_key": ""})
|
||||
|
||||
assert value == "generic-env-file-key"
|
||||
assert source == "env_file"
|
||||
|
||||
|
||||
def test_mask_secret_without_prefix_is_fully_masked():
|
||||
assert _mask_secret("plainsecret")["preview"] == "***********"
|
||||
assert _mask_secret("sk-prefixed")["preview"] == "sk-********"
|
||||
|
||||
|
||||
def test_build_payload_updates_only_selected_provider_key():
|
||||
current = {
|
||||
"ai_provider": {
|
||||
"default_provider": "openai",
|
||||
"providers": {
|
||||
"openai": {
|
||||
"provider": "openai",
|
||||
"provider_api": "openai-completions",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model": "gpt-old",
|
||||
"api_key": "openai-old-key",
|
||||
"max_tokens": 4096,
|
||||
"anthropic_version": "2023-06-01",
|
||||
},
|
||||
"minimax": {
|
||||
"provider": "minimax",
|
||||
"api_key": "minimax-old-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
update = AIProviderIntegrationUpdate(
|
||||
provider="openai",
|
||||
provider_api="openai-completions",
|
||||
base_url="https://api.openai.com/v1",
|
||||
model="gpt-new",
|
||||
api_key="openai-new-key",
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
payload = _build_ai_provider_payload(current, update)
|
||||
|
||||
assert payload["default_provider"] == "openai"
|
||||
assert payload["providers"]["openai"]["api_key"] == "openai-new-key"
|
||||
assert payload["providers"]["openai"]["model"] == "gpt-new"
|
||||
assert payload["providers"]["minimax"]["api_key"] == "minimax-old-key"
|
||||
|
||||
|
||||
def test_build_payload_keeps_saved_key_when_preview_submitted():
|
||||
current = {
|
||||
"ai_provider": {
|
||||
"providers": {
|
||||
"openai": {
|
||||
"provider": "openai",
|
||||
"api_key": "sk-old-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
update = AIProviderIntegrationUpdate(
|
||||
provider="openai",
|
||||
provider_api="openai-completions",
|
||||
base_url="https://api.openai.com/v1",
|
||||
model="gpt-test",
|
||||
api_key="sk-*********",
|
||||
)
|
||||
|
||||
payload = _build_ai_provider_payload(current, update)
|
||||
|
||||
assert payload["providers"]["openai"]["api_key"] == "sk-old-secret"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_config_uses_default_provider_specific_key(monkeypatch):
|
||||
record = SimpleNamespace(
|
||||
payload={
|
||||
"ai_provider": {
|
||||
"default_provider": "minimax",
|
||||
"providers": {
|
||||
"openai": {
|
||||
"provider": "openai",
|
||||
"api_key": "openai-key",
|
||||
"provider_api": "openai-completions",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model": "gpt-test",
|
||||
},
|
||||
"minimax": {
|
||||
"provider": "minimax",
|
||||
"api_key": "minimax-key",
|
||||
"provider_api": "anthropic-messages",
|
||||
"base_url": "https://api.minimaxi.com/anthropic",
|
||||
"model": "MiniMax-test",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async def fake_get_setting_record(_db, category):
|
||||
assert category == "external_integrations"
|
||||
return record
|
||||
|
||||
monkeypatch.setattr(settings_api, "get_setting_record", fake_get_setting_record)
|
||||
|
||||
runtime_config = await get_runtime_ai_provider_config(object())
|
||||
|
||||
assert runtime_config["llm_config"]["provider"] == "minimax"
|
||||
assert runtime_config["llm_config"]["api_key"] == "minimax-key"
|
||||
assert runtime_config["llm_config"]["model"] == "MiniMax-test"
|
||||
@@ -1,9 +1,14 @@
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api.v1.visualization import convert_compute_centers_to_geojson
|
||||
from app.api.v1 import visualization as visualization_api
|
||||
from app.api.v1.visualization import (
|
||||
CollectComputeCenterLocationRequest,
|
||||
convert_compute_centers_to_geojson,
|
||||
)
|
||||
import app.services.compute_center_locations as compute_center_locations
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
@@ -498,6 +503,102 @@ def test_collect_location_candidates_failure_returns_attempted_queries(monkeypat
|
||||
assert attempted, "even on failure we record attempted queries for diagnostics"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_compute_center_location_skips_llm_when_candidates_exist(monkeypatch):
|
||||
candidate = compute_center_locations.LocationCandidate(
|
||||
latitude=45.764,
|
||||
longitude=4.8357,
|
||||
display_name="Lyon",
|
||||
precision="city",
|
||||
confidence=0.62,
|
||||
query="Lyon, France",
|
||||
source="nominatim_online_geocode",
|
||||
source_note="fixture",
|
||||
matched_fields=("city", "country"),
|
||||
needs_confirmation=True,
|
||||
city="Lyon",
|
||||
country="France",
|
||||
)
|
||||
monkeypatch.setattr(visualization_api, "_load_compute_center_record", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
visualization_api,
|
||||
"collect_location_candidates",
|
||||
lambda **_kwargs: ([candidate], ["Lyon, France"]),
|
||||
)
|
||||
|
||||
async def _explode(**_kwargs):
|
||||
raise AssertionError("LLM fallback should not run when a normal candidate exists")
|
||||
|
||||
monkeypatch.setattr(visualization_api, "collect_llm_location_fallback_candidate", _explode)
|
||||
|
||||
response = await visualization_api.collect_compute_center_location(
|
||||
"epoch_ai_gpu-test",
|
||||
CollectComputeCenterLocationRequest(
|
||||
name="Mystery Cluster",
|
||||
source="epoch_ai_gpu",
|
||||
city="Lyon",
|
||||
country="France",
|
||||
),
|
||||
db=AsyncMock(),
|
||||
)
|
||||
|
||||
assert response["success"] is True
|
||||
assert response["best_candidate"]["source"] == "nominatim_online_geocode"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_compute_center_location_uses_llm_when_candidates_empty(monkeypatch):
|
||||
llm_candidate = compute_center_locations.LocationCandidate(
|
||||
latitude=45.764,
|
||||
longitude=4.8357,
|
||||
display_name="Lyon, France",
|
||||
precision="city",
|
||||
confidence=0.74,
|
||||
query="llm_factcheck:compute_center:Mystery Cluster",
|
||||
source="llm_location_factcheck",
|
||||
source_note="LLM location factcheck fallback",
|
||||
matched_fields=("name",),
|
||||
needs_confirmation=True,
|
||||
city="Lyon",
|
||||
country="France",
|
||||
)
|
||||
monkeypatch.setattr(visualization_api, "_load_compute_center_record", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
visualization_api,
|
||||
"collect_location_candidates",
|
||||
lambda **_kwargs: ([], ["Mystery Cluster, France"]),
|
||||
)
|
||||
|
||||
from app.services.location.llm_fallback import LocationLLMFallbackResult
|
||||
|
||||
async def _fallback(**_kwargs):
|
||||
return LocationLLMFallbackResult(
|
||||
candidates=[llm_candidate],
|
||||
attempted_queries=["llm_factcheck:compute_center:Mystery Cluster"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(visualization_api, "get_ai_provider_client", AsyncMock(return_value=object()))
|
||||
monkeypatch.setattr(visualization_api, "collect_llm_location_fallback_candidate", _fallback)
|
||||
|
||||
response = await visualization_api.collect_compute_center_location(
|
||||
"epoch_ai_gpu-test",
|
||||
CollectComputeCenterLocationRequest(
|
||||
name="Mystery Cluster",
|
||||
source="epoch_ai_gpu",
|
||||
country="France",
|
||||
),
|
||||
db=AsyncMock(),
|
||||
)
|
||||
|
||||
assert response["success"] is True
|
||||
assert response["best_candidate"]["source"] == "llm_location_factcheck"
|
||||
assert response["best_candidate"]["needs_confirmation"] is True
|
||||
assert response["attempted_queries"] == [
|
||||
"Mystery Cluster, France",
|
||||
"llm_factcheck:compute_center:Mystery Cluster",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_centers_geojson_endpoint_returns_stats():
|
||||
records = [
|
||||
|
||||
Reference in New Issue
Block a user