1624 lines
61 KiB
Python
1624 lines
61 KiB
Python
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, Query
|
|
from pydantic import BaseModel, EmailStr, Field
|
|
from dotenv import dotenv_values
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.security import get_current_user
|
|
from app.core.time import to_iso8601_utc
|
|
from app.core.config import settings as app_settings
|
|
from app.core.data_sources import get_data_sources_config
|
|
from app.core.datasource_defaults import DEFAULT_DATASOURCES
|
|
from app.ai_tasks.prompts import (
|
|
get_effective_prompt,
|
|
list_effective_prompts,
|
|
reset_prompt_override,
|
|
save_prompt_override,
|
|
serialize_effective_prompt,
|
|
)
|
|
from app.db.session import get_db
|
|
from app.models.datasource import DataSource
|
|
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,
|
|
check_barentswatch_connectivity,
|
|
get_barentswatch_datasource_record,
|
|
resolve_barentswatch_config,
|
|
)
|
|
from app.services.credential_guides import (
|
|
generate_credential_guide,
|
|
get_credential_guide,
|
|
reset_credential_guide,
|
|
)
|
|
from app.services.datasource_connectivity import (
|
|
build_builtin_connectivity_checksum,
|
|
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,
|
|
)
|
|
from app.services.scheduler import sync_datasource_job
|
|
from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload, normalize_tv_settings
|
|
|
|
router = APIRouter()
|
|
AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS = 5
|
|
AI_CONNECTION_TEST_PROMPT_KEY = "ai.connection_test"
|
|
|
|
DEFAULT_SETTINGS = {
|
|
"system": {
|
|
"system_name": "智能星球",
|
|
"refresh_interval": 60,
|
|
"auto_refresh": True,
|
|
"data_retention_days": 30,
|
|
"max_concurrent_tasks": 5,
|
|
},
|
|
"notifications": {
|
|
"email_enabled": False,
|
|
"email_address": "",
|
|
"critical_alerts": True,
|
|
"warning_alerts": True,
|
|
"daily_summary": False,
|
|
},
|
|
"security": {
|
|
"session_timeout": 60,
|
|
"max_login_attempts": 5,
|
|
"password_policy": "medium",
|
|
},
|
|
"tv": DEFAULT_TV_SETTINGS,
|
|
"smtp": {
|
|
"host": "",
|
|
"port": 587,
|
|
"username": "",
|
|
"password": "",
|
|
"from_address": "",
|
|
"from_name": "Planet",
|
|
"use_tls": False,
|
|
"use_starttls": True,
|
|
"timeout_seconds": 20,
|
|
},
|
|
"external_integrations": {
|
|
"ai_provider": {
|
|
"service_url": "",
|
|
"service_token": "",
|
|
"default_provider": "minimax",
|
|
"providers": {},
|
|
"timeout_seconds": 60,
|
|
"retry_attempts": 2,
|
|
},
|
|
"web_search": {
|
|
"enabled": False,
|
|
"default_provider": "tavily",
|
|
"providers": {},
|
|
},
|
|
"ocr": {
|
|
"enabled": False,
|
|
"provider": "paddleocr",
|
|
"base_url": "",
|
|
"api_key": "",
|
|
"model": "",
|
|
"languages": ["zh", "en"],
|
|
"timeout_seconds": 30,
|
|
"max_file_size_mb": 20,
|
|
"output_format": "markdown",
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
class SystemSettingsUpdate(BaseModel):
|
|
system_name: str = "智能星球"
|
|
refresh_interval: int = Field(default=60, ge=10, le=3600)
|
|
auto_refresh: bool = True
|
|
data_retention_days: int = Field(default=30, ge=1, le=3650)
|
|
max_concurrent_tasks: int = Field(default=5, ge=1, le=50)
|
|
|
|
|
|
class NotificationSettingsUpdate(BaseModel):
|
|
email_enabled: bool = False
|
|
email_address: Optional[EmailStr] = None
|
|
critical_alerts: bool = True
|
|
warning_alerts: bool = True
|
|
daily_summary: bool = False
|
|
|
|
|
|
class SecuritySettingsUpdate(BaseModel):
|
|
session_timeout: int = Field(default=60, ge=5, le=1440)
|
|
max_login_attempts: int = Field(default=5, ge=1, le=20)
|
|
password_policy: str = Field(default="medium")
|
|
|
|
|
|
class CollectorSettingsUpdate(BaseModel):
|
|
is_active: bool
|
|
priority: str = Field(default="P1")
|
|
frequency_minutes: int = Field(default=60, ge=1, le=10080)
|
|
|
|
|
|
class TVStreamSourceUpdate(BaseModel):
|
|
id: str = Field(min_length=1, max_length=100)
|
|
name: str = Field(min_length=1, max_length=200)
|
|
provider: str = Field(default="Unknown", max_length=100)
|
|
region: str = Field(default="Global", max_length=100)
|
|
language: str = Field(default="und", max_length=32)
|
|
source_type: str = Field(default="iframe", pattern="^(iframe|hls|video|external|youtube)$")
|
|
embed_url: str = ""
|
|
stream_url: str = ""
|
|
homepage_url: str = ""
|
|
poster_url: str = ""
|
|
youtube_video_id: str = ""
|
|
youtube_channel: str = ""
|
|
is_enabled: bool = True
|
|
is_fallback: bool = False
|
|
sort_order: int = Field(default=10, ge=0, le=9999)
|
|
collector_source: Optional[str] = None
|
|
notes: str = ""
|
|
|
|
|
|
class TVSettingsUpdate(BaseModel):
|
|
default_source_id: str = Field(default=DEFAULT_TV_SETTINGS["default_source_id"], min_length=1)
|
|
auto_fallback: bool = True
|
|
sources: list[TVStreamSourceUpdate] = Field(default_factory=list)
|
|
|
|
|
|
class SMTPSettingsUpdate(BaseModel):
|
|
host: str = Field(default="", max_length=255)
|
|
port: int = Field(default=587, ge=1, le=65535)
|
|
username: str = Field(default="", max_length=255)
|
|
password: Optional[str] = None
|
|
clear_password: bool = False
|
|
from_address: str = Field(default="", max_length=255)
|
|
from_name: str = Field(default="Planet", max_length=120)
|
|
use_tls: bool = False
|
|
use_starttls: bool = True
|
|
timeout_seconds: int = Field(default=20, ge=3, le=300)
|
|
|
|
|
|
class SMTPTestRequest(BaseModel):
|
|
to: EmailStr
|
|
settings: Optional[SMTPSettingsUpdate] = None
|
|
|
|
|
|
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)
|
|
model: str = Field(default="", max_length=200)
|
|
api_key: Optional[str] = None
|
|
max_tokens: int = Field(default=1200, ge=1, le=200000)
|
|
anthropic_version: str = Field(default="2023-06-01", max_length=40)
|
|
timeout_seconds: int = Field(default=60, ge=5, le=600)
|
|
retry_attempts: int = Field(default=2, ge=1, le=10)
|
|
clear_service_token: bool = False
|
|
clear_api_key: bool = False
|
|
|
|
|
|
class BarentsWatchIntegrationUpdate(BaseModel):
|
|
endpoint: str = ""
|
|
client_id: str = ""
|
|
client_secret: Optional[str] = None
|
|
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 OCRIntegrationUpdate(BaseModel):
|
|
enabled: bool = False
|
|
provider: str = Field(default="paddleocr", max_length=80)
|
|
base_url: str = Field(default="", max_length=500)
|
|
api_key: Optional[str] = None
|
|
model: str = Field(default="", max_length=200)
|
|
languages: list[str] = Field(default_factory=lambda: ["zh", "en"])
|
|
timeout_seconds: int = Field(default=30, ge=3, le=300)
|
|
max_file_size_mb: int = Field(default=20, ge=1, le=200)
|
|
output_format: str = Field(default="markdown", pattern="^(markdown|text|json)$")
|
|
|
|
|
|
class AIPromptUpdate(BaseModel):
|
|
system_prompt: str = Field(default="", max_length=8000)
|
|
prompt: str = Field(min_length=1, max_length=20000)
|
|
|
|
|
|
class ExternalIntegrationsUpdate(BaseModel):
|
|
ai_provider: AIProviderIntegrationUpdate
|
|
barentswatch: BarentsWatchIntegrationUpdate
|
|
web_search: WebSearchIntegrationUpdate | None = None
|
|
ocr: OCRIntegrationUpdate | None = None
|
|
|
|
|
|
def merge_with_defaults(category: str, payload: Optional[dict]) -> dict:
|
|
merged = deepcopy(DEFAULT_SETTINGS[category])
|
|
if payload:
|
|
merged.update(payload)
|
|
return merged
|
|
|
|
|
|
async def get_setting_record(db: AsyncSession, category: str) -> Optional[SystemSetting]:
|
|
result = await db.execute(select(SystemSetting).where(SystemSetting.category == category))
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def get_setting_payloads(db: AsyncSession, categories: list[str]) -> dict[str, dict]:
|
|
if not categories:
|
|
return {}
|
|
|
|
result = await db.execute(
|
|
select(SystemSetting).where(SystemSetting.category.in_(categories))
|
|
)
|
|
records_by_category = {
|
|
record.category: record
|
|
for record in result.scalars().all()
|
|
}
|
|
return {
|
|
category: merge_with_defaults(
|
|
category,
|
|
records_by_category.get(category).payload if records_by_category.get(category) else None,
|
|
)
|
|
for category in categories
|
|
}
|
|
|
|
|
|
async def get_setting_payload(db: AsyncSession, category: str) -> dict:
|
|
record = await get_setting_record(db, category)
|
|
return merge_with_defaults(category, record.payload if record else None)
|
|
|
|
|
|
async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -> dict:
|
|
record = await get_setting_record(db, category)
|
|
if record is None:
|
|
record = SystemSetting(category=category, payload=payload)
|
|
db.add(record)
|
|
else:
|
|
record.payload = payload
|
|
|
|
await db.commit()
|
|
await db.refresh(record)
|
|
return merge_with_defaults(category, record.payload)
|
|
|
|
|
|
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": "", "source": source}
|
|
text = str(value)
|
|
if "-" in text:
|
|
prefix = text.split("-", 1)[0] + "-"
|
|
preview = prefix + ("*" * max(len(text) - len(prefix), 1))
|
|
else:
|
|
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
|
|
if text == current_preview or text.startswith("••••"):
|
|
return True
|
|
if "-" in text:
|
|
_prefix, masked = text.split("-", 1)
|
|
if masked and all(char in {"*", "•", " ", "\t"} for char in masked):
|
|
return True
|
|
return all(char in {"*", "•", " ", "\t"} for char 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 _ai_provider_runtime_fingerprint(ai_payload: dict) -> dict:
|
|
runtime_config = _runtime_config_from_ai_payload(ai_payload)
|
|
llm_config = runtime_config.get("llm_config") or {}
|
|
return {
|
|
"service_url": runtime_config.get("service_url") or "",
|
|
"service_token": runtime_config.get("service_token") or "",
|
|
"timeout_seconds": int(runtime_config.get("timeout_seconds") or 0),
|
|
"retry_attempts": int(runtime_config.get("retry_attempts") or 0),
|
|
"provider": llm_config.get("provider") or "",
|
|
"provider_api": llm_config.get("provider_api") or "",
|
|
"base_url": llm_config.get("base_url") or "",
|
|
"model": llm_config.get("model") or "",
|
|
"api_key": llm_config.get("api_key") or "",
|
|
"max_tokens": int(llm_config.get("max_tokens") or 0),
|
|
"anthropic_version": llm_config.get("anthropic_version") or "",
|
|
}
|
|
|
|
|
|
async def _validate_ai_provider_full_connection(ai_payload: dict) -> dict:
|
|
runtime_config = _runtime_config_from_ai_payload(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 {},
|
|
)
|
|
status_result = await client.get_status()
|
|
if not status_result.configured:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="AI Provider 可访问,但当前 provider/model/key 未完整配置。",
|
|
)
|
|
prompt = await get_effective_prompt(None, AI_CONNECTION_TEST_PROMPT_KEY)
|
|
analysis_result = await client.analyze(
|
|
SituationalAnalysisRequest(
|
|
title="保存前完整连接测试",
|
|
objective=prompt.prompt,
|
|
system_prompt=prompt.system_prompt or None,
|
|
observations=["这是保存 AI Provider 配置前的完整 LLM 调用测试。"],
|
|
constraints=["回复尽量简短。"],
|
|
)
|
|
)
|
|
return {
|
|
"status": status_result.model_dump(),
|
|
"provider": analysis_result.provider,
|
|
"model": analysis_result.model,
|
|
}
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
def _normalize_ocr_payload(ocr_payload: dict | None) -> dict:
|
|
raw = dict(ocr_payload or {})
|
|
languages = raw.get("languages")
|
|
if not isinstance(languages, list) or not languages:
|
|
languages = ["zh", "en"]
|
|
return {
|
|
"enabled": bool(raw.get("enabled", False)),
|
|
"provider": str(raw.get("provider") or "paddleocr").strip().lower() or "paddleocr",
|
|
"base_url": str(raw.get("base_url") or "").strip(),
|
|
"api_key": str(raw.get("api_key") or "").strip(),
|
|
"model": str(raw.get("model") or "").strip(),
|
|
"languages": [str(item).strip() for item in languages if str(item).strip()],
|
|
"timeout_seconds": int(raw.get("timeout_seconds") or 30),
|
|
"max_file_size_mb": int(raw.get("max_file_size_mb") or 20),
|
|
"output_format": str(raw.get("output_format") or "markdown").strip() or "markdown",
|
|
}
|
|
|
|
|
|
def _resolve_ocr_api_key(ocr_config: dict) -> tuple[str, str]:
|
|
saved_key = ocr_config.get("api_key") or ""
|
|
if saved_key:
|
|
return str(saved_key), "runtime"
|
|
return _resolve_web_search_env_secret("OCR_API_KEY")
|
|
|
|
|
|
def _build_ocr_payload(
|
|
current_payload: dict,
|
|
update: OCRIntegrationUpdate | None,
|
|
) -> dict:
|
|
current_ocr = _normalize_ocr_payload(current_payload.get("ocr") or {})
|
|
if update is None:
|
|
return current_ocr
|
|
current_key, current_key_source = _resolve_ocr_api_key(current_ocr)
|
|
current_key_preview = _mask_secret(current_key, current_key_source)["preview"]
|
|
ocr_payload = {
|
|
"enabled": update.enabled,
|
|
"provider": update.provider.strip().lower() or current_ocr.get("provider") or "paddleocr",
|
|
"base_url": update.base_url.strip(),
|
|
"model": update.model.strip(),
|
|
"languages": [item.strip() for item in update.languages if item.strip()] or ["zh", "en"],
|
|
"timeout_seconds": update.timeout_seconds,
|
|
"max_file_size_mb": update.max_file_size_mb,
|
|
"output_format": update.output_format.strip() or "markdown",
|
|
}
|
|
if not _is_secret_placeholder(update.api_key, current_key_preview):
|
|
ocr_payload["api_key"] = str(update.api_key).strip()
|
|
elif current_ocr.get("api_key"):
|
|
ocr_payload["api_key"] = current_ocr.get("api_key") or ""
|
|
else:
|
|
ocr_payload["api_key"] = ""
|
|
return ocr_payload
|
|
|
|
|
|
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:
|
|
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_ai_payload(payload.get("ai_provider") or {})
|
|
|
|
|
|
async def get_barentswatch_config_record(db: AsyncSession) -> Optional[DataSourceConfig]:
|
|
return await get_barentswatch_datasource_record(db)
|
|
|
|
|
|
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")
|
|
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 {})
|
|
normalized_ocr = _normalize_ocr_payload(raw_payload.get("ocr") 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"])
|
|
)
|
|
ocr_api_key, ocr_api_key_source = _resolve_ocr_api_key(normalized_ocr)
|
|
barentswatch_record = await get_barentswatch_config_record(db)
|
|
barentswatch_auth = barentswatch_record.auth_config if barentswatch_record else {}
|
|
barentswatch_auth = barentswatch_auth or {}
|
|
resolved_barentswatch = await resolve_barentswatch_config(db)
|
|
return {
|
|
"ai_provider": {
|
|
"service_url": ai_config["service_url"],
|
|
"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": 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"],
|
|
"retry_attempts": ai_config["retry_attempts"],
|
|
"source": "runtime" if runtime_setting else "env",
|
|
},
|
|
"barentswatch": {
|
|
"endpoint": resolved_barentswatch.endpoint,
|
|
"client_id": barentswatch_auth.get("client_id") or resolved_barentswatch.client_id,
|
|
"client_secret": _mask_secret(
|
|
barentswatch_auth.get("client_secret") or resolved_barentswatch.client_secret
|
|
),
|
|
"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",
|
|
},
|
|
"ocr": {
|
|
"enabled": normalized_ocr["enabled"],
|
|
"provider": normalized_ocr["provider"],
|
|
"base_url": normalized_ocr["base_url"],
|
|
"api_key": _mask_secret(ocr_api_key, ocr_api_key_source),
|
|
"model": normalized_ocr["model"],
|
|
"languages": normalized_ocr["languages"],
|
|
"timeout_seconds": normalized_ocr["timeout_seconds"],
|
|
"max_file_size_mb": normalized_ocr["max_file_size_mb"],
|
|
"output_format": normalized_ocr["output_format"],
|
|
"source": "runtime" if normalized_ocr.get("api_key") else (ocr_api_key_source or "default"),
|
|
},
|
|
}
|
|
|
|
|
|
async def save_external_integrations_payload(
|
|
db: AsyncSession,
|
|
update: ExternalIntegrationsUpdate,
|
|
) -> dict:
|
|
current_payload = await get_setting_payload(db, "external_integrations")
|
|
current_ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
|
|
ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider)
|
|
if _ai_provider_runtime_fingerprint(ai_payload) != _ai_provider_runtime_fingerprint(current_ai_payload):
|
|
await _validate_ai_provider_full_connection(ai_payload)
|
|
web_search_payload = _build_web_search_payload(current_payload, update.web_search)
|
|
ocr_payload = _build_ocr_payload(current_payload, update.ocr)
|
|
|
|
await save_setting_payload(
|
|
db,
|
|
"external_integrations",
|
|
{"ai_provider": ai_payload, "web_search": web_search_payload, "ocr": ocr_payload},
|
|
)
|
|
|
|
default_endpoint = get_data_sources_config().get_yaml_url("barentswatch_vessels")
|
|
barentswatch_record = await get_barentswatch_config_record(db)
|
|
if barentswatch_record is None:
|
|
barentswatch_record = DataSourceConfig(
|
|
name="barentswatch_vessels",
|
|
description="BarentsWatch Live AIS credentials",
|
|
source_type="api",
|
|
endpoint=update.barentswatch.endpoint.strip() or default_endpoint,
|
|
auth_type="oauth_client",
|
|
auth_config={},
|
|
headers={},
|
|
config={},
|
|
is_active=True,
|
|
)
|
|
db.add(barentswatch_record)
|
|
|
|
current_auth = dict(barentswatch_record.auth_config or {})
|
|
if update.barentswatch.clear_client_secret:
|
|
current_auth.pop("client_secret", None)
|
|
elif update.barentswatch.client_secret not in (None, ""):
|
|
current_auth["client_secret"] = update.barentswatch.client_secret
|
|
current_auth["client_id"] = update.barentswatch.client_id.strip()
|
|
barentswatch_record.endpoint = update.barentswatch.endpoint.strip() or default_endpoint
|
|
barentswatch_record.auth_type = "oauth_client"
|
|
barentswatch_record.auth_config = current_auth
|
|
await db.commit()
|
|
|
|
return await serialize_external_integrations(db)
|
|
|
|
|
|
def format_frequency_label(minutes: int) -> str:
|
|
if minutes % 1440 == 0:
|
|
return f"{minutes // 1440}d"
|
|
if minutes % 60 == 0:
|
|
return f"{minutes // 60}h"
|
|
return f"{minutes}m"
|
|
|
|
|
|
async def get_ais_source_health_by_source(db: AsyncSession) -> dict[str, dict]:
|
|
result = await db.execute(select(AISSourceHealth))
|
|
return {item.source: item.to_dict() for item in result.scalars().all()}
|
|
|
|
|
|
def serialize_collector(datasource: DataSource, ais_health_by_source: dict[str, dict] | None = None) -> dict:
|
|
defaults = DEFAULT_DATASOURCES.get(datasource.source, {})
|
|
return {
|
|
"id": datasource.id,
|
|
"name": datasource.name,
|
|
"display_name": defaults.get("display_name") or datasource.name,
|
|
"source": datasource.source,
|
|
"module": datasource.module,
|
|
"priority": datasource.priority,
|
|
"frequency_minutes": datasource.frequency_minutes,
|
|
"frequency": format_frequency_label(datasource.frequency_minutes),
|
|
"is_active": datasource.is_active,
|
|
"last_run_at": to_iso8601_utc(datasource.last_run_at),
|
|
"last_status": datasource.last_status,
|
|
"next_run_at": to_iso8601_utc(datasource.next_run_at),
|
|
"is_free": bool(defaults.get("is_free", True)),
|
|
"requires_credentials": bool(defaults.get("requires_credentials", False)),
|
|
"credential_provider": defaults.get("credential_provider"),
|
|
"credential_status": defaults.get("credential_status", "none"),
|
|
"ais_health": (ais_health_by_source or {}).get(datasource.source),
|
|
}
|
|
|
|
|
|
@router.get("/system")
|
|
async def get_system_settings(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
return {"system": await get_setting_payload(db, "system")}
|
|
|
|
|
|
@router.put("/system")
|
|
async def update_system_settings(
|
|
settings: SystemSettingsUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
payload = await save_setting_payload(db, "system", settings.model_dump())
|
|
return {"status": "updated", "system": payload}
|
|
|
|
|
|
@router.get("/notifications")
|
|
async def get_notification_settings(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
return {"notifications": await get_setting_payload(db, "notifications")}
|
|
|
|
|
|
@router.put("/notifications")
|
|
async def update_notification_settings(
|
|
settings: NotificationSettingsUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
payload = await save_setting_payload(db, "notifications", settings.model_dump())
|
|
return {"status": "updated", "notifications": payload}
|
|
|
|
|
|
@router.get("/security")
|
|
async def get_security_settings(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
return {"security": await get_setting_payload(db, "security")}
|
|
|
|
|
|
@router.put("/security")
|
|
async def update_security_settings(
|
|
settings: SecuritySettingsUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
payload = await save_setting_payload(db, "security", settings.model_dump())
|
|
return {"status": "updated", "security": payload}
|
|
|
|
|
|
def _serialize_smtp_payload(payload: dict) -> dict:
|
|
password = str(payload.get("password") or "")
|
|
return {
|
|
"host": payload.get("host") or "",
|
|
"port": int(payload.get("port") or 587),
|
|
"username": payload.get("username") or "",
|
|
"password": _mask_secret(password, "runtime" if password else ""),
|
|
"from_address": payload.get("from_address") or "",
|
|
"from_name": payload.get("from_name") or "Planet",
|
|
"use_tls": bool(payload.get("use_tls", False)),
|
|
"use_starttls": bool(payload.get("use_starttls", True)),
|
|
"timeout_seconds": int(payload.get("timeout_seconds") or 20),
|
|
"configured": bool(payload.get("host") and payload.get("from_address")),
|
|
}
|
|
|
|
|
|
def _build_smtp_payload(current_payload: dict, update: SMTPSettingsUpdate) -> dict:
|
|
current_password = str(current_payload.get("password") or "")
|
|
current_preview = _mask_secret(current_password, "runtime" if current_password else "")["preview"]
|
|
if update.clear_password:
|
|
password = ""
|
|
elif _is_secret_placeholder(update.password, current_preview):
|
|
password = current_password
|
|
else:
|
|
password = str(update.password).strip()
|
|
return {
|
|
"host": update.host.strip(),
|
|
"port": update.port,
|
|
"username": update.username.strip(),
|
|
"password": password,
|
|
"from_address": update.from_address.strip(),
|
|
"from_name": update.from_name.strip() or "Planet",
|
|
"use_tls": update.use_tls,
|
|
"use_starttls": update.use_starttls,
|
|
"timeout_seconds": update.timeout_seconds,
|
|
}
|
|
|
|
|
|
@router.get("/smtp")
|
|
async def get_smtp_settings(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
return {"smtp": _serialize_smtp_payload(await get_setting_payload(db, "smtp"))}
|
|
|
|
|
|
@router.put("/smtp")
|
|
async def update_smtp_settings(
|
|
payload: SMTPSettingsUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
if current_user.role not in ("admin", "super_admin"):
|
|
raise HTTPException(status_code=403, detail="Only administrators can change SMTP settings")
|
|
current = await get_setting_payload(db, "smtp")
|
|
merged = _build_smtp_payload(current, payload)
|
|
saved = await save_setting_payload(db, "smtp", merged)
|
|
return {"status": "updated", "smtp": _serialize_smtp_payload(saved)}
|
|
|
|
|
|
@router.post("/smtp/test")
|
|
async def test_smtp_settings(
|
|
payload: SMTPTestRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
if current_user.role not in ("admin", "super_admin"):
|
|
raise HTTPException(status_code=403, detail="Only administrators can test SMTP settings")
|
|
from app.services.email import EmailError, send_email
|
|
|
|
current = await get_setting_payload(db, "smtp")
|
|
config = _build_smtp_payload(current, payload.settings) if payload.settings else current
|
|
if not config.get("host") or not config.get("from_address"):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Host and from_address are required to send a test email",
|
|
)
|
|
try:
|
|
await send_email(
|
|
db,
|
|
to=payload.to,
|
|
subject="Planet SMTP test",
|
|
text_body="This is a test email from Planet SMTP settings.",
|
|
html_body="<p>This is a test email from Planet SMTP settings.</p>",
|
|
config=config,
|
|
)
|
|
except EmailError as exc:
|
|
return {"success": False, "message": str(exc), "code": exc.code}
|
|
return {"success": True, "message": "Test email sent"}
|
|
|
|
|
|
@router.get("/tv")
|
|
async def get_tv_settings(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
return {"tv": await get_tv_settings_payload(db)}
|
|
|
|
|
|
@router.put("/tv")
|
|
async def update_tv_settings(
|
|
settings: TVSettingsUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
payload = normalize_tv_settings(settings.model_dump())
|
|
saved = await save_setting_payload(db, "tv", payload)
|
|
return {"status": "updated", "tv": normalize_tv_settings(saved)}
|
|
|
|
|
|
@router.get("/integrations")
|
|
async def get_external_integrations(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
return {"integrations": await serialize_external_integrations(db)}
|
|
|
|
|
|
@router.get("/ai-prompts")
|
|
async def get_ai_prompts(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
prompts = await list_effective_prompts(db)
|
|
return {"data": [serialize_effective_prompt(prompt) for prompt in prompts]}
|
|
|
|
|
|
@router.put("/ai-prompts/{task_key}")
|
|
async def update_ai_prompt(
|
|
task_key: str,
|
|
payload: AIPromptUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
try:
|
|
prompt = await save_prompt_override(
|
|
db,
|
|
task_key,
|
|
system_prompt=payload.system_prompt,
|
|
prompt=payload.prompt,
|
|
)
|
|
except KeyError:
|
|
raise HTTPException(status_code=404, detail="未知 AI 提示词任务") from None
|
|
return {"data": serialize_effective_prompt(prompt)}
|
|
|
|
|
|
@router.post("/ai-prompts/{task_key}/reset")
|
|
async def reset_ai_prompt(
|
|
task_key: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
try:
|
|
prompt = await reset_prompt_override(db, task_key)
|
|
except KeyError:
|
|
raise HTTPException(status_code=404, detail="未知 AI 提示词任务") from None
|
|
return {"data": serialize_effective_prompt(prompt)}
|
|
|
|
|
|
@router.get("/integrations/barentswatch/connectivity")
|
|
async def get_barentswatch_connectivity(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
return await check_barentswatch_connectivity(db)
|
|
|
|
|
|
@router.post("/integrations/barentswatch/connect")
|
|
async def connect_barentswatch_integration(
|
|
payload: BarentsWatchIntegrationUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
current = await resolve_barentswatch_config(db)
|
|
config = BarentsWatchConfig(
|
|
endpoint=payload.endpoint.strip() or current.endpoint,
|
|
client_id=payload.client_id.strip() or current.client_id,
|
|
client_secret=(
|
|
""
|
|
if payload.clear_client_secret
|
|
else payload.client_secret or current.client_secret
|
|
),
|
|
credential_source="draft",
|
|
endpoint_source="draft",
|
|
)
|
|
result = await check_barentswatch_config(config)
|
|
if result.get("success"):
|
|
checksum, _context = await build_builtin_connectivity_checksum(
|
|
"barentswatch_vessels",
|
|
config.endpoint,
|
|
"none",
|
|
{},
|
|
{},
|
|
db,
|
|
credential_override={
|
|
"client_id": config.client_id,
|
|
"client_secret": config.client_secret,
|
|
},
|
|
)
|
|
validation = await save_connectivity_success(
|
|
db,
|
|
"barentswatch_vessels",
|
|
checksum,
|
|
result,
|
|
connected_by="connection_button",
|
|
)
|
|
await db.commit()
|
|
return {**result, "connected": True, "validation": validation}
|
|
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)
|
|
quick_llm_config = {
|
|
**(runtime_config.get("llm_config") or {}),
|
|
"max_tokens": 1,
|
|
}
|
|
client = AIProviderClient(
|
|
service_url=runtime_config["service_url"],
|
|
service_token=runtime_config["service_token"],
|
|
timeout=min(int(runtime_config["timeout_seconds"] or 60), AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS),
|
|
retry_attempts=1,
|
|
llm_config=quick_llm_config,
|
|
)
|
|
|
|
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(),
|
|
}
|
|
prompt = await get_effective_prompt(db, AI_CONNECTION_TEST_PROMPT_KEY)
|
|
probe_result = await client.analyze(
|
|
SituationalAnalysisRequest(
|
|
title="快速连接测试",
|
|
objective=prompt.prompt,
|
|
system_prompt=prompt.system_prompt or None,
|
|
observations=[],
|
|
constraints=["Output only OK."],
|
|
)
|
|
)
|
|
return {
|
|
"success": True,
|
|
"connected": True,
|
|
"message": "连接测试通过",
|
|
"status": status_result.model_dump(),
|
|
"provider": probe_result.provider,
|
|
"model": probe_result.model,
|
|
"mode": "quick_probe",
|
|
}
|
|
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.get("/integrations/ocr/secrets")
|
|
async def reveal_ocr_secrets(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
current_payload = await get_setting_payload(db, "external_integrations")
|
|
ocr_payload = _normalize_ocr_payload(current_payload.get("ocr") or {})
|
|
api_key, api_key_source = _resolve_ocr_api_key(ocr_payload)
|
|
return {
|
|
"provider": ocr_payload["provider"],
|
|
"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,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
try:
|
|
return {"guide": await get_credential_guide(db, provider)}
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
|
@router.post("/credential-guides/{provider}/generate")
|
|
async def generate_provider_credential_guide(
|
|
provider: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
ai_client: AIProviderClient = Depends(get_ai_provider_client),
|
|
):
|
|
try:
|
|
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
|
|
|
|
|
|
@router.post("/credential-guides/{provider}/reset")
|
|
async def reset_provider_credential_guide(
|
|
provider: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
try:
|
|
return {"guide": await reset_credential_guide(db, provider)}
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
|
@router.get("/integrations/ai-provider/presets")
|
|
async def get_ai_provider_presets(
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return {"data": list_fallback_llm_provider_presets()}
|
|
|
|
|
|
@router.post("/integrations/ai-provider/presets/{provider}/refresh")
|
|
async def refresh_ai_provider_preset(
|
|
provider: str,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
try:
|
|
return {"data": await refresh_llm_provider_preset(provider)}
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
except Exception as exc:
|
|
fallback = get_fallback_llm_provider_preset(provider)
|
|
fallback["refresh_error"] = str(exc)
|
|
return {"data": fallback}
|
|
|
|
|
|
@router.put("/integrations")
|
|
async def update_external_integrations(
|
|
payload: ExternalIntegrationsUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
saved = await save_external_integrations_payload(db, payload)
|
|
return {"status": "updated", "integrations": saved}
|
|
|
|
|
|
@router.get("/collectors")
|
|
async def get_collector_settings(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id))
|
|
datasources = result.scalars().all()
|
|
ais_health_by_source = await get_ais_source_health_by_source(db)
|
|
return {"collectors": [serialize_collector(datasource, ais_health_by_source) for datasource in datasources]}
|
|
|
|
|
|
@router.put("/collectors/{datasource_id}")
|
|
async def update_collector_settings(
|
|
datasource_id: int,
|
|
settings: CollectorSettingsUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
datasource = await db.get(DataSource, datasource_id)
|
|
if not datasource:
|
|
raise HTTPException(status_code=404, detail="Data source not found")
|
|
|
|
datasource.is_active = settings.is_active
|
|
datasource.priority = settings.priority
|
|
datasource.frequency_minutes = settings.frequency_minutes
|
|
await db.commit()
|
|
await db.refresh(datasource)
|
|
await sync_datasource_job(datasource.id)
|
|
ais_health_by_source = await get_ais_source_health_by_source(db)
|
|
return {"status": "updated", "collector": serialize_collector(datasource, ais_health_by_source)}
|
|
|
|
|
|
@router.get("")
|
|
async def get_all_settings(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id))
|
|
datasources = result.scalars().all()
|
|
setting_payloads = await get_setting_payloads(
|
|
db,
|
|
["system", "notifications", "security"],
|
|
)
|
|
ais_health_by_source = await get_ais_source_health_by_source(db)
|
|
return {
|
|
"system": setting_payloads["system"],
|
|
"notifications": setting_payloads["notifications"],
|
|
"security": setting_payloads["security"],
|
|
"tv": await get_tv_settings_payload(db),
|
|
"integrations": await serialize_external_integrations(db),
|
|
"collectors": [serialize_collector(datasource, ais_health_by_source) for datasource in datasources],
|
|
"generated_at": to_iso8601_utc(datetime.now(UTC)),
|
|
}
|