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..c5d4cee3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.49.0 +0.51.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..1b95160e 100644 --- a/backend/app/api/v1/bgp.py +++ b/backend/app/api/v1/bgp.py @@ -14,10 +14,17 @@ 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.api.v1.settings import get_web_search_client +from app.services.location.llm_fallback import ( + collect_llm_location_fallback_candidate, + collect_location_search_evidence, +) router = APIRouter() @@ -282,6 +289,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 +315,46 @@ 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, + ) + llm_result = None + try: + web_search_client = await get_web_search_client(db) + search_result = await collect_location_search_evidence( + web_search_client=web_search_client, + query=query, + entity_type="bgp_collector", + ) + attempted_queries = [*attempted_queries, *search_result.attempted_queries] + if not search_result.evidence: + llm_failure_reason = search_result.failure_reason + raise RuntimeError(search_result.failure_reason or "no WebSearch evidence") + 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, + search_evidence=search_result.evidence, + ) + except Exception as exc: + if llm_failure_reason is 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 +375,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..f65e8a6d 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -1,9 +1,12 @@ from copy import deepcopy from datetime import UTC, datetime +import os +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 +21,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, @@ -35,7 +39,18 @@ from app.services.datasource_connectivity import ( save_connectivity_success, ) from app.services.ai_client import AIProviderClient, get_ai_provider_client +from app.services.ai_tools.schemas import WebSearchConfig, WebSearchProviderConfig +from app.services.ai_tools.web_search import ( + WebSearchClient, + WebSearchConfigurationError, + WebSearchError, + get_web_search_provider_preset, + list_web_search_provider_presets, + normalize_web_search_provider, + provider_defaults as web_search_provider_defaults, +) 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,16 +85,16 @@ 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, - } + }, + "web_search": { + "enabled": False, + "default_provider": "tavily", + "providers": {}, + }, }, } @@ -141,6 +156,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) @@ -161,9 +177,31 @@ class BarentsWatchIntegrationUpdate(BaseModel): clear_client_secret: bool = False +class WebSearchIntegrationUpdate(BaseModel): + enabled: bool = False + default_provider: Optional[str] = None + provider: str = Field(default="tavily", max_length=80) + base_url: str = Field(default="", max_length=500) + api_key: Optional[str] = None + max_results: int = Field(default=5, ge=1, le=20) + timeout_seconds: int = Field(default=20, ge=3, le=120) + endpoint_path: str = Field(default="", max_length=200) + search_depth: str = Field(default="basic", max_length=40) + engine: str = Field(default="google", max_length=80) + include_answer: bool = False + include_raw_content: bool = False + include_text: bool = False + categories: str = Field(default="general", max_length=120) + engines: list[str] = Field(default_factory=list) + search_path: str = Field(default="", max_length=200) + scrape_path: str = Field(default="", max_length=200) + scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"]) + + class ExternalIntegrationsUpdate(BaseModel): ai_provider: AIProviderIntegrationUpdate barentswatch: BarentsWatchIntegrationUpdate + web_search: WebSearchIntegrationUpdate | None = None def merge_with_defaults(category: str, payload: Optional[dict]) -> dict: @@ -216,17 +254,412 @@ 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" +WEB_SEARCH_ENV_FILES = ( + Path(__file__).resolve().parents[4] / ".env", + Path(__file__).resolve().parents[3] / ".env", + AI_PROVIDER_ENV_FILE, +) + + +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 _read_web_search_env_files() -> dict[str, str]: + values: dict[str, str] = {} + for path in WEB_SEARCH_ENV_FILES: + if not path.exists(): + continue + values.update({ + key: str(value) + for key, value in dotenv_values(path).items() + if value is not None + }) + return values + + +def _resolve_web_search_env_secret(*names: str) -> tuple[str, str]: + env_file_values = _read_web_search_env_files() + for name in names: + if not name: + continue + value = env_file_values.get(name) + if value: + return value, "env_file" + value = os.environ.get(name) + if value: + return value, "env" + 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", + }, + } + + +def _web_search_provider_defaults(provider: str) -> dict: + return web_search_provider_defaults(provider).model_dump() + + +def _normalize_web_search_payload(web_search_payload: dict | None) -> dict: + raw = dict(web_search_payload or {}) + default_provider = normalize_web_search_provider( + raw.get("default_provider") or raw.get("provider") + ) + providers = { + normalize_web_search_provider(provider): dict(config or {}) + for provider, config in (raw.get("providers") or {}).items() + if provider + } + legacy_fields = { + key: raw.get(key) + for key in ( + "base_url", + "api_key", + "max_results", + "timeout_seconds", + "endpoint_path", + "search_depth", + "engine", + "include_answer", + "include_raw_content", + "include_text", + "categories", + "engines", + "search_path", + "scrape_path", + "scrape_formats", + ) + 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_web_search_provider(provider) + normalized_providers[provider_id] = { + **_web_search_provider_defaults(provider_id), + **dict(config or {}), + "provider": provider_id, + } + + if default_provider not in normalized_providers: + normalized_providers[default_provider] = _web_search_provider_defaults(default_provider) + + return { + "enabled": bool(raw.get("enabled", False)), + "default_provider": default_provider, + "providers": normalized_providers, + } + + +def _resolve_web_search_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_web_search_provider_preset(provider) + return _resolve_web_search_env_secret(preset.get("api_key_env") or "", "WEB_SEARCH_API_KEY") + + +def _build_web_search_payload( + current_payload: dict, + update: WebSearchIntegrationUpdate | None, +) -> dict: + current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {}) + if update is None: + return current_web_search + provider_id = normalize_web_search_provider(update.default_provider or update.provider) + current_providers = { + provider: dict(config or {}) + for provider, config in current_web_search.get("providers", {}).items() + } + current_provider = current_providers.get(provider_id) or _web_search_provider_defaults(provider_id) + current_key, current_key_source = _resolve_web_search_api_key(provider_id, current_provider) + current_key_preview = _mask_secret(current_key, current_key_source)["preview"] + provider_payload = { + **_web_search_provider_defaults(provider_id), + **current_provider, + "provider": provider_id, + "base_url": update.base_url.strip() + or current_provider.get("base_url") + or _web_search_provider_defaults(provider_id).get("base_url") + or "", + "max_results": update.max_results, + "timeout_seconds": update.timeout_seconds, + "endpoint_path": update.endpoint_path.strip() or current_provider.get("endpoint_path") or "", + "search_depth": update.search_depth.strip() or "basic", + "engine": update.engine.strip() or "google", + "include_answer": update.include_answer, + "include_raw_content": update.include_raw_content, + "include_text": update.include_text, + "categories": update.categories.strip() or "general", + "engines": update.engines, + "search_path": update.search_path.strip() or current_provider.get("search_path") or "", + "scrape_path": update.scrape_path.strip() or current_provider.get("scrape_path") or "", + "scrape_formats": update.scrape_formats or ["markdown"], + } + if not _is_secret_placeholder(update.api_key, current_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 + return { + "enabled": update.enabled, + "default_provider": provider_id, + "providers": current_providers, + } + + +def _runtime_config_from_web_search_payload(web_search_payload: dict) -> WebSearchConfig: + normalized = _normalize_web_search_payload(web_search_payload) + provider_id = normalized["default_provider"] + provider_config = normalized["providers"].get(provider_id) or _web_search_provider_defaults(provider_id) + api_key, _source = _resolve_web_search_api_key(provider_id, provider_config) + provider_models = { + provider: WebSearchProviderConfig(**{ + **config, + "api_key": ( + api_key if provider == provider_id else _resolve_web_search_api_key(provider, config)[0] + ), + }) + for provider, config in normalized["providers"].items() + } + return WebSearchConfig( + enabled=normalized["enabled"], + default_provider=provider_id, + provider=provider_id, + providers=provider_models, + ) + + +async def get_runtime_web_search_config(db: AsyncSession) -> WebSearchConfig: + runtime_record = await get_setting_record(db, "external_integrations") + payload = merge_with_defaults( + "external_integrations", + runtime_record.payload if runtime_record else None, + ) + return _runtime_config_from_web_search_payload(payload.get("web_search") or {}) + + +async def get_web_search_client(db: AsyncSession) -> WebSearchClient: + return WebSearchClient(await get_runtime_web_search_config(db)) async def get_runtime_ai_provider_config(db: AsyncSession) -> dict: @@ -235,31 +668,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 +678,59 @@ 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 {}) + normalized_web_search = _normalize_web_search_payload(raw_payload.get("web_search") 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) + web_search_providers_payload: dict[str, dict] = {} + for provider in sorted({ + *[preset["provider"] for preset in list_web_search_provider_presets()], + *normalized_web_search["providers"].keys(), + normalized_web_search["default_provider"], + }): + provider_id = normalize_web_search_provider(provider) + provider_config = ( + normalized_web_search["providers"].get(provider_id) + or _web_search_provider_defaults(provider_id) + ) + api_key, api_key_source = _resolve_web_search_api_key(provider_id, provider_config) + web_search_providers_payload[provider_id] = { + **{ + key: value + for key, value in provider_config.items() + if key != "api_key" + }, + "provider": provider_id, + "api_key": _mask_secret(api_key, api_key_source), + "source": "runtime" if provider_config.get("api_key") else (api_key_source or "preset"), + } + display_web_search_config = ( + web_search_providers_payload.get(normalized_web_search["default_provider"]) + or _web_search_provider_defaults(normalized_web_search["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 +738,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"], @@ -297,6 +760,28 @@ async def serialize_external_integrations(db: AsyncSession) -> dict: ), "source": resolved_barentswatch.credential_source, }, + "web_search": { + "enabled": normalized_web_search["enabled"], + "default_provider": normalized_web_search["default_provider"], + "provider": normalized_web_search["default_provider"], + "base_url": display_web_search_config.get("base_url") or "", + "api_key": display_web_search_config.get("api_key") or _mask_secret(None), + "providers": web_search_providers_payload, + "max_results": int(display_web_search_config.get("max_results") or 5), + "timeout_seconds": int(display_web_search_config.get("timeout_seconds") or 20), + "endpoint_path": display_web_search_config.get("endpoint_path") or "", + "search_depth": display_web_search_config.get("search_depth") or "basic", + "engine": display_web_search_config.get("engine") or "google", + "include_answer": bool(display_web_search_config.get("include_answer", False)), + "include_raw_content": bool(display_web_search_config.get("include_raw_content", False)), + "include_text": bool(display_web_search_config.get("include_text", False)), + "categories": display_web_search_config.get("categories") or "general", + "engines": display_web_search_config.get("engines") or [], + "search_path": display_web_search_config.get("search_path") or "", + "scrape_path": display_web_search_config.get("scrape_path") or "", + "scrape_formats": display_web_search_config.get("scrape_formats") or ["markdown"], + "source": "runtime" if runtime_setting else "env", + }, } @@ -305,31 +790,14 @@ 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) + web_search_payload = _build_web_search_payload(current_payload, update.web_search) - await save_setting_payload(db, "external_integrations", {"ai_provider": ai_payload}) + await save_setting_payload( + db, + "external_integrations", + {"ai_provider": ai_payload, "web_search": web_search_payload}, + ) default_endpoint = get_data_sources_config().get_yaml_url("barentswatch_vessels") barentswatch_record = await get_barentswatch_config_record(db) @@ -530,6 +998,158 @@ 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=["回复尽量简短。"], + ) + ) + current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {}) + await save_setting_payload( + db, + "external_integrations", + {"ai_provider": draft_ai_payload, "web_search": current_web_search}, + ) + 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("/integrations/web-search/presets") +async def get_web_search_presets( + current_user: User = Depends(get_current_user), +): + return {"data": list_web_search_provider_presets()} + + +@router.get("/integrations/web-search/secrets") +async def reveal_web_search_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") + web_search_payload = _normalize_web_search_payload(current_payload.get("web_search") or {}) + provider_id = normalize_web_search_provider(provider or web_search_payload["default_provider"]) + provider_config = ( + web_search_payload["providers"].get(provider_id) + or _web_search_provider_defaults(provider_id) + ) + api_key, api_key_source = _resolve_web_search_api_key(provider_id, provider_config) + return { + "provider": provider_id, + "api_key": api_key, + "api_key_source": api_key_source, + } + + +@router.post("/integrations/web-search/connect") +async def connect_web_search_integration( + payload: WebSearchIntegrationUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + current_payload = await get_setting_payload(db, "external_integrations") + draft_web_search_payload = _build_web_search_payload(current_payload, payload) + runtime_config = _runtime_config_from_web_search_payload(draft_web_search_payload) + client = WebSearchClient(runtime_config) + + try: + results = await client.test_connection() + return { + "success": True, + "connected": True, + "message": "WebSearch 连接成功。", + "provider": runtime_config.default_provider, + "results": [item.model_dump(mode="json") for item in results[:3]], + } + except WebSearchConfigurationError as exc: + return { + "success": False, + "connected": False, + "message": str(exc), + } + except WebSearchError as exc: + return { + "success": False, + "connected": False, + "message": str(exc), + } + except Exception as exc: + return { + "success": False, + "connected": False, + "message": f"WebSearch 连接测试失败: {exc}", + } + + @router.get("/credential-guides/{provider}") async def read_credential_guide( provider: str, @@ -550,7 +1170,15 @@ async def generate_provider_credential_guide( ai_client: AIProviderClient = Depends(get_ai_provider_client), ): try: - return {"guide": await generate_credential_guide(db, provider, ai_client)} + web_search_client = await get_web_search_client(db) + return { + "guide": await generate_credential_guide( + db, + provider, + ai_client, + web_search_client, + ) + } except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index 5eb07822..79a0a669 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -31,12 +31,19 @@ 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.api.v1.settings import get_web_search_client from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS +from app.services.location.llm_fallback import ( + collect_llm_location_fallback_candidate, + collect_location_search_evidence, +) from app.services.persistent_logs import record_system_log from app.services.vessel_ais_aggregation import ( build_field_conflict_candidates, @@ -1864,6 +1871,49 @@ 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, + ) + llm_result = None + try: + web_search_client = await get_web_search_client(db) + search_result = await collect_location_search_evidence( + web_search_client=web_search_client, + query=query, + entity_type="compute_center", + ) + attempted_queries = [*attempted_queries, *search_result.attempted_queries] + if not search_result.evidence: + llm_failure_reason = search_result.failure_reason + raise RuntimeError(search_result.failure_reason or "no WebSearch evidence") + 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, + search_evidence=search_result.evidence, + ) + except Exception as exc: + if llm_failure_reason is 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 +1927,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/ai_tools/__init__.py b/backend/app/services/ai_tools/__init__.py new file mode 100644 index 00000000..467a9360 --- /dev/null +++ b/backend/app/services/ai_tools/__init__.py @@ -0,0 +1,7 @@ +"""Backend-owned AI tool services. + +The services in this package are business tools used by Planet's backend +orchestrators. They intentionally live outside ``aiprovider`` so model transport +stays separate from evidence collection and domain policy. +""" + diff --git a/backend/app/services/ai_tools/evidence_store.py b/backend/app/services/ai_tools/evidence_store.py new file mode 100644 index 00000000..28a94420 --- /dev/null +++ b/backend/app/services/ai_tools/evidence_store.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import hashlib +from typing import Iterable + +from app.services.ai_tools.schemas import FetchedEvidence, SearchEvidence + + +def evidence_content_hash(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def normalize_search_evidence(items: Iterable[SearchEvidence], *, limit: int = 5) -> list[dict]: + normalized: list[dict] = [] + seen_urls: set[str] = set() + for item in items: + if not item.url or item.url in seen_urls: + continue + seen_urls.add(item.url) + normalized.append( + { + "title": item.title, + "url": item.url, + "snippet": item.snippet, + "content": item.compact_text(), + "score": item.score, + "source_provider": item.source_provider, + "retrieved_at": item.retrieved_at.isoformat(), + "metadata": item.metadata, + } + ) + if len(normalized) >= limit: + break + return normalized + + +def normalize_fetched_evidence(item: FetchedEvidence, *, text_limit: int = 1200) -> dict: + text = " ".join(item.text.split())[:text_limit] + return { + "title": item.title, + "url": item.final_url or item.url, + "text": text, + "content_hash": item.content_hash or evidence_content_hash(item.text), + "extractor": item.extractor, + "retrieved_at": item.retrieved_at.isoformat(), + "metadata": item.metadata, + } + diff --git a/backend/app/services/ai_tools/schemas.py b/backend/app/services/ai_tools/schemas.py new file mode 100644 index 00000000..49100641 --- /dev/null +++ b/backend/app/services/ai_tools/schemas.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class SearchEvidence(BaseModel): + title: str = "" + url: str = "" + snippet: str = "" + content: str = "" + score: float | None = None + source_provider: str = "" + retrieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + metadata: dict[str, Any] = Field(default_factory=dict) + + def compact_text(self, limit: int = 700) -> str: + text = " ".join((self.content or self.snippet or "").split()) + return text[:limit] + + +class FetchedEvidence(BaseModel): + url: str + final_url: str = "" + title: str = "" + text: str = "" + content_hash: str = "" + extractor: str = "basic_html" + retrieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class WebSearchProviderConfig(BaseModel): + provider: str = "tavily" + base_url: str = "" + api_key: str = "" + max_results: int = Field(default=5, ge=1, le=20) + timeout_seconds: int = Field(default=20, ge=3, le=120) + endpoint_path: str = "" + search_depth: str = "basic" + engine: str = "google" + include_answer: bool = False + include_raw_content: bool = False + include_text: bool = False + categories: str = "general" + engines: list[str] = Field(default_factory=list) + search_path: str = "" + scrape_path: str = "" + scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"]) + + +class WebSearchConfig(BaseModel): + enabled: bool = False + default_provider: str = "tavily" + provider: str = "tavily" + providers: dict[str, WebSearchProviderConfig] = Field(default_factory=dict) + + @property + def active_provider_config(self) -> WebSearchProviderConfig: + return self.providers.get(self.default_provider) or self.providers.get(self.provider) or WebSearchProviderConfig(provider=self.default_provider or self.provider) + diff --git a/backend/app/services/ai_tools/web_fetch.py b/backend/app/services/ai_tools/web_fetch.py new file mode 100644 index 00000000..53f69cc4 --- /dev/null +++ b/backend/app/services/ai_tools/web_fetch.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import hashlib + +import httpx +from bs4 import BeautifulSoup + +from app.services.ai_tools.schemas import FetchedEvidence + + +class WebFetchError(RuntimeError): + pass + + +def _extract_title_and_text(html: str) -> tuple[str, str]: + soup = BeautifulSoup(html, "html.parser") + for tag in soup(["script", "style", "noscript", "svg"]): + tag.decompose() + title = soup.title.get_text(" ", strip=True) if soup.title else "" + main = soup.find("main") or soup.find("article") or soup.body or soup + text = main.get_text("\n", strip=True) + lines = [line.strip() for line in text.splitlines() if line.strip()] + return title, "\n".join(lines) + + +async def fetch_url_evidence( + url: str, + *, + timeout_seconds: int = 20, + max_bytes: int = 1_500_000, +) -> FetchedEvidence: + if not url: + raise WebFetchError("url is required") + try: + async with httpx.AsyncClient( + timeout=timeout_seconds, + follow_redirects=True, + headers={"User-Agent": "PlanetEvidenceFetcher/1.0"}, + ) as client: + response = await client.get(url) + response.raise_for_status() + content = response.content[:max_bytes] + except httpx.HTTPError as exc: + raise WebFetchError(f"failed to fetch page: {exc}") from exc + + title, text = _extract_title_and_text(content.decode(response.encoding or "utf-8", errors="ignore")) + content_hash = hashlib.sha256(text.encode("utf-8")).hexdigest() + return FetchedEvidence( + url=url, + final_url=str(response.url), + title=title, + text=text, + content_hash=content_hash, + extractor="beautifulsoup_basic", + ) + diff --git a/backend/app/services/ai_tools/web_search.py b/backend/app/services/ai_tools/web_search.py new file mode 100644 index 00000000..1ab13dd0 --- /dev/null +++ b/backend/app/services/ai_tools/web_search.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +import httpx + +from app.services.ai_tools.schemas import SearchEvidence, WebSearchConfig, WebSearchProviderConfig + + +WEB_SEARCH_PROVIDER_PRESETS: dict[str, dict[str, Any]] = { + "tavily": { + "provider": "tavily", + "label": "Tavily", + "api_key_env": "TAVILY_API_KEY", + "base_url": "https://api.tavily.com", + "endpoint_path": "/search", + "max_results": 5, + "timeout_seconds": 20, + "search_depth": "basic", + "include_answer": False, + "include_raw_content": False, + }, + "brave": { + "provider": "brave", + "label": "Brave Search API", + "api_key_env": "BRAVE_SEARCH_API_KEY", + "base_url": "https://api.search.brave.com", + "endpoint_path": "/res/v1/web/search", + "max_results": 5, + "timeout_seconds": 20, + }, + "serpapi": { + "provider": "serpapi", + "label": "SerpAPI", + "api_key_env": "SERPAPI_API_KEY", + "base_url": "https://serpapi.com", + "endpoint_path": "/search.json", + "engine": "google", + "max_results": 5, + "timeout_seconds": 20, + }, + "exa": { + "provider": "exa", + "label": "Exa", + "api_key_env": "EXA_API_KEY", + "base_url": "https://api.exa.ai", + "endpoint_path": "/search", + "max_results": 5, + "timeout_seconds": 20, + "include_text": False, + }, + "firecrawl": { + "provider": "firecrawl", + "label": "Firecrawl Search / Scrape", + "api_key_env": "FIRECRAWL_API_KEY", + "base_url": "https://api.firecrawl.dev", + "search_path": "/v2/search", + "scrape_path": "/v2/scrape", + "max_results": 5, + "timeout_seconds": 30, + "scrape_formats": ["markdown"], + }, + "searxng": { + "provider": "searxng", + "label": "SearXNG", + "api_key_env": "SEARXNG_API_KEY", + "base_url": "http://localhost:8080", + "endpoint_path": "/", + "max_results": 5, + "timeout_seconds": 20, + "categories": "general", + "engines": [], + }, +} + + +class WebSearchError(RuntimeError): + pass + + +class WebSearchConfigurationError(WebSearchError): + pass + + +def normalize_web_search_provider(provider: str | None) -> str: + return (provider or "tavily").strip().lower() or "tavily" + + +def get_web_search_provider_preset(provider: str) -> dict[str, Any]: + provider_id = normalize_web_search_provider(provider) + preset = WEB_SEARCH_PROVIDER_PRESETS.get(provider_id) + if not preset: + raise ValueError(f"Unsupported web search provider: {provider}") + return deepcopy(preset) + + +def list_web_search_provider_presets() -> list[dict[str, Any]]: + return [get_web_search_provider_preset(provider) for provider in WEB_SEARCH_PROVIDER_PRESETS] + + +def provider_defaults(provider: str) -> WebSearchProviderConfig: + preset = get_web_search_provider_preset(provider) + return WebSearchProviderConfig(**{ + key: value + for key, value in preset.items() + if key in WebSearchProviderConfig.model_fields + }) + + +class WebSearchClient: + def __init__(self, config: WebSearchConfig) -> None: + self.config = config + + async def search( + self, + query: str, + *, + max_results: int | None = None, + domains: list[str] | None = None, + freshness_days: int | None = None, + ) -> list[SearchEvidence]: + if not self.config.enabled: + raise WebSearchConfigurationError("WebSearch is disabled.") + provider_config = self.config.active_provider_config + provider = normalize_web_search_provider(provider_config.provider) + if provider != "searxng" and not provider_config.api_key: + raise WebSearchConfigurationError(f"{provider} API key is not configured.") + query = " ".join(str(query or "").split()) + if not query: + raise WebSearchConfigurationError("search query is required.") + limit = max_results or provider_config.max_results + if provider == "tavily": + return await self._search_tavily(provider_config, query, limit, domains, freshness_days) + if provider == "brave": + return await self._search_brave(provider_config, query, limit, domains) + if provider == "serpapi": + return await self._search_serpapi(provider_config, query, limit) + if provider == "exa": + return await self._search_exa(provider_config, query, limit, domains) + if provider == "firecrawl": + return await self._search_firecrawl(provider_config, query, limit) + if provider == "searxng": + return await self._search_searxng(provider_config, query, limit, domains) + raise WebSearchConfigurationError(f"Unsupported web search provider: {provider}") + + async def test_connection(self) -> list[SearchEvidence]: + return await self.search("Planet WebSearch connectivity test", max_results=1) + + async def _request_json( + self, + method: str, + url: str, + *, + provider_config: WebSearchProviderConfig, + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + ) -> dict[str, Any]: + try: + async with httpx.AsyncClient(timeout=provider_config.timeout_seconds) as client: + response = await client.request( + method, + url, + headers=headers, + params=params, + json=json, + ) + response.raise_for_status() + data = response.json() + except httpx.HTTPStatusError as exc: + detail = exc.response.text or exc.response.reason_phrase + raise WebSearchError(f"{provider_config.provider} request failed: {detail}") from exc + except httpx.HTTPError as exc: + raise WebSearchError(f"{provider_config.provider} request failed: {exc}") from exc + except ValueError as exc: + raise WebSearchError(f"{provider_config.provider} returned invalid JSON") from exc + return data if isinstance(data, dict) else {} + + async def _search_tavily( + self, + config: WebSearchProviderConfig, + query: str, + max_results: int, + domains: list[str] | None, + freshness_days: int | None, + ) -> list[SearchEvidence]: + body: dict[str, Any] = { + "api_key": config.api_key, + "query": query, + "max_results": max_results, + "search_depth": config.search_depth or "basic", + "include_answer": config.include_answer, + "include_raw_content": config.include_raw_content, + } + if domains: + body["include_domains"] = domains + if freshness_days: + body["days"] = freshness_days + data = await self._request_json( + "POST", + _join_url(config.base_url, config.endpoint_path or "/search"), + provider_config=config, + json=body, + ) + return [ + SearchEvidence( + title=str(item.get("title") or ""), + url=str(item.get("url") or ""), + snippet=str(item.get("content") or ""), + content=str(item.get("raw_content") or ""), + score=_float_or_none(item.get("score")), + source_provider="tavily", + metadata={"query": data.get("query") or query}, + ) + for item in data.get("results") or [] + if isinstance(item, dict) and item.get("url") + ] + + async def _search_brave( + self, + config: WebSearchProviderConfig, + query: str, + max_results: int, + domains: list[str] | None, + ) -> list[SearchEvidence]: + search_query = query + if domains: + search_query = f"{query} " + " ".join(f"site:{domain}" for domain in domains) + data = await self._request_json( + "GET", + _join_url(config.base_url, config.endpoint_path or "/res/v1/web/search"), + provider_config=config, + headers={"X-Subscription-Token": config.api_key}, + params={"q": search_query, "count": max_results}, + ) + results = (data.get("web") or {}).get("results") or [] + return [ + SearchEvidence( + title=str(item.get("title") or ""), + url=str(item.get("url") or ""), + snippet=str(item.get("description") or ""), + source_provider="brave", + metadata={"age": item.get("age")}, + ) + for item in results + if isinstance(item, dict) and item.get("url") + ] + + async def _search_serpapi( + self, + config: WebSearchProviderConfig, + query: str, + max_results: int, + ) -> list[SearchEvidence]: + data = await self._request_json( + "GET", + _join_url(config.base_url, config.endpoint_path or "/search.json"), + provider_config=config, + params={ + "api_key": config.api_key, + "engine": config.engine or "google", + "q": query, + "num": max_results, + }, + ) + return [ + SearchEvidence( + title=str(item.get("title") or ""), + url=str(item.get("link") or ""), + snippet=str(item.get("snippet") or ""), + source_provider="serpapi", + metadata={"position": item.get("position")}, + ) + for item in data.get("organic_results") or [] + if isinstance(item, dict) and item.get("link") + ] + + async def _search_exa( + self, + config: WebSearchProviderConfig, + query: str, + max_results: int, + domains: list[str] | None, + ) -> list[SearchEvidence]: + body: dict[str, Any] = { + "query": query, + "numResults": max_results, + } + if domains: + body["includeDomains"] = domains + if config.include_text: + body["contents"] = {"text": True} + data = await self._request_json( + "POST", + _join_url(config.base_url, config.endpoint_path or "/search"), + provider_config=config, + headers={"Authorization": f"Bearer {config.api_key}"}, + json=body, + ) + return [ + SearchEvidence( + title=str(item.get("title") or ""), + url=str(item.get("url") or ""), + snippet=str(item.get("summary") or ""), + content=str(item.get("text") or ""), + score=_float_or_none(item.get("score")), + source_provider="exa", + metadata={"id": item.get("id")}, + ) + for item in data.get("results") or [] + if isinstance(item, dict) and item.get("url") + ] + + async def _search_firecrawl( + self, + config: WebSearchProviderConfig, + query: str, + max_results: int, + ) -> list[SearchEvidence]: + data = await self._request_json( + "POST", + _join_url(config.base_url, config.search_path or "/v2/search"), + provider_config=config, + headers={"Authorization": f"Bearer {config.api_key}"}, + json={"query": query, "limit": max_results}, + ) + raw_results = data.get("data") or data.get("results") or [] + return [ + SearchEvidence( + title=str(item.get("title") or ""), + url=str(item.get("url") or item.get("sourceURL") or ""), + snippet=str(item.get("description") or item.get("markdown") or ""), + source_provider="firecrawl", + metadata={"status": item.get("status")}, + ) + for item in raw_results + if isinstance(item, dict) and (item.get("url") or item.get("sourceURL")) + ] + + async def _search_searxng( + self, + config: WebSearchProviderConfig, + query: str, + max_results: int, + domains: list[str] | None, + ) -> list[SearchEvidence]: + search_query = query + if domains: + search_query = f"{query} " + " ".join(f"site:{domain}" for domain in domains) + params: dict[str, Any] = { + "q": search_query, + "format": "json", + "categories": config.categories or "general", + } + if config.engines: + params["engines"] = ",".join(config.engines) + headers = {"Authorization": f"Bearer {config.api_key}"} if config.api_key else None + data = await self._request_json( + "GET", + _join_url(config.base_url, config.endpoint_path or "/"), + provider_config=config, + headers=headers, + params=params, + ) + results = data.get("results") or [] + evidence = [ + SearchEvidence( + title=str(item.get("title") or ""), + url=str(item.get("url") or ""), + snippet=str(item.get("content") or ""), + score=_float_or_none(item.get("score")), + source_provider="searxng", + metadata={"engine": item.get("engine")}, + ) + for item in results + if isinstance(item, dict) and item.get("url") + ] + return evidence[:max_results] + + +def _join_url(base_url: str, path: str) -> str: + return f"{(base_url or '').rstrip('/')}/{(path or '').lstrip('/')}" + + +def _float_or_none(value: Any) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None + 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/credential_guides.py b/backend/app/services/credential_guides.py index 49ba6ce1..c17d4cf0 100644 --- a/backend/app/services/credential_guides.py +++ b/backend/app/services/credential_guides.py @@ -10,6 +10,8 @@ from sqlalchemy import select from app.models.system_setting import SystemSetting from app.schemas.ai import SituationalAnalysisRequest from app.services.ai_client import AIProviderClient +from app.services.ai_tools.evidence_store import normalize_search_evidence +from app.services.ai_tools.web_search import WebSearchClient, WebSearchError CREDENTIAL_GUIDES_CATEGORY = "collector_credential_guides" @@ -153,10 +155,26 @@ async def get_credential_guide(db, provider: str) -> dict[str, Any]: "markdown": custom.get("markdown") if custom else default.markdown, "prompt": default.prompt, "source": "ai" if custom else "default", + "sources": custom.get("sources", []) if custom else [], + "verification_status": ( + custom.get("verification_status", "verified_with_search_evidence") + if custom + else "default_unverified" + ), + "verification_error": custom.get("verification_error") if custom else None, } -async def save_credential_guide(db, provider: str, title: str, markdown: str) -> dict[str, Any]: +async def save_credential_guide( + db, + provider: str, + title: str, + markdown: str, + *, + sources: list[dict[str, Any]] | None = None, + verification_status: str = "verified_with_search_evidence", + verification_error: str | None = None, +) -> dict[str, Any]: default = DEFAULT_CREDENTIAL_GUIDES.get(provider) if default is None: raise ValueError(f"Unsupported credential guide provider: {provider}") @@ -165,6 +183,9 @@ async def save_credential_guide(db, provider: str, title: str, markdown: str) -> store[provider] = { "title": title or default.title, "markdown": markdown, + "sources": sources or [], + "verification_status": verification_status, + "verification_error": verification_error, } if record is None: db.add(SystemSetting(category=CREDENTIAL_GUIDES_CATEGORY, payload=store)) @@ -192,28 +213,58 @@ async def generate_credential_guide( db, provider: str, ai_client: AIProviderClient, + web_search_client: WebSearchClient | None = None, ) -> dict[str, Any]: default = DEFAULT_CREDENTIAL_GUIDES.get(provider) if default is None: raise ValueError(f"Unsupported credential guide provider: {provider}") + search_evidence: list[dict[str, Any]] = [] + search_error: str | None = None + if web_search_client is not None: + try: + evidence = await web_search_client.search( + _credential_guide_search_query(default), + max_results=5, + ) + search_evidence = normalize_search_evidence(evidence, limit=5) + except WebSearchError as exc: + search_error = str(exc) + except Exception as exc: + search_error = f"WebSearch unavailable: {exc}" + + if not search_evidence: + guide = await get_credential_guide(db, provider) + guide["verification_status"] = "unverified_no_search_evidence" + guide["verification_error"] = search_error + guide["sources"] = [] + return guide + response = await ai_client.analyze( SituationalAnalysisRequest( title=f"Generate credential guide for {provider}", - objective=default.prompt, + objective=( + default.prompt + + "\n只能根据 context.search_evidence 中的来源生成教程;" + + "如果证据不足,明确说明需要以官方页面为准。" + ), context={ "provider": provider, "current_default_guide": default.markdown, "product_context": "Planet collector credential settings", + "search_evidence": search_evidence, }, observations=[ "Use concise Chinese markdown.", "Prefer stable concepts over brittle UI labels.", "Include verification and troubleshooting steps.", + "Include a short sources section with the provided URLs.", ], constraints=[ "Do not ask the user for secrets.", "Do not include fabricated screenshots.", + "Do not invent source URLs or product UI labels.", + "Use only the provided search_evidence as factual support.", "Return markdown only.", ], ) @@ -221,4 +272,19 @@ async def generate_credential_guide( markdown = response.content.strip() if not markdown: markdown = default.markdown - return await save_credential_guide(db, provider, default.title, markdown) + return await save_credential_guide( + db, + provider, + default.title, + markdown, + sources=search_evidence, + verification_status="verified_with_search_evidence", + ) + + +def _credential_guide_search_query(default: CredentialGuideDefault) -> str: + if default.provider == "barentswatch": + return "BarentsWatch developer tutorial AIS API OAuth client credentials" + if default.provider == "aisstream": + return "AISStream API key documentation websocket stream" + return f"{default.provider} API credentials documentation" 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..812c4523 --- /dev/null +++ b/backend/app/services/location/llm_fallback.py @@ -0,0 +1,1064 @@ +"""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.ai_tools.evidence_store import normalize_search_evidence +from app.services.ai_tools.web_search import WebSearchClient, WebSearchError +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 LocationSearchEvidenceResult: + evidence: list[dict[str, Any]] + 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, + }, + }, + raw_payload={ + "llm_payload": payload, + "search_evidence": payload.get("search_evidence") or [], + }, + ), 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 + + +def _location_search_query(query: LocationQuery, entity_type: str) -> str: + extra = query.extra or {} + parts = [ + coerce_str(query.name), + coerce_str(extra.get("site")), + coerce_str(extra.get("operator")), + coerce_str(extra.get("organization")), + coerce_str(query.city), + coerce_str(query.country), + "physical location", + ] + if entity_type == "bgp_collector": + parts.append("route collector city") + elif entity_type == "compute_center": + parts.append("datacenter supercomputer facility city") + return " ".join(part for part in parts if part) + + +async def collect_location_search_evidence( + *, + web_search_client: WebSearchClient, + query: LocationQuery, + entity_type: str, + max_results: int = 5, +) -> LocationSearchEvidenceResult: + search_query = _location_search_query(query, entity_type) + attempt = f"web_search:{entity_type}:{search_query}" + try: + evidence = await web_search_client.search(search_query, max_results=max_results) + except WebSearchError as exc: + return LocationSearchEvidenceResult( + evidence=[], + attempted_queries=[attempt], + failure_reason=f"WebSearch location evidence failed: {exc}", + ) + except Exception as exc: + return LocationSearchEvidenceResult( + evidence=[], + attempted_queries=[attempt], + failure_reason=f"WebSearch location evidence unavailable: {exc}", + ) + normalized = normalize_search_evidence(evidence, limit=max_results) + if not normalized: + return LocationSearchEvidenceResult( + evidence=[], + attempted_queries=[attempt], + failure_reason="WebSearch returned no usable location evidence.", + ) + return LocationSearchEvidenceResult( + evidence=normalized, + attempted_queries=[attempt], + failure_reason=None, + ) + + +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] = (), + search_evidence: list[dict[str, Any]] | None = None, + 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'}" + if search_evidence is not None and not search_evidence: + return LocationLLMFallbackResult( + candidates=[], + attempted_queries=[attempt], + failure_reason="LLM location factcheck skipped: no WebSearch evidence.", + ) + 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), + "search_evidence": search_evidence or [], + "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.", + "If search_evidence is provided, use only that evidence as factual support.", + "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) + if search_evidence: + payload["search_evidence"] = search_evidence + existing_evidence = _evidence_items(payload.get("evidence")) + payload["evidence"] = [ + *existing_evidence, + *[ + { + "source": item.get("title") or item.get("url"), + "url": item.get("url"), + "text": item.get("snippet") or item.get("content"), + "source_type": "web_search", + "entity_match": True, + } + for item in search_evidence + if isinstance(item, dict) + ], + ] + 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/location/models.py b/backend/app/services/location/models.py index ab626009..f0aa600f 100644 --- a/backend/app/services/location/models.py +++ b/backend/app/services/location/models.py @@ -51,6 +51,7 @@ class LocationCandidate: matched_location_name: str | None = None location_verified_at: str | None = None suggested_registry_entry: dict[str, Any] | None = None + raw_payload: dict[str, Any] | None = None def to_dict(self) -> dict[str, Any]: return { @@ -70,6 +71,7 @@ class LocationCandidate: "matched_location_name": self.matched_location_name, "location_verified_at": self.location_verified_at, "suggested_registry_entry": self.suggested_registry_entry, + "raw_payload": self.raw_payload, } 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..585cdd03 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,75 @@ 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"]), + ) + + from app.services.location.llm_fallback import LocationSearchEvidenceResult + + async def _search_evidence(**_kwargs): + return LocationSearchEvidenceResult( + evidence=[ + { + "title": "RRC source", + "url": "https://example.test/rrc", + "snippet": "rrc-mystery is in Lyon.", + } + ], + attempted_queries=["web_search:bgp_collector:rrc-mystery Lyon France physical location route collector city"], + ) + + 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, "get_web_search_client", AsyncMock(return_value=object())) + monkeypatch.setattr(bgp_api, "collect_location_search_evidence", _search_evidence) + 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", + "web_search:bgp_collector:rrc-mystery Lyon France physical location route collector city", + "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..1c989228 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,117 @@ 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, LocationSearchEvidenceResult + + async def _search_evidence(**_kwargs): + return LocationSearchEvidenceResult( + evidence=[ + { + "title": "Mystery Cluster source", + "url": "https://example.test/mystery", + "snippet": "Mystery Cluster is in Lyon.", + } + ], + attempted_queries=["web_search:compute_center:Mystery Cluster France physical location"], + ) + + 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, "get_web_search_client", AsyncMock(return_value=object())) + monkeypatch.setattr(visualization_api, "collect_location_search_evidence", _search_evidence) + 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", + "web_search:compute_center:Mystery Cluster France physical location", + "llm_factcheck:compute_center:Mystery Cluster", + ] + + @pytest.mark.asyncio async def test_compute_centers_geojson_endpoint_returns_stats(): records = [ diff --git a/backend/tests/test_web_search_tools.py b/backend/tests/test_web_search_tools.py new file mode 100644 index 00000000..1e25c8b3 --- /dev/null +++ b/backend/tests/test_web_search_tools.py @@ -0,0 +1,261 @@ +from types import SimpleNamespace + +import pytest + +from app.api.v1 import settings as settings_api +from app.api.v1.settings import ( + WebSearchIntegrationUpdate, + _build_web_search_payload, + _mask_secret, + _normalize_web_search_payload, + _resolve_web_search_api_key, +) +from app.services.ai_tools.schemas import WebSearchConfig, WebSearchProviderConfig +from app.services.ai_tools.web_search import WebSearchClient +from app.services.credential_guides import generate_credential_guide +from app.services.location.llm_fallback import ( + collect_llm_location_fallback_candidate, + collect_location_search_evidence, +) +from app.services.location.models import LocationQuery + + +@pytest.fixture(autouse=True) +def isolated_web_search_env_files(monkeypatch, tmp_path): + env_file = tmp_path / ".env" + monkeypatch.setattr(settings_api, "WEB_SEARCH_ENV_FILES", (env_file,)) + return env_file + + +def test_normalize_web_search_payload_adds_default_provider(): + payload = _normalize_web_search_payload({}) + + assert payload["default_provider"] == "tavily" + assert payload["providers"]["tavily"]["base_url"] == "https://api.tavily.com" + + +def test_web_search_key_prefers_provider_env(isolated_web_search_env_files): + isolated_web_search_env_files.write_text( + "TAVILY_API_KEY=tavily-env-key\nWEB_SEARCH_API_KEY=generic-search-key\n", + encoding="utf-8", + ) + + value, source = _resolve_web_search_api_key("tavily", {"api_key": ""}) + + assert value == "tavily-env-key" + assert source == "env_file" + + +def test_build_web_search_payload_keeps_saved_key_when_preview_submitted(): + current = { + "web_search": { + "default_provider": "tavily", + "providers": { + "tavily": { + "provider": "tavily", + "api_key": "tvly-old-secret", + }, + }, + } + } + update = WebSearchIntegrationUpdate( + enabled=True, + provider="tavily", + base_url="https://api.tavily.com", + api_key=_mask_secret("tvly-old-secret")["preview"], + ) + + payload = _build_web_search_payload(current, update) + + assert payload["enabled"] is True + assert payload["providers"]["tavily"]["api_key"] == "tvly-old-secret" + + +@pytest.mark.asyncio +async def test_tavily_adapter_normalizes_results(monkeypatch): + config = WebSearchConfig( + enabled=True, + default_provider="tavily", + provider="tavily", + providers={ + "tavily": WebSearchProviderConfig( + provider="tavily", + base_url="https://api.tavily.com", + api_key="key", + ) + }, + ) + client = WebSearchClient(config) + + async def fake_request_json(*args, **kwargs): + return { + "query": "Alem.Cloud", + "results": [ + { + "title": "Alem.Cloud official", + "url": "https://example.test/alem", + "content": "Alem.Cloud is in Astana.", + "score": 0.9, + } + ], + } + + monkeypatch.setattr(client, "_request_json", fake_request_json) + + results = await client.search("Alem.Cloud") + + assert results[0].source_provider == "tavily" + assert results[0].url == "https://example.test/alem" + assert "Astana" in results[0].snippet + + +@pytest.mark.asyncio +async def test_searxng_adapter_allows_empty_api_key(monkeypatch): + config = WebSearchConfig( + enabled=True, + default_provider="searxng", + provider="searxng", + providers={ + "searxng": WebSearchProviderConfig( + provider="searxng", + base_url="http://localhost:8080", + api_key="", + ) + }, + ) + client = WebSearchClient(config) + + async def fake_request_json(*args, **kwargs): + return { + "results": [ + { + "title": "TAIPEI-1", + "url": "https://example.test/taipei", + "content": "TAIPEI-1 is in Taipei.", + "score": 2, + "engine": "duckduckgo", + } + ], + } + + monkeypatch.setattr(client, "_request_json", fake_request_json) + + results = await client.search("TAIPEI-1") + + assert results[0].source_provider == "searxng" + assert results[0].metadata["engine"] == "duckduckgo" + + +@pytest.mark.asyncio +async def test_location_search_evidence_returns_failure_on_empty_results(monkeypatch): + class EmptySearchClient: + async def search(self, *args, **kwargs): + return [] + + result = await collect_location_search_evidence( + web_search_client=EmptySearchClient(), + query=LocationQuery(name="TAIPEI-1", country="Taiwan"), + entity_type="compute_center", + ) + + assert result.evidence == [] + assert "no usable" in result.failure_reason + + +@pytest.mark.asyncio +async def test_llm_location_fallback_skips_when_search_evidence_empty(): + class ExplodingAIClient: + async def analyze(self, *_args, **_kwargs): + raise AssertionError("LLM should not be called without evidence") + + result = await collect_llm_location_fallback_candidate( + provider_client=ExplodingAIClient(), + query=LocationQuery(name="TAIPEI-1", country="Taiwan"), + entity_type="compute_center", + search_evidence=[], + ) + + assert result.candidates == [] + assert "no WebSearch evidence" in result.failure_reason + + +@pytest.mark.asyncio +async def test_credential_guide_keeps_default_without_search_evidence(): + class EmptySearchClient: + async def search(self, *args, **kwargs): + return [] + + class ExplodingAIClient: + async def analyze(self, *_args, **_kwargs): + raise AssertionError("AI should not be called without search evidence") + + async def fake_get_store(_db): + return None, {} + + import app.services.credential_guides as credential_guides + + original = credential_guides._get_guide_store + credential_guides._get_guide_store = fake_get_store + try: + guide = await generate_credential_guide( + object(), + "barentswatch", + ExplodingAIClient(), + EmptySearchClient(), + ) + finally: + credential_guides._get_guide_store = original + + assert guide["source"] == "default" + assert guide["verification_status"] == "unverified_no_search_evidence" + + +@pytest.mark.asyncio +async def test_credential_guide_uses_search_evidence(monkeypatch): + class SearchClient: + async def search(self, *args, **kwargs): + from app.services.ai_tools.schemas import SearchEvidence + + return [ + SearchEvidence( + title="Official docs", + url="https://docs.example.test", + snippet="Create an AIS client.", + source_provider="tavily", + ) + ] + + class AIClient: + async def analyze(self, payload): + assert payload.context["search_evidence"] + return SimpleNamespace(content="## Generated\n\nSources included.") + + saved = {} + + async def fake_get_store(_db): + return None, saved + + async def fake_save(db, provider, title, markdown, **metadata): + return { + "provider": provider, + "title": title, + "markdown": markdown, + "source": "ai", + **metadata, + } + + import app.services.credential_guides as credential_guides + + monkeypatch.setattr(credential_guides, "_get_guide_store", fake_get_store) + monkeypatch.setattr(credential_guides, "save_credential_guide", fake_save) + + guide = await generate_credential_guide( + object(), + "barentswatch", + AIClient(), + SearchClient(), + ) + + assert guide["source"] == "ai" + assert guide["verification_status"] == "verified_with_search_evidence" + assert guide["sources"][0]["url"] == "https://docs.example.test" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a20fd6f1..76f0066a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,38 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.51.0] — 2026-05-11 + +Released: 2026-05-11 + +### ✨ Highlights +- 新增 AI Settings 控制台页面与 `backend/app/services/ai_tools/` 工具层,串通 Web Search Provider 与轻量 Agent orchestrator。 +- 重写 Earth 算力中心候选「预览 / 保存」交互:单一委托 click + 内存 candidate Map,新增空心呼吸圈预览,保存后即时生成正式图标,后台刷新失败不再误报为保存失败。 +- 重写动作捕捉 zoom 识别:mirror-safe 的 trend + pose hold 双通道,张开/合拢手势直接对应 zoom_in/out 并支持持续触发;单臂 rotate 仅在另一只手明确静止时才允许。 + +### Improvements +- 同步中英文 `earth-frontend-context.md`、`frontend-admin-frontend-context.md`、`faq.md`、`manual.md`、`quickstart.md`。 +- Earth 模块多处优化:bgp-cruise-adapter、interactable、satellites、presentation-controller、controls 调整与回归测试补全。 + +--- + +## [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..4ef11dfa --- /dev/null +++ b/docs/plans/agents-light-orchestrator-websearch-plan.md @@ -0,0 +1,618 @@ +# 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` + +### Common WebSearch Providers + +The first implementation should model WebSearch as a provider-specific adapter +behind one internal interface: + +```text +SearchEvidenceProvider.search(query, max_results, domains, freshness_days) + -> list[SearchEvidence] +``` + +Recommended provider ids and environment variables: + +| Provider | Provider id | Env key | Default base URL | Primary use | +| --- | --- | --- | --- | --- | +| Tavily | `tavily` | `TAVILY_API_KEY` | `https://api.tavily.com` | Default hosted search for agent/RAG style results | +| Brave Search API | `brave` | `BRAVE_SEARCH_API_KEY` | `https://api.search.brave.com` | Independent web index and low-level SERP results | +| SerpAPI | `serpapi` | `SERPAPI_API_KEY` | `https://serpapi.com` | Search-engine-backed SERP data with engine options | +| Exa | `exa` | `EXA_API_KEY` | `https://api.exa.ai` | Neural/semantic web search and result contents | +| Firecrawl Search / Scrape | `firecrawl` | `FIRECRAWL_API_KEY` | `https://api.firecrawl.dev` | Search plus page scrape/markdown extraction | +| SearXNG | `searxng` | optional `SEARXNG_API_KEY` | self-hosted instance URL | Self-hosted metasearch when external search APIs are undesirable | + +The normalized configuration should support per-provider defaults while keeping +one active provider: + +```text +external_integrations.web_search + enabled: true + default_provider: tavily + providers: + tavily: + base_url: https://api.tavily.com + api_key: + max_results: 5 + search_depth: basic + include_answer: false + include_raw_content: false + brave: + base_url: https://api.search.brave.com + api_key: + endpoint_path: /res/v1/web/search + max_results: 5 + serpapi: + base_url: https://serpapi.com + api_key: + endpoint_path: /search.json + engine: google + max_results: 5 + exa: + base_url: https://api.exa.ai + api_key: + endpoint_path: /search + max_results: 5 + include_text: false + firecrawl: + base_url: https://api.firecrawl.dev + api_key: + search_path: /v2/search + scrape_path: /v2/scrape + max_results: 5 + scrape_formats: [markdown] + searxng: + base_url: http://localhost:8080 + api_key: + endpoint_path: / + max_results: 5 + categories: general + engines: [] +``` + +Adapter notes: + +- Tavily should call `/search` and normalize title, URL, snippet/content, score, + and optional raw content. +- Brave should call `/res/v1/web/search` and map web results into the same + `SearchEvidence` shape. +- SerpAPI should call `/search.json`, pass `engine`, and normalize organic + results. Search-engine-specific fields should remain in provider metadata. +- Exa should call `/search`; optional result text should be treated as fetched + content only when enabled. +- Firecrawl can be used both as `web_search` and `web_fetch`: `/v2/search` + returns result URLs/descriptions and may include scrape options, while + `/v2/scrape` can produce markdown for a selected URL. +- SearXNG should query the configured instance with `q` and `format=json`. + Public instances should not be assumed reliable for production; a controlled + self-hosted instance is preferred. + +The settings UI should expose only provider, base URL, key, max results, and a +test button in the first version. Provider-specific advanced fields can stay +collapsed or backend-only until a real workflow needs them. + +### Frontend Configuration Window + +Add a WebSearch configuration panel to the existing settings page, next to the +LLM provider configuration. It should behave like the current AI provider secret +controls: clear configured state, masked preview, explicit show/hide, test +connection, and save feedback. + +First-version visible fields: + +```text +WebSearch Provider +API Base URL +API Key +Max Results +Timeout Seconds +Enable WebSearch +Test Connection +Save +``` + +Provider dropdown options: + +```text +Tavily +Brave Search API +SerpAPI +Exa +Firecrawl Search / Scrape +SearXNG +``` + +Field behavior: + +- Switching provider loads that provider's saved config and masked key preview. +- Empty key input means keep the existing saved or environment key. +- Typing a new key replaces only the selected provider's key. +- Show key reveals the full current input value when the backend reveal endpoint + allows it; hide key returns to the prefix-preserving masked preview. +- The configured badge should only show `已配置` or `未配置`, not repeat the + masked key text. +- `Test Connection` sends the current unsaved draft to the backend and should + not require a separate save first. +- A successful test may save the draft as the new WebSearch default only if the + API endpoint is explicitly designed to mirror the AI provider test behavior. + Otherwise, test should be read-only and the Save button should persist. +- Save success and test success must show visible feedback. Failures should show + provider-specific but secret-safe error messages. + +Provider-specific UI hints: + +| Provider | UI hint | +| --- | --- | +| Tavily | Good default for agent/RAG style search. | +| Brave Search API | Uses Brave's independent search index. | +| SerpAPI | Supports search-engine-specific parameters such as `engine`. | +| Exa | Good for semantic search and optional result text. | +| Firecrawl | Can search and scrape pages into markdown. | +| SearXNG | Requires a reachable self-hosted or trusted instance URL. | + +Advanced fields can live in a collapsed section: + +```text +Endpoint Path +Search Depth +Engine +Categories +Engines +Include Raw Content +Scrape Formats +Domain Allowlist +``` + +The first version should keep the UI conservative. It should not expose every +provider knob until backend workflows use those knobs. + +### Web Fetch and Page Extraction + +`web_fetch` is separate from `web_search`. Search finds candidate URLs; fetch +turns selected pages into clean, citable evidence. + +Recommended extraction chain: + +```text +1. plain httpx fetch +2. trafilatura extraction for static HTML +3. readability extraction as secondary cleanup +4. Playwright fetch only for allowlisted JS-heavy pages +5. Firecrawl scrape as hosted fallback when configured +``` + +Implementation guidance: + +- Use `trafilatura` as the first local extractor because it is Python-native and + matches the backend stack. +- Prefer a Python readability implementation for local cleanup. Do not introduce + a Node-only readability dependency for backend fetch. +- Use Playwright sparingly for JavaScript-rendered pages. It should have domain + allowlists, low concurrency, strict timeouts, response size limits, and no + automatic form submission or login behavior. +- Store `content_hash`, `retrieved_at`, final URL, title, extracted text + preview, and extractor name in `ai_evidence`. +- Keep short quotes for UI review, but do not store huge page bodies directly in + every task record. Large extracted content should be truncated or stored once + by hash. + +The local/self-hosted stack should look like this: + +```text +SearXNG + -> SearchEvidence URLs + -> httpx fetch + -> trafilatura / readability + -> Playwright only when static extraction fails and the domain is allowed + -> normalized evidence + -> LLM structured output through AIProviderClient +``` + +This route gives Planet a lower-cost and more controllable search path, while +hosted providers remain available when search quality or maintenance effort +matters more than self-hosting. + + +## 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 会在调试面板中显示本地 `