"""Tests for the shared location resolution pipeline. Validates the abstraction itself: the protocol contract, the orchestrator, each built-in resolver, and the pluggability promise (a custom resolver can be slotted in without touching consumers). """ from __future__ import annotations import json from pathlib import Path import pytest from app.services.location import ( InheritFromAnotherEntityResolver, LocationCandidate, LocationPipeline, LocationQuery, NominatimResolver, RegistryResolver, 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 ──────────────────────────────────────────────────── @pytest.fixture def tmp_registry(tmp_path: Path) -> Path: payload = { "locations": [ { "canonical_name": "Test Site Alpha", "aliases": ["alpha", "alpha-one", "Acme HQ"], "operator": "Acme Networks", "site": "Acme HQ", "city": "Lyon", "country": "France", "latitude": 45.764, "longitude": 4.8357, "precision": "site", "confidence": 0.92, "source_note": "Test fixture", "verified_at": "2026-05-08", }, { "canonical_name": "Test Site Bravo", "aliases": ["bravo"], "operator": "Acme Networks", "site": "Bravo POP", "city": "Berlin", "country": "Germany", "latitude": 52.52, "longitude": 13.405, "precision": "city", "confidence": 0.85, }, ], "city_fallbacks": [ { "city": "Bhutan-Capital", "country": "Bhutan", "latitude": 27.4728, "longitude": 89.639, "precision": "city", "confidence": 0.5, } ], } path = tmp_path / "registry.json" path.write_text(json.dumps(payload), encoding="utf-8") return path # ── SourceCoordinatesResolver ──────────────────────────────────────── def test_source_coordinates_resolver_passes_through_valid_coordinates(): resolver = SourceCoordinatesResolver() query = LocationQuery( name="Acme HQ", source_latitude=45.0, source_longitude=4.0, country="France", ) output = resolver.resolve(query) assert len(output.candidates) == 1 candidate = output.candidates[0] assert candidate.latitude == 45.0 assert candidate.longitude == 4.0 assert candidate.precision == "precise" assert candidate.source == "source_coordinates" assert candidate.needs_confirmation is False def test_source_coordinates_resolver_skips_zero_coordinates(): resolver = SourceCoordinatesResolver() output = resolver.resolve( LocationQuery(name="X", source_latitude=0.0, source_longitude=0.0) ) assert output.candidates == () def test_source_coordinates_resolver_skips_when_missing(): resolver = SourceCoordinatesResolver() output = resolver.resolve(LocationQuery(name="X")) assert output.candidates == () # ── RegistryResolver ───────────────────────────────────────────────── def test_registry_resolver_matches_alias(tmp_registry): resolver = RegistryResolver(registry_path=tmp_registry) resolver.reload() output = resolver.resolve( LocationQuery(name="alpha", country="France") ) candidates = list(output.candidates) assert candidates, "should match registry entry" assert any(c.matched_location_name == "Test Site Alpha" for c in candidates) alpha = next(c for c in candidates if c.matched_location_name == "Test Site Alpha") assert alpha.precision == "site" assert alpha.confidence == pytest.approx(0.92) assert alpha.needs_confirmation is True assert alpha.location_verified_at is None def test_registry_resolver_filters_country_mismatch(tmp_registry): resolver = RegistryResolver(registry_path=tmp_registry) resolver.reload() # alpha is in France; query says Spain → should reject output = resolver.resolve( LocationQuery(name="alpha", country="Spain") ) assert all( c.matched_location_name != "Test Site Alpha" for c in output.candidates ) def test_registry_resolver_emits_city_fallback_candidate(tmp_registry): resolver = RegistryResolver(registry_path=tmp_registry) resolver.reload() output = resolver.resolve( LocationQuery(city="Bhutan-Capital", country="Bhutan") ) candidates = list(output.candidates) assert candidates, "city fallback should fire" assert any(c.source == "local_registry_city" for c in candidates) # ── NominatimResolver ─────────────────────────────────────────────── def test_nominatim_resolver_calls_geocoder_with_plan_queries(): calls = [] def fake_geocoder(query: str): calls.append(query) return { "lat": "12.34", "lon": "56.78", "display_name": "Test City, Country", "address": {"city": "Test City", "country": "Country"}, } def plan(query: LocationQuery): return [ ("primary query", ("name",)), ("secondary query", ("city",)), ] resolver = NominatimResolver( query_plan_builder=plan, geocoder=fake_geocoder, ) output = resolver.resolve(LocationQuery(name="X", country="Country")) assert calls == ["primary query", "secondary query"] assert output.attempted_queries == ("primary query", "secondary query") assert len(output.candidates) == 2 assert all(c.precision == "city" for c in output.candidates) assert all(c.needs_confirmation for c in output.candidates) def test_nominatim_resolver_skips_when_geocoder_returns_none(): resolver = NominatimResolver( query_plan_builder=lambda q: [("only", ("name",))], geocoder=lambda q: None, ) output = resolver.resolve(LocationQuery(name="X")) assert output.candidates == () assert output.attempted_queries == ("only",) def test_nominatim_resolver_swallows_exceptions_per_query(): def boom(query): raise RuntimeError("network down") resolver = NominatimResolver( query_plan_builder=lambda q: [("a", ()), ("b", ())], geocoder=boom, ) output = resolver.resolve(LocationQuery(name="X")) assert output.candidates == () assert output.attempted_queries == ("a", "b") # ── InheritFromAnotherEntityResolver ──────────────────────────────── def test_inherit_resolver_returns_provided_candidate(): sentinel = LocationCandidate( latitude=10.0, longitude=20.0, display_name="Inherited", precision="city", confidence=0.7, query="inherit::test", source="inherited", source_note=None, matched_fields=("collector",), needs_confirmation=False, ) resolver = InheritFromAnotherEntityResolver( source_lookup=lambda q: sentinel ) output = resolver.resolve(LocationQuery(name="X")) assert output.candidates == (sentinel,) def test_inherit_resolver_skips_when_lookup_returns_none(): resolver = InheritFromAnotherEntityResolver(source_lookup=lambda q: None) assert resolver.resolve(LocationQuery(name="X")).candidates == () # ── LocationPipeline orchestration ────────────────────────────────── def test_pipeline_aggregates_candidates_across_resolvers(tmp_registry): pipeline = LocationPipeline( [ SourceCoordinatesResolver(), RegistryResolver(registry_path=tmp_registry), NominatimResolver( query_plan_builder=lambda q: [("nominatim attempt", ("name",))], geocoder=lambda q: { "lat": "1.0", "lon": "2.0", "display_name": "Online City", "address": {"city": "Online City", "country": "France"}, }, ), ] ) pipeline.resolvers[1].reload() candidates, attempted = pipeline.collect_candidates( LocationQuery( name="alpha", country="France", source_latitude=44.0, source_longitude=5.0, ) ) sources = {c.source for c in candidates} assert "source_coordinates" in sources assert "local_registry" in sources assert "nominatim_online_geocode" in sources assert "nominatim attempt" in attempted def test_pipeline_dedupes_by_source_and_coordinates(): same = LocationCandidate( latitude=1.0, longitude=2.0, display_name="dup", precision="city", confidence=0.5, query="x", source="dup_source", source_note=None, matched_fields=(), needs_confirmation=False, ) class _DupResolver: name = "dup_source" def resolve(self, query): return ResolverOutput(candidates=(same, same)) pipeline = LocationPipeline([_DupResolver()]) candidates, _ = pipeline.collect_candidates(LocationQuery(name="X")) assert len(candidates) == 1 def test_registry_short_aliases_do_not_match_inside_larger_tokens(tmp_path: Path): registry_path = tmp_path / "registry.json" registry_path.write_text( json.dumps( { "locations": [ { "canonical_name": "Aurora", "aliases": ["Aurora", "ANL"], "site": "DOE/SC/Argonne National Laboratory", "country": "United States", "city": "Lemont", "latitude": 41.713, "longitude": -87.982, "precision": "site", }, { "canonical_name": "Venado", "aliases": ["Venado"], "site": "DOE/NNSA/LANL", "country": "United States", "city": "Los Alamos", "latitude": 35.8443, "longitude": -106.2872, "precision": "site", }, ], "city_fallbacks": [], } ), encoding="utf-8", ) resolver = RegistryResolver(registry_path=registry_path) resolver.reload() output = resolver.resolve( LocationQuery( name="Venado", country="United States", extra={"site": "DOE/NNSA/LANL"}, ) ) assert len(output.candidates) == 1 assert output.candidates[0].matched_location_name == "Venado" def test_pipeline_resolve_best_returns_highest_priority(): online = LocationCandidate( latitude=10.0, longitude=20.0, display_name="online", precision="city", confidence=0.9, query="x", source="nominatim_online_geocode", source_note=None, matched_fields=(), needs_confirmation=True, ) source = LocationCandidate( latitude=11.0, longitude=21.0, display_name="src", precision="precise", confidence=1.0, query="x", source="source_coordinates", source_note=None, matched_fields=(), needs_confirmation=False, ) class _StubResolver: def __init__(self, c, name): self._c = c self.name = name def resolve(self, query): return ResolverOutput(candidates=(self._c,)) pipeline = LocationPipeline( [ _StubResolver(online, "online"), _StubResolver(source, "src"), ] ) result = pipeline.resolve_best(LocationQuery(name="X")) assert result.location is source, "source_coordinates should beat nominatim" def test_pipeline_returns_diagnostic_when_nothing_resolves(): pipeline = LocationPipeline([SourceCoordinatesResolver()]) result = pipeline.resolve_best(LocationQuery(name="X", country="Bhutan")) assert result.location is None assert result.diagnostic is not None assert result.diagnostic.country == "Bhutan" def test_pluggability_custom_resolver_works_without_changing_pipeline(): """Validates the abstraction promise: a new algorithm = a new class.""" class _PeeringDBStubResolver: name = "fake_peeringdb" def resolve(self, query): asn = (query.extra or {}).get("asn") if asn != 174: return ResolverOutput() return ResolverOutput( candidates=( LocationCandidate( latitude=1.0, longitude=2.0, display_name="Cogent HQ", precision="site", confidence=0.8, query=f"peeringdb::{asn}", source="peeringdb_stub", source_note="Stub for testing", matched_fields=("asn",), needs_confirmation=False, ), ) ) pipeline = LocationPipeline([_PeeringDBStubResolver()]) candidates, _ = pipeline.collect_candidates( LocationQuery(name="X", extra={"asn": 174}) ) 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