release: bump version to 0.62.0
This commit is contained in:
@@ -9,6 +9,7 @@ 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
|
||||
@@ -29,6 +30,9 @@ 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",
|
||||
@@ -97,6 +101,35 @@ class LocationEvidenceScore:
|
||||
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:
|
||||
@@ -162,6 +195,64 @@ def _evidence_label(item: Any) -> str:
|
||||
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)
|
||||
@@ -588,6 +679,7 @@ 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,
|
||||
@@ -598,6 +690,8 @@ def _weak_evidence_penalty(
|
||||
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)
|
||||
@@ -620,6 +714,7 @@ def _score_llm_location_payload(
|
||||
weak_evidence_penalty = _weak_evidence_penalty(
|
||||
payload,
|
||||
evidence_items,
|
||||
query=query,
|
||||
entity_match=entity_match,
|
||||
geography_match=geography_match,
|
||||
conflict_penalty=conflict_penalty,
|
||||
@@ -636,6 +731,8 @@ def _score_llm_location_payload(
|
||||
- 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}; "
|
||||
@@ -847,15 +944,43 @@ async def collect_location_search_evidence(
|
||||
) -> 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],
|
||||
@@ -863,11 +988,29 @@ async def collect_location_search_evidence(
|
||||
)
|
||||
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],
|
||||
@@ -947,6 +1090,15 @@ async def collect_llm_location_fallback_candidate(
|
||||
"""
|
||||
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],
|
||||
@@ -986,20 +1138,66 @@ async def collect_llm_location_fallback_candidate(
|
||||
"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,
|
||||
@@ -1009,9 +1207,17 @@ async def collect_llm_location_fallback_candidate(
|
||||
)
|
||||
if payload is None:
|
||||
payload = _payload_from_free_text(response.content, query=query)
|
||||
if payload is None:
|
||||
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],
|
||||
@@ -1032,7 +1238,7 @@ async def collect_llm_location_fallback_candidate(
|
||||
"url": item.get("url"),
|
||||
"text": item.get("snippet") or item.get("content"),
|
||||
"source_type": "web_search",
|
||||
"entity_match": True,
|
||||
"entity_match": _search_evidence_entity_match(item, query),
|
||||
}
|
||||
for item in search_evidence
|
||||
if isinstance(item, dict)
|
||||
@@ -1054,6 +1260,18 @@ async def collect_llm_location_fallback_candidate(
|
||||
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],
|
||||
@@ -1062,6 +1280,18 @@ async def collect_llm_location_fallback_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],
|
||||
|
||||
Reference in New Issue
Block a user