release: bump version to 0.62.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
linkong
2026-05-21 01:37:32 +08:00
parent 5c65ee24d6
commit fbca381512
138 changed files with 21303 additions and 5721 deletions

View File

@@ -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],