"""LLM-backed fallback candidate generation for hard-to-resolve locations.""" from __future__ import annotations import json import re from dataclasses import dataclass from typing import Any, Iterable from sqlalchemy.ext.asyncio import AsyncSession from app.core.logging import get_logger from app.core.countries import COUNTRY_ENTRIES, normalize_country from app.schemas.ai import SituationalAnalysisRequest from app.ai_tasks.prompts import get_effective_prompt from app.services.ai_client import AIProviderClient from app.services.ai_tools.evidence_store import normalize_search_evidence from app.services.ai_tools.web_search import WebSearchClient, WebSearchError from app.services.location.models import LocationCandidate, LocationQuery from app.services.location.resolvers.nominatim import build_default_nominatim_geocoder from app.services.location.text import ( coerce_str, normalize_country_text, normalize_text, parse_float, ) VALID_LLM_PRECISIONS = {"precise", "site", "city"} DEFAULT_MIN_CONFIDENCE = 0.55 LOCATION_NORMALIZE_PROMPT_KEY = "location.factcheck.normalize" LOCATION_RESOLVE_PROMPT_KEY = "location.factcheck.resolve" MODEL_CONFIDENCE_WEIGHT = 0.25 LOG_TEXT_LIMIT = 1200 LOG_EVIDENCE_LIMIT = 5 logger = get_logger(__name__, service="location") _geocode_llm_city = build_default_nominatim_geocoder() _LLM_LOCATION_NAME_KEYS = ( "matched_location_name", "display_name", "location_name", "location", "place", "city", ) _NAME_HINT_STOPWORDS = { "ai", "cloud", "cluster", "compute", "computer", "gpu", "hpc", "mercury", "phase", "super", "supercomputer", } LLM_PRECISION_ALIASES = { "precise": "precise", "exact": "precise", "coordinate": "precise", "coordinates": "precise", "site": "site", "site level": "site", "site-level": "site", "site_level": "site", "facility": "site", "facility level": "site", "city": "city", "city level": "city", "city-level": "city", "city_level": "city", } @dataclass(frozen=True) class LocationLLMFallbackResult: candidates: list[LocationCandidate] attempted_queries: list[str] failure_reason: str | None = None @dataclass(frozen=True) class LocationSearchEvidenceResult: evidence: list[dict[str, Any]] attempted_queries: list[str] failure_reason: str | None = None @dataclass(frozen=True) class LocationEvidenceScore: score: float model_confidence: float source_quality: float entity_match: float geography_match: float precision_quality: float conflict_penalty: float weak_evidence_penalty: float name_location_hint: float summary: str def _truncate_log_text(value: Any, limit: int = LOG_TEXT_LIMIT) -> str: text = coerce_str(value) if len(text) <= limit: return text return f"{text[:limit]}…" def _summarize_search_evidence(evidence: list[dict[str, Any]] | None) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] for item in (evidence or [])[:LOG_EVIDENCE_LIMIT]: if not isinstance(item, dict): continue items.append( { "title": _truncate_log_text(item.get("title"), 180), "source": _truncate_log_text(item.get("source") or item.get("name"), 120), "url": _truncate_log_text(item.get("url"), 240), "snippet": _truncate_log_text( item.get("snippet") or item.get("content") or item.get("text") or item.get("summary"), 360, ), } ) return items def _first_json_object(text: str) -> dict[str, Any] | None: stripped = text.strip() if not stripped: return None if stripped.startswith("```"): stripped = re.sub(r"^```(?:json)?\s*", "", stripped, flags=re.IGNORECASE) stripped = re.sub(r"\s*```$", "", stripped) try: data = json.loads(stripped) return data if isinstance(data, dict) else None except json.JSONDecodeError: pass start = stripped.find("{") end = stripped.rfind("}") if start < 0 or end <= start: return None try: data = json.loads(stripped[start : end + 1]) except json.JSONDecodeError: return None return data if isinstance(data, dict) else None def _compact_evidence(value: Any) -> str: if isinstance(value, list): parts = [_evidence_label(item) for item in value if _evidence_label(item)] return "; ".join(parts[:3]) return coerce_str(value) def _evidence_items(value: Any) -> list[dict[str, Any]]: if isinstance(value, list): raw_items = value elif value in (None, ""): raw_items = [] else: raw_items = [value] items: list[dict[str, Any]] = [] for item in raw_items: if isinstance(item, dict): items.append(dict(item)) else: text = coerce_str(item) if text: items.append({"text": text}) return items def _evidence_label(item: Any) -> str: if isinstance(item, dict): source = coerce_str(item.get("source") or item.get("title") or item.get("name")) url = coerce_str(item.get("url")) text = coerce_str(item.get("text") or item.get("quote") or item.get("summary")) if source and url: return f"{source} ({url})" if source: return source if url: return url return text return coerce_str(item) def _evidence_text(item: dict[str, Any]) -> str: return " ".join( coerce_str(item.get(key)) for key in ("title", "source", "name", "url", "snippet", "content", "text", "quote", "summary") if coerce_str(item.get(key)) ) def _search_evidence_entity_match(item: dict[str, Any], query: LocationQuery) -> bool: haystack = normalize_text(_evidence_text(item)) if not haystack: return False needles = [ coerce_str(query.name), *[coerce_str(alias) for alias in query.aliases], ] return any(normalize_text(needle) and normalize_text(needle) in haystack for needle in needles) def _evidence_has_location_assertion(item: dict[str, Any], city: str) -> bool: normalized_city = normalize_text(city) text = normalize_text(_evidence_text(item)) if not normalized_city or normalized_city not in text: return False assertion_terms = ( "located", "situated", "built", "hosted", "deployed", "installed", "facility", "campus", "site", "data center", "datacenter", "supercomputer center", "位于", "位於", "坐落", "建置", "設置", "设置", ) return any(term in text for term in assertion_terms) def _city_is_unsupported_name_hint(payload: dict[str, Any], query: LocationQuery, evidence_items: list[dict[str, Any]]) -> bool: city = coerce_str(payload.get("city") or query.city) if not city: return False normalized_city = normalize_text(city) normalized_name = normalize_text(query.name) if not normalized_city or not normalized_name or normalized_city not in normalized_name: return False return not any(_evidence_has_location_assertion(item, city) for item in evidence_items) def _normalize_llm_precision(value: Any) -> str: text = coerce_str(value).lower() return LLM_PRECISION_ALIASES.get(text, text) def _detect_country_in_text(text: str) -> str: normalized_text = normalize_text(text) if not normalized_text: return "" for canonical, aliases in COUNTRY_ENTRIES: variants = [canonical, *aliases] for variant in variants: normalized_variant = normalize_text(variant) if normalized_variant and normalized_variant in normalized_text: return canonical return "" def _extract_city_from_text(text: str, *, country: str | None = None) -> str: patterns = [ r"\(([^()]{2,80})\)", r"\blocated\s+(?:in|at)\s+([^,.;()\n]{2,80})(?:,\s*([^.;()\n]{2,80}))?", r"\bbased\s+in\s+([^,.;()\n]{2,80})(?:,\s*([^.;()\n]{2,80}))?", r"\b位[于於]\s*(?:[^,。;;\n]{0,40}?的\s*)?([^,。;;()\n]{2,40})", ] normalized_country = normalize_text(country) for pattern in patterns: match = re.search(pattern, text, flags=re.IGNORECASE) if not match: continue for group in match.groups(): candidate = coerce_str(group) if not candidate: continue candidate = re.sub(r"^(?:the\s+city\s+of|city\s+of)\s+", "", candidate, flags=re.I) candidate = candidate.strip(" -–—::,,。.;;") if not candidate: continue if normalized_country and normalize_text(candidate) == normalized_country: continue if normalize_country(candidate): continue return candidate return "" def _payload_from_free_text(text: str, *, query: LocationQuery) -> dict[str, Any] | None: """Build a conservative payload when the model answered in prose. This is deliberately small: it only extracts a country and a city/place-like phrase. The normal scoring and geocoding gates still decide whether the result can become a candidate. """ if not coerce_str(text): return None country = _detect_country_in_text(text) or normalize_country_text(query.country) city = _extract_city_from_text(text, country=country) if not city or not country: return None evidence_text = " ".join(coerce_str(text).split())[:500] return { "precision": "city", "confidence": 0.55, "city": city, "country": country, "matched_location_name": f"{city}, {country}", "evidence": [ { "source": "LLM prose location factcheck", "source_type": "generic", "entity_match": bool( normalize_text(query.name) and normalize_text(query.name) in normalize_text(text) ), "text": evidence_text, } ], "reasoning_summary": "Location extracted from a non-JSON LLM answer.", "parse_strategy": "free_text_location_extraction", } def _query_name_city_terms(query: LocationQuery) -> list[str]: values = [ query.name, *query.aliases, (query.extra or {}).get("site"), ] terms: list[str] = [] seen: set[str] = set() for value in values: text = coerce_str(value) if not text: continue for raw_token in re.findall(r"[A-Za-z][A-Za-z.'-]{2,}|[\u4e00-\u9fff]{2,}", text): token = raw_token.strip(" .'-") key = normalize_text(token) if not key or key in seen or key in _NAME_HINT_STOPWORDS: continue seen.add(key) terms.append(token.title() if token.isupper() else token) return terms[:5] def _payload_from_query_name_geocode(query: LocationQuery) -> dict[str, Any] | None: """Use entity-name city hints only after LLM parsing fails. The hint is accepted only when the derived term geocodes to a city-like result in the query country. This keeps names such as "MUSICA Phase 1" from becoming arbitrary coordinates while allowing "TAIPEI-1" -> Taipei. """ country = normalize_country_text(query.country) if not country: return None for term in _query_name_city_terms(query): geocode_query = f"{term}, {country}" try: result = _geocode_llm_city(geocode_query) except Exception: continue if not isinstance(result, dict): continue latitude = parse_float(result.get("lat")) longitude = parse_float(result.get("lon")) if latitude in (None, 0.0) or longitude in (None, 0.0): continue address = result.get("address") if isinstance(result.get("address"), dict) else {} city = ( address.get("city") or address.get("town") or address.get("village") or address.get("municipality") or address.get("suburb") ) result_country = normalize_country_text(address.get("country") or country) if not city or normalize_text(result_country) != normalize_text(country): continue if normalize_text(term) not in normalize_text(city) and normalize_text(term) not in normalize_text(result.get("display_name")): continue return { "latitude": latitude, "longitude": longitude, "precision": "city", "confidence": 0.50, "city": city, "region": address.get("state") or address.get("region"), "country": result_country, "matched_location_name": result.get("display_name") or geocode_query, "evidence": [ { "source": "Entity name city hint", "source_type": "generic", "entity_match": True, "text": ( f"Derived city term '{term}' from entity name " f"'{coerce_str(query.name)}' and verified it by geocoding." ), } ], "reasoning_summary": "City derived from entity name after LLM parsing failed.", "parse_strategy": "query_name_city_hint", "coordinate_source": "nominatim_city_fallback", } return None def _extract_llm_coordinates(payload: dict[str, Any]) -> tuple[float | None, float | None]: latitude = parse_float( payload.get("latitude") if payload.get("latitude") not in (None, "") else payload.get("lat") ) longitude = parse_float( payload.get("longitude") if payload.get("longitude") not in (None, "") else ( payload.get("lon") if payload.get("lon") not in (None, "") else payload.get("lng") ) ) if latitude not in (None, 0.0) and longitude not in (None, 0.0): return latitude, longitude coordinates = payload.get("coordinates") or payload.get("coordinate") if isinstance(coordinates, dict): latitude = parse_float( coordinates.get("latitude") if coordinates.get("latitude") not in (None, "") else coordinates.get("lat") ) longitude = parse_float( coordinates.get("longitude") if coordinates.get("longitude") not in (None, "") else ( coordinates.get("lon") if coordinates.get("lon") not in (None, "") else coordinates.get("lng") ) ) elif isinstance(coordinates, (list, tuple)) and len(coordinates) >= 2: first = parse_float(coordinates[0]) second = parse_float(coordinates[1]) if first is not None and second is not None: # GeoJSON-style [lon, lat] is the common interchange format. longitude, latitude = first, second return latitude, longitude def _fill_city_coordinates_from_geocoder( payload: dict[str, Any], *, query: LocationQuery, ) -> tuple[dict[str, Any], str | None]: city = coerce_str(payload.get("city") or query.city) country = coerce_str(payload.get("country") or query.country) geocode_queries: list[str] = [] def add_geocode_query(value: str) -> None: cleaned = coerce_str(value) if cleaned and cleaned not in geocode_queries: geocode_queries.append(cleaned) if city and country: add_geocode_query(f"{city}, {country}") for key in _LLM_LOCATION_NAME_KEYS: value = payload.get(key) if not isinstance(value, str): continue if country and country.lower() not in value.lower(): add_geocode_query(f"{value}, {country}") add_geocode_query(value) if not geocode_queries: return payload, None failures: list[str] = [] geocode_query = "" result: dict[str, Any] | None = None for candidate_query in geocode_queries: geocode_query = candidate_query try: maybe_result = _geocode_llm_city(geocode_query) except Exception as exc: failures.append(f"{geocode_query}: {exc}") continue if not isinstance(maybe_result, dict): failures.append(f"{geocode_query}: no result") continue latitude = parse_float(maybe_result.get("lat")) longitude = parse_float(maybe_result.get("lon")) if latitude in (None, 0.0) or longitude in (None, 0.0): failures.append(f"{geocode_query}: invalid coordinates") continue result = maybe_result break if result is None: detail = "; ".join(failures[:3]) or "no usable geocode query" return payload, f"city geocode fallback found no usable result ({detail})" latitude = parse_float(result.get("lat")) longitude = parse_float(result.get("lon")) if latitude in (None, 0.0) or longitude in (None, 0.0): return payload, f"city geocode fallback returned invalid coordinates for '{geocode_query}'" address = result.get("address") if isinstance(result.get("address"), dict) else {} city = ( city or address.get("city") or address.get("town") or address.get("village") or address.get("municipality") or address.get("suburb") ) country = country or address.get("country") try: precision = _normalize_llm_precision(payload.get("precision")) or "city" except Exception: precision = "city" filled = { **payload, "latitude": latitude, "longitude": longitude, "precision": precision, "city": payload.get("city") or city, "region": payload.get("region") or address.get("state") or address.get("region"), "country": payload.get("country") or address.get("country") or country, "matched_location_name": ( payload.get("matched_location_name") or result.get("display_name") or geocode_query ), "coordinate_source": "nominatim_city_fallback", } return filled, None def _truthy_evidence_field(item: dict[str, Any], *keys: str) -> bool: for key in keys: value = item.get(key) if isinstance(value, bool): if value: return True elif coerce_str(value).lower() in {"true", "yes", "exact", "strong"}: return True return False def _source_quality_score(evidence_items: list[dict[str, Any]]) -> float: best = 0.0 for item in evidence_items: source_type = normalize_text( item.get("source_type") or item.get("type") or item.get("source_kind") or "" ) source_text = normalize_text( " ".join( [ coerce_str(item.get("source")), coerce_str(item.get("url")), coerce_str(item.get("text")), coerce_str(item.get("summary")), ] ) ) combined = f"{source_type} {source_text}" if any(token in combined for token in ("official", "government", "gov", "edu", "university")): best = max(best, 0.35) elif any(token in combined for token in ("database", "registry", "wikipedia", "news", "press")): best = max(best, 0.25) elif combined.strip(): best = max(best, 0.15) return best def _entity_match_score(payload: dict[str, Any], query: LocationQuery, evidence_items: list[dict[str, Any]]) -> float: if any( _truthy_evidence_field(item, "entity_match", "matches_entity", "name_match") for item in evidence_items ): return 0.25 names = [ query.name, *query.aliases, (query.extra or {}).get("site"), (query.extra or {}).get("operator"), (query.extra or {}).get("organization"), ] needles = [normalize_text(name) for name in names if normalize_text(name)] haystack = normalize_text( " ".join( [ coerce_str(payload.get("matched_location_name")), coerce_str(payload.get("reasoning_summary")), *[_evidence_label(item) for item in evidence_items], ] ) ) if needles and any(needle in haystack for needle in needles): return 0.25 return 0.0 def _geography_match_score(payload: dict[str, Any], query: LocationQuery) -> float: city = normalize_text(payload.get("city") or query.city) country = normalize_text(normalize_country_text(payload.get("country") or query.country)) context_country = normalize_text(normalize_country_text(query.country)) if city and country and (not context_country or country == context_country): return 0.20 if country and (not context_country or country == context_country): return 0.05 return 0.0 def _precision_quality_score(precision: str) -> float: return { "precise": 0.15, "site": 0.12, "city": 0.08, }.get(precision, 0.0) def _name_location_hint_score(payload: dict[str, Any], query: LocationQuery) -> float: query_name = normalize_text(query.name) city = normalize_text(payload.get("city") or query.city) matched_name = normalize_text(payload.get("matched_location_name")) if not query_name or not city: return 0.0 if city in query_name or query_name in city: return 0.07 if matched_name and (city in matched_name) and any(part in query_name for part in city.split()): return 0.04 return 0.0 def _ambiguity_text(payload: dict[str, Any], evidence_items: list[dict[str, Any]]) -> str: return normalize_text( " ".join( [ coerce_str(payload.get("ambiguity")), coerce_str(payload.get("conflicts")), coerce_str(payload.get("reasoning_summary")), *[_evidence_label(item) for item in evidence_items], ] ) ) def _conflict_penalty(payload: dict[str, Any], evidence_items: list[dict[str, Any]]) -> float: penalty = 0.0 ambiguity_text = _ambiguity_text(payload, evidence_items) if any(token in ambiguity_text for token in ("conflict", "contradict", "inconsistent")): penalty += 0.35 if any( _truthy_evidence_field(item, "has_conflict", "conflicting") for item in evidence_items ): penalty += 0.35 return min(penalty, 0.45) def _weak_evidence_penalty( payload: dict[str, Any], evidence_items: list[dict[str, Any]], *, query: LocationQuery, entity_match: float, geography_match: float, conflict_penalty: float, ) -> float: ambiguity_text = _ambiguity_text(payload, evidence_items) penalty = 0.0 if any(token in ambiguity_text for token in ("ambiguous", "unclear", "weak", "guess")): penalty += 0.20 if any(_truthy_evidence_field(item, "ambiguous") for item in evidence_items): penalty += 0.15 if _city_is_unsupported_name_hint(payload, query, evidence_items): penalty += 0.10 if conflict_penalty == 0.0 and entity_match > 0 and geography_match >= 0.20: return min(penalty, 0.15) return min(penalty, 0.30) def _score_llm_location_payload( payload: dict[str, Any], *, query: LocationQuery, precision: str, ) -> LocationEvidenceScore: model_confidence = parse_float(payload.get("confidence")) model_confidence = min(max(model_confidence if model_confidence is not None else 0.0, 0.0), 1.0) evidence_items = _evidence_items(payload.get("evidence")) source_quality = _source_quality_score(evidence_items) entity_match = _entity_match_score(payload, query, evidence_items) geography_match = _geography_match_score(payload, query) precision_quality = _precision_quality_score(precision) conflict_penalty = _conflict_penalty(payload, evidence_items) weak_evidence_penalty = _weak_evidence_penalty( payload, evidence_items, query=query, entity_match=entity_match, geography_match=geography_match, conflict_penalty=conflict_penalty, ) name_location_hint = _name_location_hint_score(payload, query) score = ( model_confidence * MODEL_CONFIDENCE_WEIGHT + source_quality + entity_match + geography_match + precision_quality + name_location_hint - conflict_penalty - weak_evidence_penalty ) score = min(max(score, 0.0), 1.0) if _city_is_unsupported_name_hint(payload, query, evidence_items): score = min(score, 0.54) summary = ( f"combined={score:.2f}; model={model_confidence:.2f}; " f"source={source_quality:.2f}; entity={entity_match:.2f}; " f"geo={geography_match:.2f}; precision={precision_quality:.2f}; " f"conflict={conflict_penalty:.2f}; weak={weak_evidence_penalty:.2f}; " f"name_hint={name_location_hint:.2f}" ) return LocationEvidenceScore( score=score, model_confidence=model_confidence, source_quality=source_quality, entity_match=entity_match, geography_match=geography_match, precision_quality=precision_quality, conflict_penalty=conflict_penalty, weak_evidence_penalty=weak_evidence_penalty, name_location_hint=name_location_hint, summary=summary, ) def _candidate_from_payload( payload: dict[str, Any], *, query: LocationQuery, entity_type: str, min_confidence: float, ) -> tuple[LocationCandidate | None, str | None]: latitude, longitude = _extract_llm_coordinates(payload) if latitude in (None, 0.0) or longitude in (None, 0.0): return None, "missing, invalid, or zero latitude/longitude" precision = _normalize_llm_precision(payload.get("precision")) if precision not in VALID_LLM_PRECISIONS: return None, f"precision '{payload.get('precision')}' is not precise/site/city" city = coerce_str(payload.get("city")) or query.city or None country = ( normalize_country_text(payload.get("country")) or normalize_country_text(query.country) or query.country ) evidence_score = _score_llm_location_payload(payload, query=query, precision=precision) if evidence_score.score < min_confidence: return None, ( f"combined evidence score {evidence_score.score:.2f} is below minimum " f"{min_confidence}; {evidence_score.summary}" ) confidence = evidence_score.score matched_location_name = ( coerce_str(payload.get("matched_location_name")) or coerce_str(payload.get("display_name")) or coerce_str(query.name) or "LLM factcheck location" ) evidence = _compact_evidence(payload.get("evidence")) reasoning_summary = coerce_str(payload.get("reasoning_summary")) source_note_parts = ["LLM location factcheck fallback"] if payload.get("coordinate_source") == "nominatim_city_fallback": source_note_parts.append("coordinates: Nominatim city fallback") if evidence: source_note_parts.append(f"evidence: {evidence}") if reasoning_summary: source_note_parts.append(f"summary: {reasoning_summary}") source_note_parts.append(f"score: {evidence_score.summary}") extra = query.extra or {} matched_fields = tuple( field for field in ("name", "site", "operator", "organization", "city", "country") if ( (field in {"name", "city", "country"} and getattr(query, field, None)) or coerce_str(extra.get(field)) ) ) or ("llm_factcheck",) return LocationCandidate( latitude=float(latitude), longitude=float(longitude), display_name=matched_location_name, precision=precision, confidence=confidence, query=f"llm_factcheck:{entity_type}:{coerce_str(query.name) or 'unknown'}", source="llm_location_factcheck", source_note="; ".join(source_note_parts), matched_fields=matched_fields, needs_confirmation=True, city=city, region=coerce_str(payload.get("region")) or query.region or None, country=country or None, matched_location_name=matched_location_name, location_verified_at=None, suggested_registry_entry={ "canonical_name": matched_location_name, "aliases": list( { value for value in [ coerce_str(query.name), *[coerce_str(alias) for alias in query.aliases], coerce_str(extra.get("operator")), coerce_str(extra.get("site")), ] if value } ), "operator": coerce_str(extra.get("operator")) or None, "site": coerce_str(extra.get("site")) or None, "country": country or None, "city": city, "region": coerce_str(payload.get("region")) or query.region or None, "latitude": float(latitude), "longitude": float(longitude), "precision": precision, "confidence": confidence, "source_note": "; ".join(source_note_parts), "llm_model_confidence": evidence_score.model_confidence, "llm_combined_confidence": evidence_score.score, "llm_score_breakdown": { "source_quality": evidence_score.source_quality, "entity_match": evidence_score.entity_match, "geography_match": evidence_score.geography_match, "precision_quality": evidence_score.precision_quality, "conflict_penalty": evidence_score.conflict_penalty, "weak_evidence_penalty": evidence_score.weak_evidence_penalty, "name_location_hint": evidence_score.name_location_hint, }, }, raw_payload={ "llm_payload": payload, "search_evidence": payload.get("search_evidence") or [], }, ), None def _normalize_llm_payload(payload: dict[str, Any]) -> dict[str, Any]: for key in ("candidate", "location", "result"): nested = payload.get(key) if isinstance(nested, dict): return nested return payload def _query_context(query: LocationQuery) -> dict[str, Any]: extra = dict(query.extra or {}) return { "name": query.name, "aliases": list(query.aliases), "city": query.city, "region": query.region, "country": query.country, "source_latitude": query.source_latitude, "source_longitude": query.source_longitude, "extra": extra, } def _observations(query: LocationQuery, attempted_queries: Iterable[str]) -> list[str]: extra = query.extra or {} fields = [ ("name", query.name), ("aliases", ", ".join(query.aliases)), ("site", extra.get("site")), ("operator", extra.get("operator")), ("organization", extra.get("organization")), ("city", query.city), ("region", query.region), ("country", query.country), ("source", extra.get("source")), ("source_id", extra.get("source_id")), ("collector", extra.get("collector")), ] observations = [ f"{label}: {value}" for label, value in fields if coerce_str(value) ] attempts = [coerce_str(item) for item in attempted_queries if coerce_str(item)] if attempts: observations.append("previous resolver attempts: " + " | ".join(attempts[:12])) return observations def _location_search_query(query: LocationQuery, entity_type: str) -> str: extra = query.extra or {} parts = [ coerce_str(query.name), coerce_str(extra.get("site")), coerce_str(extra.get("operator")), coerce_str(extra.get("organization")), coerce_str(query.city), coerce_str(query.country), "physical location", ] if entity_type == "bgp_collector": parts.append("route collector city") elif entity_type == "compute_center": parts.append("datacenter supercomputer facility city") return " ".join(part for part in parts if part) async def collect_location_search_evidence( *, web_search_client: WebSearchClient, query: LocationQuery, entity_type: str, max_results: int = 5, ) -> LocationSearchEvidenceResult: search_query = _location_search_query(query, entity_type) attempt = f"web_search:{entity_type}:{search_query}" logger.info_event( "Collecting location search evidence", event="location.factcheck.web_search.start", context={ "entity_type": entity_type, "search_query": search_query, "location_query": _query_context(query), "max_results": max_results, }, ) try: evidence = await web_search_client.search(search_query, max_results=max_results) except WebSearchError as exc: logger.warning_event( "Location search evidence failed", event="location.factcheck.web_search.failed", context={ "entity_type": entity_type, "search_query": search_query, "error": str(exc), }, ) return LocationSearchEvidenceResult( evidence=[], attempted_queries=[attempt], failure_reason=f"WebSearch location evidence failed: {exc}", ) except Exception as exc: logger.warning_event( "Location search evidence unavailable", event="location.factcheck.web_search.unavailable", context={ "entity_type": entity_type, "search_query": search_query, "error": str(exc), }, ) return LocationSearchEvidenceResult( evidence=[], attempted_queries=[attempt], failure_reason=f"WebSearch location evidence unavailable: {exc}", ) normalized = normalize_search_evidence(evidence, limit=max_results) if not normalized: logger.warning_event( "Location search returned no usable evidence", event="location.factcheck.web_search.empty", context={ "entity_type": entity_type, "search_query": search_query, }, ) return LocationSearchEvidenceResult( evidence=[], attempted_queries=[attempt], failure_reason="WebSearch returned no usable location evidence.", ) logger.info_event( "Collected location search evidence", event="location.factcheck.web_search.result", context={ "entity_type": entity_type, "search_query": search_query, "evidence_count": len(normalized), "evidence": _summarize_search_evidence(normalized), }, ) return LocationSearchEvidenceResult( evidence=normalized, attempted_queries=[attempt], failure_reason=None, ) async def _repair_location_payload_from_text( *, provider_client: AIProviderClient, raw_text: str, query: LocationQuery, entity_type: str, db: AsyncSession | None = None, ) -> dict[str, Any] | None: """Second-pass structure repair for models that answer in prose. The first LLM call owns the factcheck. This call is intentionally framed as extraction/normalization only; it should not introduce new facts. """ if not coerce_str(raw_text): return None prompt = await get_effective_prompt(db, LOCATION_NORMALIZE_PROMPT_KEY) request = SituationalAnalysisRequest( title=f"Normalize location factcheck for {entity_type}", objective=prompt.prompt, system_prompt=prompt.system_prompt or None, context={ "entity_type": entity_type, "location_query": _query_context(query), "raw_location_factcheck_text": raw_text[:4000], "required_json_schema": { "latitude": "number|null", "longitude": "number|null", "precision": "precise|site|city", "confidence": "number from 0 to 1", "city": "string|null", "region": "string|null", "country": "string|null", "matched_location_name": "string", "evidence": "array of objects with source/source_type/entity_match/text/url when present", "ambiguity": "string|null", "reasoning_summary": "short string", }, }, observations=[], constraints=[ "Return only strict JSON. Do not wrap it in markdown.", "Do not add new evidence or locations that are not present in the supplied text.", "If exact coordinates are absent but a city and country are present, set latitude and longitude to null and precision to city.", "Use confidence 0.55-0.70 for credible city-level text; use lower confidence for weak or ambiguous text.", ], ) try: response = await provider_client.analyze(request) except Exception: return None payload = _first_json_object(response.content) return _normalize_llm_payload(payload) if isinstance(payload, dict) else None async def collect_llm_location_fallback_candidate( *, provider_client: AIProviderClient, query: LocationQuery, entity_type: str, db: AsyncSession | None = None, attempted_queries: Iterable[str] = (), search_evidence: list[dict[str, Any]] | None = None, min_confidence: float = DEFAULT_MIN_CONFIDENCE, ) -> LocationLLMFallbackResult: """Ask the configured LLM for one fact-checked location candidate. The result is intentionally conservative: invalid, low-confidence, or non-city-level responses are treated as no candidate. Callers should only use this in user-triggered collection flows. """ attempt = f"llm_factcheck:{entity_type}:{coerce_str(query.name) or 'unknown'}" if search_evidence is not None and not search_evidence: logger.warning_event( "Skipping LLM location factcheck because search evidence is empty", event="location.factcheck.llm.skipped_no_evidence", context={ "entity_type": entity_type, "attempt": attempt, "location_query": _query_context(query), }, ) return LocationLLMFallbackResult( candidates=[], attempted_queries=[attempt], failure_reason="LLM location factcheck skipped: no WebSearch evidence.", ) prompt = await get_effective_prompt(db, LOCATION_RESOLVE_PROMPT_KEY) request = SituationalAnalysisRequest( title=f"Location factcheck fallback for {entity_type}", objective=prompt.prompt, system_prompt=prompt.system_prompt or None, context={ "entity_type": entity_type, "location_query": _query_context(query), "search_evidence": search_evidence or [], "required_json_schema": { "latitude": "number", "longitude": "number", "precision": "precise|site|city", "confidence": "number from 0 to 1", "city": "string|null", "region": "string|null", "country": "string|null", "matched_location_name": "string", "evidence": "array of short source/evidence phrases", "evidence[].source_type": "official|government|academic|database|news|generic", "evidence[].entity_match": "boolean when the evidence names the queried entity", "ambiguity": "string|null describing same-name conflicts or contradictory sources", "reasoning_summary": "short string", }, }, observations=_observations(query, attempted_queries), constraints=[ "Return only strict JSON. Do not wrap it in markdown.", "Do not return country-level, regional-only, or unknown precision.", "Do not invent coordinates. Use lower confidence when evidence is incomplete.", "Calibrate model confidence using this rubric: 0.85-1.0 for exact facility coordinates backed by an authoritative source; 0.70-0.84 for a confirmed facility/campus with strong public evidence; 0.55-0.69 for a confirmed city-level location backed by credible sources but without exact facility coordinates; 0.35-0.54 for weak or ambiguous city evidence; below 0.35 when the location is mostly a guess.", "Return evidence as objects when possible, including source, url, source_type, and entity_match.", "Include source names or URLs in evidence when known. The backend will recompute the final confidence from model confidence plus evidence quality.", "If search_evidence is provided, use only that evidence as factual support.", "Do not treat a website footer, office address, publisher address, or contact address as the entity's physical location.", "If the entity name contains a city name, do not choose that city unless evidence explicitly says the entity/facility/supercomputer is located, hosted, built, deployed, or installed there.", "Prefer the facility/site if known; otherwise use the best supported city.", ], ) logger.info_event( "Sending location factcheck request to LLM", event="location.factcheck.llm.request", context={ "entity_type": entity_type, "attempt": attempt, "title": request.title, "objective": request.objective, "location_query": request.context.get("location_query"), "observations": request.observations, "constraints": request.constraints, "search_evidence_count": len(search_evidence or []), "search_evidence": _summarize_search_evidence(search_evidence), }, ) try: response = await provider_client.analyze(request) except Exception as exc: logger.warning_event( "LLM location factcheck failed", event="location.factcheck.llm.failed", context={ "entity_type": entity_type, "attempt": attempt, "error": str(exc), }, ) return LocationLLMFallbackResult( candidates=[], attempted_queries=[attempt], failure_reason=f"LLM location factcheck failed: {exc}", ) logger.info_event( "Received location factcheck response from LLM", event="location.factcheck.llm.response", context={ "entity_type": entity_type, "attempt": attempt, "provider": response.provider, "model": response.model, "content": _truncate_log_text(response.content, 2000), }, ) payload = _first_json_object(response.content) if payload is None: logger.warning_event( "LLM location factcheck response was not strict JSON; attempting repair", event="location.factcheck.llm.non_json", context={ "entity_type": entity_type, "attempt": attempt, "content": _truncate_log_text(response.content, 1200), }, ) payload = await _repair_location_payload_from_text( provider_client=provider_client, raw_text=response.content, query=query, entity_type=entity_type, db=db, ) if payload is None: payload = _payload_from_free_text(response.content, query=query) if payload is None and entity_type != "compute_center": payload = _payload_from_query_name_geocode(query) if payload is None: logger.warning_event( "LLM location factcheck produced no parseable payload", event="location.factcheck.llm.unparseable", context={ "entity_type": entity_type, "attempt": attempt, }, ) return LocationLLMFallbackResult( candidates=[], attempted_queries=[attempt], failure_reason=( "LLM location factcheck did not return a parseable city-level " "location fact." ), ) payload = _normalize_llm_payload(payload) if search_evidence: payload["search_evidence"] = search_evidence existing_evidence = _evidence_items(payload.get("evidence")) payload["evidence"] = [ *existing_evidence, *[ { "source": item.get("title") or item.get("url"), "url": item.get("url"), "text": item.get("snippet") or item.get("content"), "source_type": "web_search", "entity_match": _search_evidence_entity_match(item, query), } for item in search_evidence if isinstance(item, dict) ], ] latitude, longitude = _extract_llm_coordinates(payload) city_geocode_failure = None if latitude in (None, 0.0) or longitude in (None, 0.0): payload, city_geocode_failure = _fill_city_coordinates_from_geocoder( payload, query=query, ) candidate, rejection_reason = _candidate_from_payload( payload, query=query, entity_type=entity_type, min_confidence=min_confidence, ) if candidate is None: if city_geocode_failure and rejection_reason == "missing, invalid, or zero latitude/longitude": rejection_reason = f"{rejection_reason}; {city_geocode_failure}" logger.warning_event( "Rejected LLM location factcheck candidate", event="location.factcheck.llm.rejected", context={ "entity_type": entity_type, "attempt": attempt, "reason": rejection_reason, "payload": payload, "search_evidence_count": len(search_evidence or []), "search_evidence": _summarize_search_evidence(search_evidence), }, ) return LocationLLMFallbackResult( candidates=[], attempted_queries=[attempt], failure_reason=( "LLM location factcheck returned no acceptable city-level candidate" + (f": {rejection_reason}." if rejection_reason else ".") ), ) logger.info_event( "Accepted LLM location factcheck candidate", event="location.factcheck.llm.accepted", context={ "entity_type": entity_type, "attempt": attempt, "candidate": candidate.to_dict(), "payload": payload, "search_evidence_count": len(search_evidence or []), "search_evidence": _summarize_search_evidence(search_evidence), }, ) return LocationLLMFallbackResult( candidates=[candidate], attempted_queries=[attempt], failure_reason=None, )