release: bump version to 0.50.0
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
970
backend/app/services/location/llm_fallback.py
Normal file
970
backend/app/services/location/llm_fallback.py
Normal 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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ async def test_public_catalog_only_for_anonymous_user():
|
||||
"overview",
|
||||
"quickstart",
|
||||
"manual",
|
||||
"faq",
|
||||
"location-pipeline-user",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
242
backend/tests/test_motion_agent.py
Normal file
242
backend/tests/test_motion_agent.py
Normal 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
|
||||
169
backend/tests/test_settings_ai_provider.py
Normal file
169
backend/tests/test_settings_ai_provider.py
Normal 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"
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user