release: bump version to 0.50.0

This commit is contained in:
rayd1o
2026-05-10 22:06:01 +08:00
parent e1984c7a35
commit 455b8360d0
80 changed files with 10936 additions and 298 deletions

View File

@@ -14,10 +14,13 @@ from app.models.bgp_incident import BGPIncident
from app.models.bgp_observation import BGPObservation
from app.models.user import User
from app.services.bgp_collector_locations import (
build_bgp_collector_location_query,
collect_bgp_collector_location_candidates,
get_bgp_collector_location_dict,
)
from app.services.bgp_collectors import build_bgp_collector_coverage
from app.services.ai_client import get_ai_provider_client
from app.services.location.llm_fallback import collect_llm_location_fallback_candidate
router = APIRouter()
@@ -282,6 +285,7 @@ async def collect_bgp_collector_location(
collector_id: str,
payload: CollectBGPCollectorLocationRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Run the shared location pipeline for a BGP route collector.
@@ -307,6 +311,34 @@ async def collect_bgp_collector_location(
country=country,
operator=operator,
)
llm_failure_reason = None
if not candidates:
query = build_bgp_collector_location_query(
collector=collector_id,
site=site,
city=city,
country=country,
operator=operator,
)
try:
provider_client = await get_ai_provider_client(db)
llm_result = await collect_llm_location_fallback_candidate(
provider_client=provider_client,
query=query,
entity_type="bgp_collector",
attempted_queries=attempted_queries,
)
except Exception as exc:
llm_result = None
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
attempted_queries = [
*attempted_queries,
f"llm_factcheck:bgp_collector:{collector_id or 'unknown'}",
]
if llm_result is not None:
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
candidates = llm_result.candidates
llm_failure_reason = llm_result.failure_reason
context = {
"collector": collector_id,
@@ -327,6 +359,7 @@ async def collect_bgp_collector_location(
),
"candidates": [],
"attempted_queries": list(attempted_queries),
"llm_failure_reason": llm_failure_reason,
"context": context,
}

View File

@@ -1,9 +1,11 @@
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, EmailStr, Field
from dotenv import dotenv_values
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +20,7 @@ from app.models.datasource_config import DataSourceConfig
from app.models.system_setting import SystemSetting
from app.models.user import User
from app.models.vessel import AISSourceHealth
from app.schemas.ai import SituationalAnalysisRequest
from app.services.barentswatch import (
BarentsWatchConfig,
check_barentswatch_config,
@@ -36,6 +39,7 @@ from app.services.datasource_connectivity import (
)
from app.services.ai_client import AIProviderClient, get_ai_provider_client
from app.services.llm_provider_catalog import (
FALLBACK_LLM_PROVIDER_PRESETS,
get_fallback_llm_provider_preset,
list_fallback_llm_provider_presets,
refresh_llm_provider_preset,
@@ -70,13 +74,8 @@ DEFAULT_SETTINGS = {
"ai_provider": {
"service_url": "",
"service_token": "",
"provider": "minimax",
"provider_api": "anthropic-messages",
"base_url": "https://api.minimaxi.com/anthropic",
"model": "MiniMax-M2.7",
"api_key": "",
"max_tokens": 1200,
"anthropic_version": "2023-06-01",
"default_provider": "minimax",
"providers": {},
"timeout_seconds": 60,
"retry_attempts": 2,
}
@@ -141,6 +140,7 @@ class TVSettingsUpdate(BaseModel):
class AIProviderIntegrationUpdate(BaseModel):
service_url: str = ""
service_token: Optional[str] = None
default_provider: Optional[str] = None
provider: str = Field(default="minimax", max_length=80)
provider_api: str = Field(default="anthropic-messages", max_length=80)
base_url: str = Field(default="", max_length=500)
@@ -216,17 +216,226 @@ async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -
return merge_with_defaults(category, record.payload)
def _mask_secret(value: Optional[str]) -> dict:
AI_PROVIDER_ENV_FILE = Path(__file__).resolve().parents[4] / "aiprovider" / ".env"
def _mask_secret(value: Optional[str], source: str = "") -> dict:
if not value:
return {"configured": False, "preview": ""}
return {"configured": False, "preview": "", "source": source}
text = str(value)
if "-" in text:
prefix = text.split("-", 1)[0] + "-"
preview = prefix + ("*" * max(len(text) - len(prefix), 1))
else:
prefix_len = min(4, len(text))
preview = text[:prefix_len] + ("*" * max(len(text) - prefix_len, 1))
return {"configured": True, "preview": preview}
preview = "*" * len(text)
return {"configured": True, "preview": preview, "source": source}
def _normalize_provider_id(provider: Optional[str]) -> str:
return (provider or "minimax").strip().lower() or "minimax"
def _get_provider_preset(provider: str) -> dict:
try:
return get_fallback_llm_provider_preset(provider)
except ValueError:
return {
"provider": provider,
"provider_api": "openai-completions",
"base_url": "",
"model": "",
"models": [],
"api_key_env": "",
}
def _read_ai_provider_env_file() -> dict[str, str]:
if not AI_PROVIDER_ENV_FILE.exists():
return {}
return {
key: str(value)
for key, value in dotenv_values(AI_PROVIDER_ENV_FILE).items()
if value is not None
}
def _resolve_env_secret(*names: str) -> tuple[str, str]:
env_file_values = _read_ai_provider_env_file()
for name in names:
if not name:
continue
value = env_file_values.get(name)
if value:
return value, "env_file"
return "", ""
def _provider_defaults(provider: str) -> dict:
preset = _get_provider_preset(provider)
return {
"provider": provider,
"provider_api": preset.get("provider_api") or "openai-completions",
"base_url": preset.get("base_url") or "",
"model": preset.get("model") or "",
"api_key": "",
"max_tokens": (
1200 if preset.get("provider_api") == "anthropic-messages" else 4096
),
"anthropic_version": "2023-06-01",
}
def _normalize_ai_provider_payload(ai_payload: dict | None) -> dict:
raw = dict(ai_payload or {})
default_provider = _normalize_provider_id(raw.get("default_provider") or raw.get("provider"))
providers = {
_normalize_provider_id(provider): dict(config or {})
for provider, config in (raw.get("providers") or {}).items()
if provider
}
legacy_fields = {
key: raw.get(key)
for key in (
"provider_api",
"base_url",
"model",
"api_key",
"max_tokens",
"anthropic_version",
)
if raw.get(key) not in (None, "")
}
if legacy_fields:
providers[default_provider] = {
**providers.get(default_provider, {}),
**legacy_fields,
}
normalized_providers: dict[str, dict] = {}
for provider, config in providers.items():
provider_id = _normalize_provider_id(provider)
normalized_providers[provider_id] = {
**_provider_defaults(provider_id),
**dict(config or {}),
"provider": provider_id,
}
if default_provider not in normalized_providers:
normalized_providers[default_provider] = _provider_defaults(default_provider)
return {
"service_url": raw.get("service_url") or "",
"service_token": raw.get("service_token") or "",
"default_provider": default_provider,
"providers": normalized_providers,
"timeout_seconds": int(raw.get("timeout_seconds") or 60),
"retry_attempts": int(raw.get("retry_attempts") or 2),
}
def _resolve_provider_api_key(provider: str, provider_config: dict) -> tuple[str, str]:
saved_key = provider_config.get("api_key") or ""
if saved_key:
return str(saved_key), "runtime"
preset = _get_provider_preset(provider)
api_key_env = preset.get("api_key_env") or ""
return _resolve_env_secret(api_key_env, "AI_API_KEY")
def _resolve_service_token(ai_payload: dict) -> tuple[str, str]:
saved_token = ai_payload.get("service_token") or ""
if saved_token:
return str(saved_token), "runtime"
token, source = _resolve_env_secret("AI_PROVIDER_SERVICE_TOKEN")
if token:
return token, source
if app_settings.AI_PROVIDER_SERVICE_TOKEN:
return app_settings.AI_PROVIDER_SERVICE_TOKEN, "backend_env"
return "", ""
def _is_secret_placeholder(value: Optional[str], current_preview: str = "") -> bool:
if value in (None, ""):
return True
text = str(value).strip()
if not text:
return True
return text == current_preview or text.startswith("••••") or "*" in text
def _build_ai_provider_payload(current_payload: dict, update: AIProviderIntegrationUpdate) -> dict:
current_ai = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
provider_id = _normalize_provider_id(update.default_provider or update.provider)
current_providers = {
provider: dict(config or {})
for provider, config in current_ai.get("providers", {}).items()
}
current_provider = current_providers.get(provider_id) or _provider_defaults(provider_id)
current_api_key, current_api_key_source = _resolve_provider_api_key(provider_id, current_provider)
current_api_key_preview = _mask_secret(current_api_key, current_api_key_source)["preview"]
provider_payload = {
**_provider_defaults(provider_id),
**current_provider,
"provider": provider_id,
"provider_api": update.provider_api.strip()
or current_provider.get("provider_api")
or "anthropic-messages",
"base_url": update.base_url.strip(),
"model": update.model.strip(),
"max_tokens": update.max_tokens,
"anthropic_version": update.anthropic_version.strip() or "2023-06-01",
}
if not _is_secret_placeholder(update.api_key, current_api_key_preview):
provider_payload["api_key"] = str(update.api_key).strip()
elif current_provider.get("api_key"):
provider_payload["api_key"] = current_provider.get("api_key") or ""
else:
provider_payload["api_key"] = ""
current_providers[provider_id] = provider_payload
current_service_token, current_service_source = _resolve_service_token(current_ai)
current_service_preview = _mask_secret(current_service_token, current_service_source)["preview"]
ai_payload = {
"service_url": update.service_url.strip()
or app_settings.AI_PROVIDER_SERVICE_URL,
"service_token": current_ai.get("service_token") or "",
"default_provider": provider_id,
"providers": current_providers,
"timeout_seconds": update.timeout_seconds,
"retry_attempts": update.retry_attempts,
}
if not _is_secret_placeholder(update.service_token, current_service_preview):
ai_payload["service_token"] = str(update.service_token).strip()
return ai_payload
def _runtime_config_from_ai_payload(ai_payload: dict) -> dict:
normalized_ai = _normalize_ai_provider_payload(ai_payload)
default_provider = normalized_ai["default_provider"]
provider_config = (
normalized_ai["providers"].get(default_provider) or _provider_defaults(default_provider)
)
api_key, _api_key_source = _resolve_provider_api_key(default_provider, provider_config)
return {
"service_url": normalized_ai.get("service_url") or app_settings.AI_PROVIDER_SERVICE_URL,
"service_token": _resolve_service_token(normalized_ai)[0],
"timeout_seconds": int(
normalized_ai.get("timeout_seconds") or app_settings.AI_PROVIDER_TIMEOUT_SECONDS
),
"retry_attempts": int(
normalized_ai.get("retry_attempts") or app_settings.AI_PROVIDER_RETRY_ATTEMPTS
),
"llm_config": {
"provider": default_provider,
"provider_api": provider_config.get("provider_api") or "anthropic-messages",
"base_url": provider_config.get("base_url") or "",
"model": provider_config.get("model") or "",
"api_key": api_key,
"max_tokens": int(provider_config.get("max_tokens") or 1200),
"anthropic_version": provider_config.get("anthropic_version") or "2023-06-01",
},
}
async def get_runtime_ai_provider_config(db: AsyncSession) -> dict:
@@ -235,31 +444,7 @@ async def get_runtime_ai_provider_config(db: AsyncSession) -> dict:
"external_integrations",
runtime_record.payload if runtime_record else None,
)
ai_payload = payload.get("ai_provider") or {}
has_runtime_llm_config = bool(
runtime_record
and isinstance(runtime_record.payload, dict)
and isinstance(runtime_record.payload.get("ai_provider"), dict)
)
return {
"service_url": ai_payload.get("service_url") or app_settings.AI_PROVIDER_SERVICE_URL,
"service_token": ai_payload.get("service_token") or app_settings.AI_PROVIDER_SERVICE_TOKEN,
"timeout_seconds": int(
ai_payload.get("timeout_seconds") or app_settings.AI_PROVIDER_TIMEOUT_SECONDS
),
"retry_attempts": int(
ai_payload.get("retry_attempts") or app_settings.AI_PROVIDER_RETRY_ATTEMPTS
),
"llm_config": {
"provider": ai_payload.get("provider") or "minimax",
"provider_api": ai_payload.get("provider_api") or "anthropic-messages",
"base_url": ai_payload.get("base_url") or "https://api.minimaxi.com/anthropic",
"model": ai_payload.get("model") or "MiniMax-M2.7",
"api_key": ai_payload.get("api_key") or "",
"max_tokens": int(ai_payload.get("max_tokens") or 1200),
"anthropic_version": ai_payload.get("anthropic_version") or "2023-06-01",
} if has_runtime_llm_config else {},
}
return _runtime_config_from_ai_payload(payload.get("ai_provider") or {})
async def get_barentswatch_config_record(db: AsyncSession) -> Optional[DataSourceConfig]:
@@ -269,7 +454,32 @@ async def get_barentswatch_config_record(db: AsyncSession) -> Optional[DataSourc
async def serialize_external_integrations(db: AsyncSession) -> dict:
ai_config = await get_runtime_ai_provider_config(db)
runtime_setting = await get_setting_record(db, "external_integrations")
display_llm_config = ai_config["llm_config"] or DEFAULT_SETTINGS["external_integrations"]["ai_provider"]
raw_payload = merge_with_defaults(
"external_integrations",
runtime_setting.payload if runtime_setting else None,
)
normalized_ai = _normalize_ai_provider_payload(raw_payload.get("ai_provider") or {})
default_provider = normalized_ai["default_provider"]
providers_payload: dict[str, dict] = {}
for provider in sorted({
*FALLBACK_LLM_PROVIDER_PRESETS.keys(),
*normalized_ai["providers"].keys(),
default_provider,
}):
provider_id = _normalize_provider_id(provider)
provider_config = normalized_ai["providers"].get(provider_id) or _provider_defaults(provider_id)
api_key, api_key_source = _resolve_provider_api_key(provider_id, provider_config)
providers_payload[provider_id] = {
"provider": provider_id,
"provider_api": provider_config.get("provider_api") or "openai-completions",
"base_url": provider_config.get("base_url") or "",
"model": provider_config.get("model") or "",
"api_key": _mask_secret(api_key, api_key_source),
"max_tokens": int(provider_config.get("max_tokens") or 1200),
"anthropic_version": provider_config.get("anthropic_version") or "2023-06-01",
"source": "runtime" if provider_config.get("api_key") else (api_key_source or "preset"),
}
display_llm_config = providers_payload.get(default_provider) or _provider_defaults(default_provider)
barentswatch_record = await get_barentswatch_config_record(db)
barentswatch_auth = barentswatch_record.auth_config if barentswatch_record else {}
barentswatch_auth = barentswatch_auth or {}
@@ -277,12 +487,14 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
return {
"ai_provider": {
"service_url": ai_config["service_url"],
"service_token": _mask_secret(ai_config["service_token"]),
"provider": display_llm_config.get("provider") or "minimax",
"service_token": _mask_secret(*_resolve_service_token(normalized_ai)),
"default_provider": default_provider,
"provider": default_provider,
"provider_api": display_llm_config.get("provider_api") or "anthropic-messages",
"base_url": display_llm_config.get("base_url") or "https://api.minimaxi.com/anthropic",
"model": display_llm_config.get("model") or "MiniMax-M2.7",
"api_key": _mask_secret(display_llm_config.get("api_key")),
"api_key": display_llm_config.get("api_key") or _mask_secret(None),
"providers": providers_payload,
"max_tokens": int(display_llm_config.get("max_tokens") or 1200),
"anthropic_version": display_llm_config.get("anthropic_version") or "2023-06-01",
"timeout_seconds": ai_config["timeout_seconds"],
@@ -305,29 +517,7 @@ async def save_external_integrations_payload(
update: ExternalIntegrationsUpdate,
) -> dict:
current_payload = await get_setting_payload(db, "external_integrations")
current_ai = current_payload.get("ai_provider") or {}
ai_payload = {
"service_url": update.ai_provider.service_url.strip()
or app_settings.AI_PROVIDER_SERVICE_URL,
"service_token": current_ai.get("service_token") or "",
"provider": update.ai_provider.provider.strip() or "minimax",
"provider_api": update.ai_provider.provider_api.strip() or "anthropic-messages",
"base_url": update.ai_provider.base_url.strip(),
"model": update.ai_provider.model.strip(),
"api_key": current_ai.get("api_key") or "",
"max_tokens": update.ai_provider.max_tokens,
"anthropic_version": update.ai_provider.anthropic_version.strip() or "2023-06-01",
"timeout_seconds": update.ai_provider.timeout_seconds,
"retry_attempts": update.ai_provider.retry_attempts,
}
if update.ai_provider.clear_service_token:
ai_payload["service_token"] = ""
elif update.ai_provider.service_token not in (None, ""):
ai_payload["service_token"] = update.ai_provider.service_token
if update.ai_provider.clear_api_key:
ai_payload["api_key"] = ""
elif update.ai_provider.api_key not in (None, ""):
ai_payload["api_key"] = update.ai_provider.api_key
ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider)
await save_setting_payload(db, "external_integrations", {"ai_provider": ai_payload})
@@ -530,6 +720,85 @@ async def connect_barentswatch_integration(
return {**result, "connected": False}
@router.post("/integrations/ai-provider/connect")
async def connect_ai_provider_integration(
payload: AIProviderIntegrationUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
draft_ai_payload = _build_ai_provider_payload(current_payload, payload)
runtime_config = _runtime_config_from_ai_payload(draft_ai_payload)
client = AIProviderClient(
service_url=runtime_config["service_url"],
service_token=runtime_config["service_token"],
timeout=runtime_config["timeout_seconds"],
retry_attempts=runtime_config["retry_attempts"],
llm_config=runtime_config.get("llm_config") or {},
)
try:
status_result = await client.get_status()
if not status_result.configured:
return {
"success": False,
"connected": False,
"message": "AI Provider 可访问,但当前 provider/model/key 未完整配置。",
"status": status_result.model_dump(),
}
analysis_result = await client.analyze(
SituationalAnalysisRequest(
title="连接测试",
objective="请用一句话回复连接可用。",
observations=["这是配置中心发起的 LLM 连接测试。"],
constraints=["回复尽量简短。"],
)
)
await save_setting_payload(db, "external_integrations", {"ai_provider": draft_ai_payload})
return {
"success": True,
"connected": True,
"message": "AI Provider 连接成功,已保存为全局默认配置。",
"status": status_result.model_dump(),
"provider": analysis_result.provider,
"model": analysis_result.model,
"integrations": await serialize_external_integrations(db),
}
except HTTPException as exc:
return {
"success": False,
"connected": False,
"message": str(exc.detail),
}
except Exception as exc:
return {
"success": False,
"connected": False,
"message": f"AI Provider 连接测试失败: {exc}",
}
@router.get("/integrations/ai-provider/secrets")
async def reveal_ai_provider_secrets(
provider: str = Query(default=""),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
provider_id = _normalize_provider_id(provider or ai_payload["default_provider"])
provider_config = ai_payload["providers"].get(provider_id) or _provider_defaults(provider_id)
api_key, api_key_source = _resolve_provider_api_key(provider_id, provider_config)
service_token, service_token_source = _resolve_service_token(ai_payload)
return {
"provider": provider_id,
"api_key": api_key,
"api_key_source": api_key_source,
"service_token": service_token,
"service_token_source": service_token_source,
}
@router.get("/credential-guides/{provider}")
async def read_credential_guide(
provider: str,

View File

@@ -31,12 +31,15 @@ from app.services.cable_graph import build_graph_from_data, CableGraph, haversin
from app.services.compute_center_locations import (
RENDERABLE_PRECISIONS,
ResolutionDiagnostic,
build_compute_center_location_query,
collect_location_candidates,
refresh_compute_center_location_cache,
resolve_compute_center_location_full,
upsert_compute_center_location,
)
from app.services.ai_client import get_ai_provider_client
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
from app.services.location.llm_fallback import collect_llm_location_fallback_candidate
from app.services.persistent_logs import record_system_log
from app.services.vessel_ais_aggregation import (
build_field_conflict_candidates,
@@ -1864,6 +1867,37 @@ async def collect_compute_center_location(
country=country,
record_id=record_id,
)
llm_failure_reason = None
if not candidates:
query = build_compute_center_location_query(
name=name,
source=source,
source_id=source_id,
operator=operator,
site=site,
organization=organization,
city=city,
country=country,
)
try:
provider_client = await get_ai_provider_client(db)
llm_result = await collect_llm_location_fallback_candidate(
provider_client=provider_client,
query=query,
entity_type="compute_center",
attempted_queries=attempted_queries,
)
except Exception as exc:
llm_result = None
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
attempted_queries = [
*attempted_queries,
f"llm_factcheck:compute_center:{name or source_id or 'unknown'}",
]
if llm_result is not None:
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
candidates = llm_result.candidates
llm_failure_reason = llm_result.failure_reason
if not candidates:
return {
@@ -1877,6 +1911,7 @@ async def collect_compute_center_location(
),
"candidates": [],
"attempted_queries": list(attempted_queries),
"llm_failure_reason": llm_failure_reason,
"context": {
"name": name,
"operator": operator,

View File

@@ -291,9 +291,27 @@ def collect_bgp_collector_location_candidates(
site: str | None = None,
operator: str | None = None,
) -> tuple[list[LocationCandidate], list[str]]:
query = build_bgp_collector_location_query(
collector=collector,
city=city,
country=country,
site=site,
operator=operator,
)
return BGP_COLLECTOR_COLLECTION_PIPELINE.collect_candidates(query)
def build_bgp_collector_location_query(
*,
collector: str | None = None,
city: str | None = None,
country: str | None = None,
site: str | None = None,
operator: str | None = None,
) -> LocationQuery:
stored = get_bgp_collector_location_dict(collector or "")
name = coerce_str(collector) or None
query = LocationQuery(
return LocationQuery(
name=name,
aliases=tuple(filter(None, (collector,))),
city=coerce_str(city or stored.get("city")) or None,
@@ -301,6 +319,6 @@ def collect_bgp_collector_location_candidates(
extra={
"site": coerce_str(site or stored.get("site")),
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
"collector": coerce_str(collector),
},
)
return BGP_COLLECTOR_COLLECTION_PIPELINE.collect_candidates(query)

View File

@@ -713,6 +713,30 @@ def collect_location_candidates(
The unused ``source`` / ``source_id`` / ``record_id`` arguments are kept
for backward compatibility with the API handler that calls this function.
"""
query = build_compute_center_location_query(
name=name,
source=source,
source_id=source_id,
operator=operator,
site=site,
city=city,
country=country,
organization=organization,
)
return COMPUTE_CENTER_COLLECTION_PIPELINE.collect_candidates(query)
def build_compute_center_location_query(
*,
name: str | None = None,
source: str | None = None,
source_id: str | None = None,
operator: str | None = None,
site: str | None = None,
city: str | None = None,
country: str | None = None,
organization: str | None = None,
) -> LocationQuery:
name_value = coerce_str(name)
context: dict[str, str] = {
"source": coerce_str(source),
@@ -725,8 +749,7 @@ def collect_location_candidates(
"operator": coerce_str(operator or organization),
"organization": coerce_str(organization),
}
query = _context_to_query(context)
return COMPUTE_CENTER_COLLECTION_PIPELINE.collect_candidates(query)
return _context_to_query(context)
def _record_operator(metadata: dict[str, Any]) -> str | None:

View File

@@ -34,7 +34,8 @@ DOCS_METADATA: tuple[DocsMetadata, ...] = (
DocsMetadata(DOCS_README_FILENAME, DEFAULT_DOCS_SLUG, "public", "Overview", 0, "技术文档", "Technical Docs"),
DocsMetadata("quickstart.md", "quickstart", "public", "Manual", 1, "快速开始", "Quickstart"),
DocsMetadata("manual.md", "manual", "public", "Manual", 2, "Planet 使用手册", "Planet Manual"),
DocsMetadata("location-pipeline-user.md", "location-pipeline-user", "public", "Manual", 3, "Earth 位置候选采集使用手册", "Earth Location Candidate Collection User Guide"),
DocsMetadata("faq.md", "faq", "public", "Manual", 3, "常见问题", "FAQ"),
DocsMetadata("location-pipeline-user.md", "location-pipeline-user", "public", "Manual", 4, "Earth 位置候选采集使用手册", "Earth Location Candidate Collection User Guide"),
DocsMetadata("earth-frontend-context.md", "earth-frontend-context", "docs_developer", "Earth", 10, "Earth 前端结构", "Earth Frontend Context"),
DocsMetadata("earth-layer-style-reference.md", "earth-layer-style-reference", "docs_developer", "Earth", 11, "Earth 图层样式属性索引", "Earth Layer Style Reference"),
DocsMetadata("earth-render-layer-order.md", "earth-render-layer-order", "docs_developer", "Earth", 12, "Earth 渲染图层顺序", "Earth Render Layer Order"),

View File

@@ -0,0 +1,970 @@
"""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 app.core.countries import COUNTRY_ENTRIES, normalize_country
from app.schemas.ai import SituationalAnalysisRequest
from app.services.ai_client import AIProviderClient
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
MODEL_CONFIDENCE_WEIGHT = 0.25
_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 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 _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 _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]],
*,
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 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,
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)
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,
},
},
), 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
async def _repair_location_payload_from_text(
*,
provider_client: AIProviderClient,
raw_text: str,
query: LocationQuery,
entity_type: str,
) -> 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
request = SituationalAnalysisRequest(
title=f"Normalize location factcheck for {entity_type}",
objective=(
"Convert the supplied location factcheck text into exactly one strict "
"JSON object. Extract only facts present in the text or original query."
),
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,
attempted_queries: Iterable[str] = (),
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'}"
request = SituationalAnalysisRequest(
title=f"Location factcheck fallback for {entity_type}",
objective=(
"Return exactly one JSON object for the most likely physical location. "
"Use only fact-checkable public knowledge; return null fields rather "
"than guessing when evidence is weak."
),
context={
"entity_type": entity_type,
"location_query": _query_context(query),
"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.",
"Prefer the facility/site if known; otherwise use the best supported city.",
],
)
try:
response = await provider_client.analyze(request)
except Exception as exc:
return LocationLLMFallbackResult(
candidates=[],
attempted_queries=[attempt],
failure_reason=f"LLM location factcheck failed: {exc}",
)
payload = _first_json_object(response.content)
if payload is None:
payload = await _repair_location_payload_from_text(
provider_client=provider_client,
raw_text=response.content,
query=query,
entity_type=entity_type,
)
if payload is None:
payload = _payload_from_free_text(response.content, query=query)
if payload is None:
payload = _payload_from_query_name_geocode(query)
if payload is None:
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)
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}"
return LocationLLMFallbackResult(
candidates=[],
attempted_queries=[attempt],
failure_reason=(
"LLM location factcheck returned no acceptable city-level candidate"
+ (f": {rejection_reason}." if rejection_reason else ".")
),
)
return LocationLLMFallbackResult(
candidates=[candidate],
attempted_queries=[attempt],
failure_reason=None,
)

View File

@@ -33,6 +33,7 @@ from app.services.playground_session_store import upsert_playground_session
STREAM_CHUNK_SIZE = 24
STREAM_INTERVAL_SECONDS = 0.08
THINKING_PREVIEW_SECONDS = 2.6
ORPHANED_RUN_MESSAGE = "后台生成任务已中断,请点击上一条用户消息的重试按钮重新生成。"
class _ActiveRun:
@@ -179,6 +180,7 @@ async def _build_thread_response(
session: PlaygroundSession,
) -> PlaygroundThreadResponse:
messages = await _list_visible_messages(db, session_id=session.id)
messages = await _reconcile_orphaned_active_messages(db, messages)
id_map = {item.id: item.public_id for item in messages}
return PlaygroundThreadResponse(
session=session_to_response(session),
@@ -186,6 +188,30 @@ async def _build_thread_response(
)
async def _reconcile_orphaned_active_messages(
db: AsyncSession,
messages: list[PlaygroundMessage],
) -> list[PlaygroundMessage]:
changed = False
for item in messages:
if item.status not in {"pending", "thinking", "answering"}:
continue
if item.public_id in _ACTIVE_RUNS:
continue
item.status = "error"
item.content = item.content or ORPHANED_RUN_MESSAGE
orphan_meta = "错误: 后台任务已中断"
if orphan_meta not in (item.meta or []):
item.meta = [*(item.meta or []), orphan_meta]
changed = True
if changed:
await db.flush()
await db.commit()
for item in messages:
await db.refresh(item)
return messages
async def get_thread(
db: AsyncSession,
*,
@@ -550,6 +576,15 @@ def _build_conversation_history(messages: Sequence[PlaygroundMessage], current_u
return history[-8:]
def _format_run_exception(exc: Exception) -> str:
if isinstance(exc, HTTPException):
detail = exc.detail
if isinstance(detail, str):
return detail
return str(detail)
return str(exc) or type(exc).__name__
async def _run_assistant_message(
*,
user_id: int,
@@ -680,13 +715,18 @@ async def _run_assistant_message(
await db.commit()
raise
except Exception as exc:
error_message = _format_run_exception(exc)
async with async_session_factory() as db:
result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == assistant_message_id))
message = result.scalar_one_or_none()
if message is not None:
message.status = "error"
message.content = message.content or "分析失败,请检查 AI Provider 配置或稍后再试。"
message.meta = [*(message.meta or []), f"错误: {type(exc).__name__}"]
message.content = message.content or f"分析失败{error_message}"
message.meta = [
*(message.meta or []),
f"Request ID: {request_id}",
f"错误: {error_message}",
]
await db.flush()
await db.commit()
finally:

View File

@@ -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 ─────────────────────────────────────────────

View File

@@ -47,6 +47,7 @@ async def test_public_catalog_only_for_anonymous_user():
"overview",
"quickstart",
"manual",
"faq",
"location-pipeline-user",
}

View File

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

View 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

View 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"

View File

@@ -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 = [