From 455b8360d0d6e6063e7ff1aeb4b049fe247058ff Mon Sep 17 00:00:00 2001 From: rayd1o Date: Sun, 10 May 2026 22:06:01 +0800 Subject: [PATCH 1/2] release: bump version to 0.50.0 --- README.md | 2 + VERSION | 2 +- aiprovider/.env.example | 9 + backend/app/api/v1/bgp.py | 33 + backend/app/api/v1/settings.py | 399 ++++++- backend/app/api/v1/visualization.py | 35 + .../app/services/bgp_collector_locations.py | 22 +- .../app/services/compute_center_locations.py | 27 +- backend/app/services/docs_gatekeeper.py | 3 +- backend/app/services/location/llm_fallback.py | 970 +++++++++++++++ .../app/services/playground_chat_service.py | 44 +- backend/tests/test_bgp_collector_locations.py | 56 + backend/tests/test_docs_gatekeeper.py | 1 + backend/tests/test_location_pipeline.py | 528 ++++++++ backend/tests/test_motion_agent.py | 242 ++++ backend/tests/test_settings_ai_provider.py | 169 +++ .../test_visualization_compute_centers.py | 103 +- docs/CHANGELOG.md | 17 + docs/plans/README.md | 4 + ...gents-light-orchestrator-websearch-plan.md | 407 +++++++ ...rth-motion-capture-gesture-control-plan.md | 191 +++ ...arth-motion-gesture-interaction-v2-plan.md | 66 + ...resentation-decoupled-architecture-plan.md | 67 ++ docs/technical/en/README.md | 1 + docs/technical/en/agents-aiprovider.md | 96 +- docs/technical/en/earth-frontend-context.md | 33 +- docs/technical/en/faq.md | 306 +++++ .../en/location-pipeline-development.md | 27 +- docs/technical/en/location-pipeline-user.md | 12 +- docs/technical/en/manual.md | 42 +- docs/technical/en/ops-planet-sh-startup.md | 88 +- docs/technical/en/quickstart.md | 8 +- docs/technical/zh/README.md | 1 + docs/technical/zh/agents-aiprovider.md | 96 +- docs/technical/zh/earth-frontend-context.md | 27 +- docs/technical/zh/faq.md | 308 +++++ .../zh/location-pipeline-development.md | 27 +- docs/technical/zh/location-pipeline-user.md | 12 +- docs/technical/zh/manual.md | 42 +- docs/technical/zh/ops-planet-sh-startup.md | 88 +- docs/technical/zh/quickstart.md | 8 +- docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/css/hud.css | 170 +++ frontend/public/earth/index.html | 168 ++- frontend/public/earth/js/compute-centers.js | 15 +- frontend/public/earth/js/constants.js | 5 + frontend/public/earth/js/controls.js | 335 +++++- .../public/earth/js/country-boundaries.js | 80 +- frontend/public/earth/js/cruise-sequencer.js | 50 +- .../public/earth/js/cruise-sequencer.test.js | 101 ++ frontend/public/earth/js/info-card.js | 249 +++- frontend/public/earth/js/main.js | 1064 ++++++++++++++++- .../public/earth/js/motion-agent-provider.js | 108 ++ .../earth/js/motion-browser-provider.js | 548 +++++++++ frontend/public/earth/js/motion-control.js | 257 ++++ .../public/earth/js/motion-control.test.js | 624 ++++++++++ .../public/earth/js/motion-cruise-adapter.js | 186 +++ .../public/earth/js/motion-debug-panel.js | 355 ++++++ frontend/public/earth/js/motion-events.js | 5 + frontend/public/earth/js/motion-protocol.js | 93 ++ .../earth/js/presentation-controller.js | 183 +++ .../earth/js/presentation-controller.test.js | 137 +++ frontend/public/earth/js/tv.js | 12 +- frontend/src/index.css | 26 +- frontend/src/pages/Docs/docs-content.ts | 8 +- frontend/src/pages/Playground/Playground.tsx | 34 +- frontend/src/pages/Settings/Settings.tsx | 284 ++++- motion_agent/__init__.py | 11 + motion_agent/__main__.py | 5 + motion_agent/cameras.py | 147 +++ motion_agent/cli.py | 65 + motion_agent/config.py | 65 + motion_agent/events.py | 96 ++ motion_agent/recognizer.py | 118 ++ motion_agent/server.py | 192 +++ motion_agent/state.py | 44 + planet.sh | 450 ++++++- pyproject.toml | 4 +- uv.lock | 316 ++++- 80 files changed, 10936 insertions(+), 298 deletions(-) create mode 100644 backend/app/services/location/llm_fallback.py create mode 100644 backend/tests/test_motion_agent.py create mode 100644 backend/tests/test_settings_ai_provider.py create mode 100644 docs/plans/agents-light-orchestrator-websearch-plan.md create mode 100644 docs/plans/earth-motion-capture-gesture-control-plan.md create mode 100644 docs/plans/earth-motion-gesture-interaction-v2-plan.md create mode 100644 docs/plans/earth-presentation-decoupled-architecture-plan.md create mode 100644 docs/technical/en/faq.md create mode 100644 docs/technical/zh/faq.md create mode 100644 frontend/public/earth/js/cruise-sequencer.test.js create mode 100644 frontend/public/earth/js/motion-agent-provider.js create mode 100644 frontend/public/earth/js/motion-browser-provider.js create mode 100644 frontend/public/earth/js/motion-control.js create mode 100644 frontend/public/earth/js/motion-control.test.js create mode 100644 frontend/public/earth/js/motion-cruise-adapter.js create mode 100644 frontend/public/earth/js/motion-debug-panel.js create mode 100644 frontend/public/earth/js/motion-events.js create mode 100644 frontend/public/earth/js/motion-protocol.js create mode 100644 frontend/public/earth/js/presentation-controller.js create mode 100644 frontend/public/earth/js/presentation-controller.test.js create mode 100644 motion_agent/__init__.py create mode 100644 motion_agent/__main__.py create mode 100644 motion_agent/cameras.py create mode 100644 motion_agent/cli.py create mode 100644 motion_agent/config.py create mode 100644 motion_agent/events.py create mode 100644 motion_agent/recognizer.py create mode 100644 motion_agent/server.py create mode 100644 motion_agent/state.py diff --git a/README.md b/README.md index 141629a4..16fbb393 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,8 @@ bun run build 推荐按下面顺序排查和配置。 +端口占用、`iphlpsvc` / portproxy、摄像头和依赖问题的集中排障入口见 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。 + ### 1. 在 WSL 中启动服务 ```bash diff --git a/VERSION b/VERSION index 5c4503b7..564edf82 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.49.0 +0.50.0 diff --git a/aiprovider/.env.example b/aiprovider/.env.example index fdfad118..ba7fd23c 100644 --- a/aiprovider/.env.example +++ b/aiprovider/.env.example @@ -32,6 +32,15 @@ AI_API_KEY=sk-cp-change-me AI_MAX_TOKENS=1200 AI_ANTHROPIC_VERSION=2023-06-01 +# Optional provider-specific keys used by Settings fallback before AI_API_KEY +# MINIMAX_API_KEY=sk-cp-change-me +# OPENAI_API_KEY=sk-change-me +# ANTHROPIC_API_KEY=sk-ant-change-me +# DEEPSEEK_API_KEY=sk-change-me +# DASHSCOPE_API_KEY=sk-change-me +# MOONSHOT_API_KEY=sk-change-me +# OPENROUTER_API_KEY=sk-or-change-me + # OpenAI-compatible example (vLLM / LM Studio / One API / local gateway) # AI_PROVIDER=openai # AI_PROVIDER_API=openai-completions diff --git a/backend/app/api/v1/bgp.py b/backend/app/api/v1/bgp.py index fea3d59b..5e075043 100644 --- a/backend/app/api/v1/bgp.py +++ b/backend/app/api/v1/bgp.py @@ -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, } diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 700a6312..21ad7f11 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -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, diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index 5eb07822..ee4f1ca9 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -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, diff --git a/backend/app/services/bgp_collector_locations.py b/backend/app/services/bgp_collector_locations.py index 5798514a..a7bb51cf 100644 --- a/backend/app/services/bgp_collector_locations.py +++ b/backend/app/services/bgp_collector_locations.py @@ -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) diff --git a/backend/app/services/compute_center_locations.py b/backend/app/services/compute_center_locations.py index 5bf47dbf..d2ba9acd 100644 --- a/backend/app/services/compute_center_locations.py +++ b/backend/app/services/compute_center_locations.py @@ -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: diff --git a/backend/app/services/docs_gatekeeper.py b/backend/app/services/docs_gatekeeper.py index 9cd90043..807e5878 100644 --- a/backend/app/services/docs_gatekeeper.py +++ b/backend/app/services/docs_gatekeeper.py @@ -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"), diff --git a/backend/app/services/location/llm_fallback.py b/backend/app/services/location/llm_fallback.py new file mode 100644 index 00000000..df9cec4e --- /dev/null +++ b/backend/app/services/location/llm_fallback.py @@ -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, + ) diff --git a/backend/app/services/playground_chat_service.py b/backend/app/services/playground_chat_service.py index d4df9dc4..0edcc3b2 100644 --- a/backend/app/services/playground_chat_service.py +++ b/backend/app/services/playground_chat_service.py @@ -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: diff --git a/backend/tests/test_bgp_collector_locations.py b/backend/tests/test_bgp_collector_locations.py index 62665ec9..4f3f939c 100644 --- a/backend/tests/test_bgp_collector_locations.py +++ b/backend/tests/test_bgp_collector_locations.py @@ -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 ───────────────────────────────────────────── diff --git a/backend/tests/test_docs_gatekeeper.py b/backend/tests/test_docs_gatekeeper.py index 123a0a61..85d960b4 100644 --- a/backend/tests/test_docs_gatekeeper.py +++ b/backend/tests/test_docs_gatekeeper.py @@ -47,6 +47,7 @@ async def test_public_catalog_only_for_anonymous_user(): "overview", "quickstart", "manual", + "faq", "location-pipeline-user", } diff --git a/backend/tests/test_location_pipeline.py b/backend/tests/test_location_pipeline.py index 93c415a2..88519985 100644 --- a/backend/tests/test_location_pipeline.py +++ b/backend/tests/test_location_pipeline.py @@ -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 diff --git a/backend/tests/test_motion_agent.py b/backend/tests/test_motion_agent.py new file mode 100644 index 00000000..99791d0a --- /dev/null +++ b/backend/tests/test_motion_agent.py @@ -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 diff --git a/backend/tests/test_settings_ai_provider.py b/backend/tests/test_settings_ai_provider.py new file mode 100644 index 00000000..dc9042c8 --- /dev/null +++ b/backend/tests/test_settings_ai_provider.py @@ -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" diff --git a/backend/tests/test_visualization_compute_centers.py b/backend/tests/test_visualization_compute_centers.py index 5e4d8d15..426f11e1 100644 --- a/backend/tests/test_visualization_compute_centers.py +++ b/backend/tests/test_visualization_compute_centers.py @@ -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 = [ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a20fd6f1..af80f20b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,23 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.50.0] — 2026-05-10 + +Released: 2026-05-10 + +### ✨ Highlights +- 新增 Earth 动作捕捉双通道控制:Browser Camera 本地识别与 Motion Agent WebSocket 高级接入,并补齐调试 HUD、骨架预览和手势冷却保护。 +- 新增 Motion 目标展示的 `PresentationController` 接入,动捕聚焦复用巡航卡片和 connector,同时保持 BGP/News 原巡航体验不变。 +- 扩展位置候选管线与 AI Provider 兜底,支持算力中心和 BGP 观测站候选采集、保存、待定位队列与 LLM factcheck。 + +### Added / Fixed / Improved +- 改进 `planet.sh`:支持可选 Motion Agent 启动、摄像头 index/URL 参数、WSL 摄像头引导、端口清理细化和 AI Provider/Motion 依赖自动处理。 +- Settings 与 Playground 支持多 provider AI 配置、密钥来源脱敏预览和运行时默认 provider 解析。 +- Docs 新增 FAQ 入口,并同步中英文手册、Earth 前端上下文、位置管线和启动脚本文档。 +- Earth 媒体面板记录直播/新闻 tab 状态,刷新后恢复用户上次选择。 + +--- + ## [0.49.0] — 2026-05-08 Released: 2026-05-08 diff --git a/docs/plans/README.md b/docs/plans/README.md index a31060b9..c78f53eb 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -25,6 +25,9 @@ - [earth-real-terrain-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md) - [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md) - [earth-news-cruise-summary-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-cruise-summary-plan.md) +- [Earth 动作捕捉手势控制计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-capture-gesture-control-plan.md) +- [Earth 动捕交互语义 V2 计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-gesture-interaction-v2-plan.md) +- [Earth Presentation 解耦架构计划](/home/ray/dev/linkong/planet/docs/plans/earth-presentation-decoupled-architecture-plan.md) - [earth-vessel-rendering-performance-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-rendering-performance-plan.md) - [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md) - [earth-interactable-layer-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-interactable-layer-plan.md) @@ -32,6 +35,7 @@ - [Docs Gatekeeper 鉴权系统计划](/home/ray/dev/linkong/planet/docs/plans/docs-gatekeeper-auth-plan.md) - [Location Resolver 共享管线计划](/home/ray/dev/linkong/planet/docs/plans/location-resolver-shared-pipeline-plan.md) - [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) +- [Lightweight Agent Orchestrator 与 WebSearch 证据层计划](/home/ray/dev/linkong/planet/docs/plans/agents-light-orchestrator-websearch-plan.md) - [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md) 不适合放入这里的内容: diff --git a/docs/plans/agents-light-orchestrator-websearch-plan.md b/docs/plans/agents-light-orchestrator-websearch-plan.md new file mode 100644 index 00000000..01d10a9f --- /dev/null +++ b/docs/plans/agents-light-orchestrator-websearch-plan.md @@ -0,0 +1,407 @@ +# Lightweight Agent Orchestrator and WebSearch Evidence Plan + +## Overview + +Planet should not turn `aiprovider` into a general-purpose agent runtime. + +`aiprovider` should remain the model gateway: + +- provider compatibility +- protocol adaptation +- model authentication +- request and response normalization + +Agent behavior belongs in the backend, where Planet already owns business state, +permissions, persistence, evidence records, and operator workflows. + +The recommended direction is a lightweight backend Agent Orchestrator with a +controlled tool layer. The first version should use fixed workflows instead of a +free-form tool-calling loop. + + +## Architecture Decision + +Use this boundary: + +```text +aiprovider = model adapter only +backend Agent = task orchestration + tools + evidence + policy + business rules +``` + +This keeps model transport separate from Planet-specific behavior. It also lets +OpenAI, MiniMax, Anthropic-compatible providers, Ollama, and later providers all +reuse the same backend tools. + +Recommended module shape: + +```text +backend/app/services/ + ai/ + agent_orchestrator.py + tool_registry.py + prompts.py + schemas.py + ai_tools/ + web_search.py + web_fetch.py + geo_resolve.py + internal_data_query.py + incident_query.py + evidence_store.py + situation/ + bgp_analyzer.py + risk_scoring.py + event_correlator.py + alert_policy.py + +aiprovider/ + provider_service.py + main.py +``` + + +## Phase 1: Controlled Workflow Agent + +The first implementation should not be a full OpenClaw/Codex-style agent loop. +Planet's immediate needs are better served by explicit workflows: + +1. `tutorial_refresh` +2. `geo_correction` +3. `situation_brief` + +Each workflow should: + +1. collect evidence with backend tools +2. normalize and store evidence +3. call `AIProviderClient` through the configured global provider/model/key +4. validate the result with Pydantic schemas +5. return a proposal, candidate, or brief instead of directly mutating critical state + +For location correction, the flow should be: + +```text +object name / type / current coordinate / description + -> web_search + -> web_fetch for selected results + -> geo_resolve for city/site coordinates + -> LLM structured extraction + -> schema validation and confidence scoring + -> pending review candidate +``` + +The LLM output must be constrained to a schema such as: + +```json +{ + "object_id": "string", + "object_type": "datacenter|ixp|submarine_cable|asn|city|facility|satellite", + "current_location": { + "lat": 0, + "lon": 0 + }, + "suggested_location": { + "lat": 0, + "lon": 0 + }, + "confidence": 0.82, + "reason": "short evidence-backed explanation", + "evidence": [ + { + "title": "source title", + "url": "https://example.com/source", + "quote": "short supporting excerpt", + "retrieved_at": "2026-05-10T00:00:00Z" + } + ], + "needs_human_review": true +} +``` + +The LLM may generate a suggestion, but it must not directly write final +coordinates into the dimension tables. + + +## Phase 2: Backend Tool Registry + +Add a small Python tool interface in the backend: + +```python +class ToolResult(BaseModel): + ok: bool + data: Any = None + error: str | None = None + evidence: list[dict] = [] +``` + +Register tools through a backend registry: + +```text +web_search +web_fetch +geo_resolve +internal_data_query +incident_query +evidence_store +``` + +Do not put WebSearch inside `aiprovider`. + +Reasons: + +- search is a business tool, not a model-provider feature +- search evidence must be stored and audited by the backend +- different LLM providers should share the same search pipeline +- Planet may switch between Tavily, Brave, Exa, SearXNG, or MiniMax MCP without + changing model transport + +The first WebSearch implementation should be an HTTP evidence provider. Tavily is +the recommended first default because it is simple to call from the existing +`httpx` backend stack and returns LLM/RAG-friendly search results. The interface +should remain provider-neutral so Brave, Exa, SearXNG, or MiniMax MCP can be +added later. + +WebSearch configuration should live under PostgreSQL `system_settings` with the +rest of external integrations: + +```text +external_integrations.web_search + enabled + provider + api_key + base_url + max_results + timeout_seconds +``` + +Secret resolution should follow the existing settings pattern: + +1. saved PostgreSQL secret +2. provider-specific environment variable, for example `TAVILY_API_KEY` +3. generic fallback `WEB_SEARCH_API_KEY` + + +## Phase 3: Limited Agent Loop + +After the fixed workflows are stable, the backend can add a limited agent loop: + +```text +LLM sees an allowed tool list + -> LLM requests a tool call + -> backend validates and executes the tool + -> tool result is added to context + -> LLM continues + -> final structured output after at most N steps +``` + +Guardrails: + +- max tool steps: 3 to 5 +- only read-only tools may run automatically +- writes go to pending review first +- all web evidence must be persisted +- all final outputs must pass schema validation +- prompts must include explicit evidence boundaries + +Permission levels: + +```text +L0: pure analysis, no tools +L1: read-only tools, web_search / web_fetch / internal_query +L2: proposal generation, write pending review records +L3: low-risk notifications and briefs +L4: database mutation or alert triggering, human confirmation required +``` + + +## Situational Awareness Boundary + +Planet's situational-awareness layer should not rely on the LLM as the primary +risk engine. + +Use deterministic analysis for: + +- anomaly type +- affected prefixes +- affected ASNs +- geographic scope +- duration +- severity score +- confidence +- related events +- raw evidence + +Use the LLM for: + +- readable summaries +- risk explanation +- likely impact narrative +- next recommended actions +- missing data requests + +In short: + +```text +deterministic services compute the score +LLM explains the evidence and options +``` + +Proactive alerts should be triggered by deterministic rules or scheduled jobs, +then optionally summarized by the Agent Orchestrator. + + +## Persistence Model + +Add lightweight persistence for auditability: + +```text +ai_tasks + id + task_type + status + input_json + output_json + model + created_at + finished_at + error + +ai_evidence + id + task_id + source_type + title + url + snippet + content_hash + retrieved_at + credibility_score + +ai_briefs + id + brief_type + severity + title + summary + evidence_ids + related_entity_ids + created_at + acknowledged_at + +ai_location_suggestions + id + object_type + object_id + old_lat + old_lon + new_lat + new_lon + confidence + reason + evidence_ids + status +``` + +The tables can be introduced incrementally. The first implementation may start +with `ai_tasks` and `ai_evidence`, then add specialized tables when the UI needs +review queues and acknowledgement state. + + +## MVP Scope + +The MVP should deliver three fixed capabilities: + +### 1. Tutorial Refresh + +Input: + +- provider or tutorial topic +- current tutorial text +- known stale point, when available + +Tools: + +- `web_search` +- `web_fetch` + +Output: + +- updated Markdown +- source list +- verification status + +### 2. Geo Correction + +Input: + +- object id +- object name +- object type +- current coordinates +- source description + +Tools: + +- `web_search` +- `web_fetch` +- `geo_resolve` + +Output: + +- `LocationCorrection` JSON +- evidence list +- pending review candidate + +### 3. Situation Brief + +Input: + +- anomaly event +- deterministic findings +- internal data summary + +Tools: + +- `internal_data_query` +- optional `web_search` + +Output: + +- `SituationBrief` JSON +- risk explanation +- recommended actions +- missing evidence list + + +## Test Plan + +Backend tests: + +- WebSearch settings persist to `system_settings` and mask secrets in API responses. +- Env fallback resolves provider-specific keys before `WEB_SEARCH_API_KEY`. +- WebSearch provider normalizes success, empty results, 401, 429, and timeout responses. +- `tutorial_refresh` uses evidence when available and marks output unverified when no evidence exists. +- `geo_correction` returns pending review candidates and never writes final coordinates directly. +- `situation_brief` accepts deterministic findings and returns schema-valid summaries. +- Agent outputs fail closed when schema validation fails. + +Frontend tests: + +- WebSearch settings card shows configured state, masked key, connection test result, and save feedback. +- Candidate review UI can display evidence links and pending location suggestions. +- Situation brief UI can show evidence-backed summaries without exposing raw secrets. + +Regression tests: + +- existing `aiprovider` status and analysis calls remain unchanged +- current LLM provider configuration remains the global model source +- location pipeline tests continue to pass +- datasource credential guide tests continue to pass + + +## Assumptions + +- `aiprovider` remains model-adapter-only. +- Backend tools are implemented directly in Python first; MCP support is optional and later. +- Search is evidence collection, not model transport. +- Writes to important domain tables require human confirmation. +- Deterministic analysis owns risk scores; LLM output is explanatory and evidence-backed. diff --git a/docs/plans/earth-motion-capture-gesture-control-plan.md b/docs/plans/earth-motion-capture-gesture-control-plan.md new file mode 100644 index 00000000..0968fe0b --- /dev/null +++ b/docs/plans/earth-motion-capture-gesture-control-plan.md @@ -0,0 +1,191 @@ +# Earth Motion Capture Gesture Control Plan + +## Goal + +为 Planet Earth 大屏和未来 3D 展示增加一套解耦的动作捕捉手势控制能力。实时输入分成两条路线:网页端可直接通过浏览器 `getUserMedia` 在本机识别;高级设备可继续使用本机 Motion Capture Edge Agent。两条路线都只输出轻量语义事件,客户端负责把“手势事件”映射到“具体交互函数”。 + +首版面向两颗 Logitech C1000 RGB 摄像头,但必须保持单摄像头兼容。后续任何 USB 摄像头、手机摄像头、RTSP/HTTP/WebRTC 视频源都应通过输入适配器接入,而不是改 Earth 渲染端。 + +## Architecture + +实时链路分两种 provider,但进入 Earth 后协议一致: + +```text +Browser camera -> browser-local recognizer -> Motion Provider events -> Earth control functions +Camera(s)/RTSP/HTTP -> Local Motion Capture Agent -> local WebSocket -> Motion Provider events -> Earth control functions +``` + +关键原则: + +- 实时控制不经过 SaaS 云端。 +- 实时控制不复用现有新闻、RSS、聚合数据接口。 +- 浏览器 provider 和 Agent provider 都不向云端上传视频帧,只输出低带宽语义事件。 +- Web/3D 客户端只消费统一事件并执行映射,不把具体输入源写进 Earth 交互逻辑。 +- 双摄首版用于冗余和稳定性,不承诺完整 3D 姿态重建。 + +## Motion Providers + +Earth 使用统一 Motion Provider 抽象: + +- `browser_camera`:默认 provider。使用 `getUserMedia` 获取摄像头,在浏览器本地加载 MediaPipe Tasks Vision,输出 `gesture` / `skeleton` / `status` 事件。适合 SaaS、WSL、Windows 浏览器、大屏演示和“不安装 app”的用户。 +- `motion_agent`:连接本地 Agent WebSocket。适合双摄、USB index、RTSP/HTTP 视频源、边缘设备和客户端集成。 + +设置项保存在 `planet.earth.settings.v2.shared.motionProvider`。`?motionProvider=browser` 强制浏览器摄像头,`?motionProvider=agent` 或 `?motionAgent=ws://...` 强制 Motion Agent。 + +## Motion Capture Agent + +Agent 是本地 Edge 服务,职责包括: + +- 读取摄像头:默认 USB index,支持单摄、双摄和未来 URL 视频源。 +- 运行识别:首版使用 OpenCV + MediaPipe;识别引擎藏在接口后,未来可替换为 ONNX、TensorRT、C++ 或 Rust worker。 +- 输出事件:通过 WebSocket 推送 `gesture`、`status`、`heartbeat`。 +- 控制节流:负责置信度阈值、防抖、冷却时间和连续手势限频。 +- 健康状态:报告摄像头数量、当前模式、识别 FPS、最近手势和错误。 +- 明确失败:缺少 CV 依赖、摄像头打不开、无可用输入时给出可读错误。 + +Python 不应成为性能瓶颈:重计算在 OpenCV/MediaPipe 原生代码中完成,Python 只做编排、状态机和事件推送。事件消息通常小于 1KB,频率不超过 20Hz。 + +## Event Protocol + +本地默认地址: + +```text +ws://127.0.0.1:8765/ws/gestures +``` + +事件类型: + +- `gesture` +- `status` +- `heartbeat` + +手势语义: + +- `rotate_left`:左挥手,地球向左旋转。 +- `rotate_right`:右挥手,地球向右旋转。 +- `zoom_in`:双手张开,地球放大。 +- `zoom_out`:双手合拢,地球缩小。 +- `confirm`:握拳或确认动作,触发当前交互确认。 + +最小事件字段: + +```json +{ + "type": "gesture", + "gesture": "rotate_left", + "phase": "discrete", + "confidence": 0.92, + "intensity": 0.8, + "timestamp_ms": 1770000000000, + "seq": 42, + "source": "motion-agent", + "mode": "single", + "payload": {} +} +``` + +## Earth Client Integration + +Earth 前端新增 motion-control adapter: + +- 连接本地 Agent WebSocket。 +- 处理断线、重连、心跳和状态。 +- 过滤低置信度事件。 +- 将手势映射到 Earth 控制函数。 +- Agent 离线时不影响普通鼠标、触摸、巡航和图层交互。 + +Earth 端只暴露最小动作入口: + +- `applyMotionRotate(direction, intensity)` +- `applyMotionZoom(direction, intensity)` +- `applyMotionConfirm()` + +动作捕捉不直接操作 Three.js 内部对象,也不修改图层业务模块。 + +## SaaS Strategy + +未来网页端做成 SaaS 后,默认实时手势链路仍在浏览器本地完成,不走云端 RPC。高级现场设备可选本地 Agent: + +```text +Browser SaaS page -> getUserMedia -> browser-local recognizer +Browser SaaS page -> local secure bridge -> Local Motion Capture Agent (advanced) +Cloud SaaS -> config/auth/status only +``` + +原因: + +- 云端 RPC 会增加网络 RTT 和抖动。 +- 上传摄像头帧有隐私和带宽风险。 +- 大屏交互需要稳定体感延迟,云端只适合做配置、授权、设备状态和审计。 + +浏览器摄像头要求 HTTPS 或 localhost。Agent 模式在本地部署可使用 `ws://127.0.0.1:8765`;生产 HTTPS SaaS 若要接 Agent,需要补 `wss://127.0.0.1` 或等价本地安全桥接,避免浏览器混合内容限制。 + +## Latency Budget + +目标体感延迟: + +- 摄像头采集:16-33ms。 +- 识别:8-25ms。 +- 状态机:小于 2ms。 +- 本地 WebSocket:1-5ms。 +- 浏览器渲染:约 16ms。 + +实验室目标:从动作被识别到 Earth 响应 p95 小于 50ms;摄像头到画面响应端到端小于 120ms。 + +## Implementation Milestones + +1. 保存本计划并注册到 `docs/plans/README.md`。 +2. 新增独立 motion agent 包,提供 CLI、配置、摄像头输入抽象、事件模型和 WebSocket server。 +3. 新增手势状态机,支持阈值、防抖、冷却和限频。 +4. 新增 Earth motion-control provider manager,默认接浏览器摄像头 provider,可切换到 Motion Agent provider。 +5. 增加 Agent 单元测试、协议测试和前端 adapter 静态验证。 +6. 更新中英文用户手册和 Earth 前端开发上下文。 + +## Debug Mode Addition + +**当前状态**:Browser Camera provider 会在调试面板中显示本地 `