ForcePilot/backend/package/yuxi/config/app.py

317 lines
15 KiB
Python
Raw Normal View History

"""应用配置模块。"""
from __future__ import annotations
2025-10-22 11:51:32 +08:00
import os
from pathlib import Path
from typing import Any
import tomli
import tomli_w
from pydantic import BaseModel, Field, PrivateAttr, model_validator
2025-10-22 11:51:32 +08:00
from yuxi.utils.logging_config import logger
2025-10-22 11:51:32 +08:00
class Config(BaseModel):
"""应用配置类。"""
2025-10-22 11:51:32 +08:00
save_dir: str = Field(default="saves", description="保存目录")
enable_reranker: bool = Field(default=False, description="是否开启重排序")
enable_content_guard: bool = Field(default=False, description="是否启用内容审查")
enable_content_guard_llm: bool = Field(default=False, description="是否启用LLM内容审查")
default_model: str = Field(
default="siliconflow-cn:Pro/MiniMaxAI/MiniMax-M2.5",
2025-10-22 11:51:32 +08:00
description="默认对话模型",
)
fast_model: str = Field(
default="siliconflow-cn:Pro/MiniMaxAI/MiniMax-M2.5",
2025-10-22 11:51:32 +08:00
description="快速响应模型",
)
embed_model: str = Field(
default="siliconflow-cn:Pro/BAAI/bge-m3",
description="默认 Embedding 模型",
2025-10-22 11:51:32 +08:00
)
reranker: str = Field(
default="siliconflow-cn:Pro/BAAI/bge-reranker-v2-m3",
description="默认 Re-Ranker 模型",
2025-10-22 11:51:32 +08:00
)
content_guard_llm_model: str = Field(
default="siliconflow-cn:Pro/MiniMaxAI/MiniMax-M2.5",
2025-10-22 11:51:32 +08:00
description="内容审查LLM模型",
)
default_agent_id: str = Field(default="ChatbotAgent", description="默认智能体ID")
2025-10-22 11:51:32 +08:00
sandbox_provider: str = Field(default="provisioner", description="沙箱提供者")
2026-03-20 21:18:13 +08:00
sandbox_provisioner_url: str = Field(default="http://sandbox-provisioner:8002", description="沙箱服务地址")
sandbox_virtual_path_prefix: str = Field(default="/home/gem/user-data", description="沙箱用户目录前缀")
sandbox_exec_timeout_seconds: int = Field(default=180, description="沙箱执行超时时间(秒)")
sandbox_max_output_bytes: int = Field(default=262144, description="沙箱最大输出字节数")
sandbox_keepalive_interval_seconds: int = Field(default=30, description="沙箱保活间隔")
2025-10-22 11:51:32 +08:00
enable_ssrf_protection: bool = Field(default=True, description="是否启用 SSRF 防护")
ssrf_allowed_domains: list[str] = Field(default_factory=list, description="SSRF 域名白名单")
ssrf_blocked_domains: list[str] = Field(default_factory=list, description="SSRF 域名黑名单")
ssrf_dns_pinned_ttl: int = Field(default=300, description="SSRF DNS 缓存时间(秒)")
# === 定时任务调度配置(扁平字段,对齐既有模式,见设计方案 §13 ===
scheduler_enabled: bool = Field(default=True, description="定时任务总开关")
scheduler_tick_interval_seconds: int = Field(default=60, description="tick 频率(秒)")
scheduler_max_consecutive_errors: int = Field(default=5, description="死信阈值")
scheduler_task_timeout_seconds: int = Field(default=600, description="单任务超时(秒)")
scheduler_max_payload_size_kb: int = Field(default=64, description="payload 大小上限KB")
scheduler_max_active_tasks_per_owner: int = Field(default=100, description="业务方任务数上限")
scheduler_stagger_max_seconds: int = Field(default=300, description="整点抖动上限(秒)")
scheduler_run_log_retention_days: int = Field(default=90, description="run_logs 保留天数")
scheduler_idempotency_retention_hours: int = Field(default=24, description="幂等记录保留小时数")
scheduler_min_cron_interval_minutes: int = Field(default=10, description="cron 最小间隔(分钟)")
scheduler_backoff_schedule: list[int] = Field(
default_factory=lambda: [10, 30, 120, 600, 3600],
description="指数退避序列(秒)",
)
# === Web 搜索配置(扁平字段,见设计方案 §10.1.1 ===
web_search_provider_chain: list[str] = Field(
default_factory=lambda: ["searxng", "tavily"],
description="Web 搜索 Provider 优先级链",
)
web_search_searxng_base_url: str = Field(default="http://searxng:8080", description="SearXNG 服务地址")
web_search_searxng_timeout: int = Field(default=10, description="SearXNG 调用超时(秒)")
web_search_searxng_engines: list[str] = Field(default_factory=list, description="SearXNG 指定引擎列表(空=使用默认)")
web_search_searxng_language: str = Field(default="auto", description="SearXNG 搜索语言")
web_search_searxng_safesearch: int = Field(default=0, description="SearXNG 安全搜索级别0/1/2")
web_search_tavily_api_key: str = Field(default="", description="Tavily API Key空=读 TAVILY_API_KEY 环境变量)")
web_search_tavily_timeout: int = Field(default=10, description="Tavily 调用超时(秒)")
web_search_default_max_results: int = Field(default=5, description="Web 搜索默认结果数")
web_search_rate_limit_per_minute: int = Field(default=20, description="单 agent Web 搜索每分钟调用上限")
# === Web 抓取配置(扁平字段,见设计方案 §10.1.2 ===
crawl4ai_base_url: str = Field(default="http://crawl4ai:11235", description="Crawl4AI 服务地址")
crawl4ai_timeout: int = Field(default=30, description="Crawl4AI 单页抓取超时(秒)")
crawl4ai_api_token: str = Field(default="", description="Crawl4AI API Token预留 JWT 认证)")
web_crawl_default_max_depth: int = Field(default=2, description="Web 抓取默认递归深度")
web_crawl_default_max_pages: int = Field(default=20, description="Web 抓取默认最大页数")
web_crawl_default_concurrency: int = Field(default=5, description="Web 抓取默认并发数")
web_crawl_domain_whitelist: list[str] = Field(default_factory=list, description="Web 抓取域名白名单(空=允许所有公网域名)")
web_crawl_respect_robots: bool = Field(default=True, description="Web 抓取是否尊重 robots.txt")
web_crawl_rate_limit_per_minute: int = Field(default=10, description="单 agent Web 抓取每分钟调用上限")
_config_file: Path | None = PrivateAttr(default=None)
_user_modified_fields: set[str] = PrivateAttr(default_factory=set)
2025-10-22 11:51:32 +08:00
model_config = {"arbitrary_types_allowed": True, "extra": "allow"}
def __init__(self, **data):
super().__init__(**data)
self._setup_paths()
self._load_user_config()
self._handle_environment()
def _setup_paths(self) -> None:
2025-10-22 11:51:32 +08:00
self._config_file = Path(self.save_dir) / "config" / "base.toml"
self._config_file.parent.mkdir(parents=True, exist_ok=True)
def _load_user_config(self) -> None:
2025-10-22 11:51:32 +08:00
if not self._config_file or not self._config_file.exists():
logger.info(f"Config file not found, using defaults: {self._config_file}")
return
logger.info(f"Loading config from {self._config_file}")
try:
with open(self._config_file, "rb") as f:
user_config = tomli.load(f)
self._user_modified_fields = set(user_config.keys())
for key, value in user_config.items():
if hasattr(self, key):
2025-10-22 11:51:32 +08:00
setattr(self, key, value)
else:
logger.warning(f"Unknown config key: {key}")
except Exception as e:
logger.error(f"Failed to load config from {self._config_file}: {e}")
def _handle_environment(self) -> None:
2026-03-20 21:18:13 +08:00
self.sandbox_provider = (os.getenv("SANDBOX_PROVIDER") or self.sandbox_provider or "provisioner").strip()
self.sandbox_provisioner_url = (
2026-03-20 21:18:13 +08:00
os.getenv("SANDBOX_PROVISIONER_URL") or self.sandbox_provisioner_url or "http://sandbox-provisioner:8002"
).strip()
self.sandbox_virtual_path_prefix = (
os.getenv("SANDBOX_VIRTUAL_PATH_PREFIX") or self.sandbox_virtual_path_prefix or "/home/gem/user-data"
).strip()
self.sandbox_exec_timeout_seconds = int(
os.getenv("SANDBOX_EXEC_TIMEOUT_SECONDS") or self.sandbox_exec_timeout_seconds or 180
)
2026-05-31 13:40:15 +08:00
self.sandbox_max_output_bytes = int(
os.getenv("SANDBOX_MAX_OUTPUT_BYTES") or self.sandbox_max_output_bytes or 262144
)
2026-03-05 22:50:38 +08:00
self.sandbox_keepalive_interval_seconds = int(
2026-03-20 21:18:13 +08:00
os.getenv("SANDBOX_KEEPALIVE_INTERVAL_SECONDS") or self.sandbox_keepalive_interval_seconds or 30
2026-03-05 22:50:38 +08:00
)
if self.sandbox_provider.lower() != "provisioner":
raise ValueError("Only sandbox_provider=provisioner is supported.")
if not self.sandbox_provisioner_url:
raise ValueError("SANDBOX_PROVISIONER_URL is required when sandbox provider is provisioner.")
if not self.sandbox_virtual_path_prefix.startswith("/"):
self.sandbox_virtual_path_prefix = f"/{self.sandbox_virtual_path_prefix}"
# SSRF 环境变量覆盖(兼容 YUXI_URL_WHITELIST
ssrf_env = os.getenv("YUXI_URL_WHITELIST", "")
if ssrf_env:
self.ssrf_allowed_domains = [d.strip() for d in ssrf_env.split(",") if d.strip()]
# === scheduler 环境变量覆盖(对齐既有三段式写法,见设计方案 §13 ===
self.scheduler_enabled = (
os.getenv("SCHEDULER_ENABLED", "true").lower() in ("true", "1", "yes")
)
self.scheduler_tick_interval_seconds = int(
os.getenv("SCHEDULER_TICK_INTERVAL_SECONDS") or self.scheduler_tick_interval_seconds or 60
)
self.scheduler_max_consecutive_errors = int(
os.getenv("SCHEDULER_MAX_CONSECUTIVE_ERRORS") or self.scheduler_max_consecutive_errors or 5
)
self.scheduler_task_timeout_seconds = int(
os.getenv("SCHEDULER_TASK_TIMEOUT_SECONDS") or self.scheduler_task_timeout_seconds or 600
)
self.scheduler_max_payload_size_kb = int(
os.getenv("SCHEDULER_MAX_PAYLOAD_SIZE_KB") or self.scheduler_max_payload_size_kb or 64
)
self.scheduler_max_active_tasks_per_owner = int(
os.getenv("SCHEDULER_MAX_ACTIVE_TASKS_PER_OWNER")
or self.scheduler_max_active_tasks_per_owner
or 100
)
self.scheduler_stagger_max_seconds = int(
os.getenv("SCHEDULER_STAGGER_MAX_SECONDS") or self.scheduler_stagger_max_seconds or 300
)
self.scheduler_run_log_retention_days = int(
os.getenv("SCHEDULER_RUN_LOG_RETENTION_DAYS") or self.scheduler_run_log_retention_days or 90
)
self.scheduler_idempotency_retention_hours = int(
os.getenv("SCHEDULER_IDEMPOTENCY_RETENTION_HOURS")
or self.scheduler_idempotency_retention_hours
or 24
)
self.scheduler_min_cron_interval_minutes = int(
os.getenv("SCHEDULER_MIN_CRON_INTERVAL_MINUTES")
or self.scheduler_min_cron_interval_minutes
or 10
)
# === Web 搜索环境变量覆盖 ===
self.web_search_searxng_base_url = (
os.getenv("WEB_SEARCH_SEARXNG_BASE_URL") or self.web_search_searxng_base_url
)
self.web_search_searxng_timeout = int(
os.getenv("WEB_SEARCH_SEARXNG_TIMEOUT") or self.web_search_searxng_timeout
)
self.web_search_searxng_language = (
os.getenv("WEB_SEARCH_SEARXNG_LANGUAGE") or self.web_search_searxng_language
)
self.web_search_searxng_safesearch = int(
os.getenv("WEB_SEARCH_SEARXNG_SAFESEARCH") or self.web_search_searxng_safesearch
)
self.web_search_tavily_api_key = (
os.getenv("TAVILY_API_KEY") or self.web_search_tavily_api_key
)
self.web_search_tavily_timeout = int(
os.getenv("WEB_SEARCH_TAVILY_TIMEOUT") or self.web_search_tavily_timeout
)
# === Web 抓取环境变量覆盖 ===
self.crawl4ai_base_url = (
os.getenv("CRAWL4AI_BASE_URL") or self.crawl4ai_base_url
)
self.crawl4ai_timeout = int(
os.getenv("CRAWL4AI_TIMEOUT") or self.crawl4ai_timeout
)
self.crawl4ai_api_token = (
os.getenv("CRAWL4AI_API_TOKEN") or self.crawl4ai_api_token
)
@model_validator(mode="after")
def _validate_web_search_crawl_config(self):
"""校验 web_search 与 web_crawl 配置合法性。"""
valid_providers = {"searxng", "tavily"}
for provider in self.web_search_provider_chain:
if provider not in valid_providers:
raise ValueError(
f"web_search_provider_chain 含未知 Provider 名: {provider}"
f"合法值: {valid_providers}"
)
if not 1 <= self.web_search_default_max_results <= 10:
raise ValueError("web_search_default_max_results 必须在 [1, 10] 范围内")
if not 1 <= self.web_crawl_default_max_depth <= 3:
raise ValueError("web_crawl_default_max_depth 必须在 [1, 3] 范围内")
if not 1 <= self.web_crawl_default_max_pages <= 100:
raise ValueError("web_crawl_default_max_pages 必须在 [1, 100] 范围内")
return self
def save(self) -> None:
2025-10-22 11:51:32 +08:00
if not self._config_file:
logger.warning("Config file path not set")
return
logger.info(f"Saving config to {self._config_file}")
default_config = Config.model_construct()
user_modified = {}
for field_name, field_info in self.model_fields.items():
2025-10-22 11:51:32 +08:00
if field_info.exclude:
continue
current_value = getattr(self, field_name)
default_value = getattr(default_config, field_name)
if current_value != default_value:
user_modified[field_name] = current_value
try:
with open(self._config_file, "wb") as f:
tomli_w.dump(user_modified, f)
logger.info(f"Config saved to {self._config_file}")
except Exception as e:
logger.error(f"Failed to save config to {self._config_file}: {e}")
def dump_config(self) -> dict[str, Any]:
config_dict = self.model_dump()
# 敏感字段脱敏
sensitive_keys = {"web_search_tavily_api_key", "crawl4ai_api_token"}
for key in sensitive_keys:
if key in config_dict and config_dict[key]:
config_dict[key] = "***"
2025-10-22 11:51:32 +08:00
fields_info = {}
for field_name, field_info in Config.model_fields.items():
if field_info.exclude:
continue
fields_info[field_name] = {
"des": field_info.description,
"default": field_info.default,
"type": field_info.annotation.__name__
if hasattr(field_info.annotation, "__name__")
else str(field_info.annotation),
"exclude": field_info.exclude if hasattr(field_info, "exclude") else False,
}
2025-10-22 11:51:32 +08:00
config_dict["_config_items"] = fields_info
return config_dict
def update(self, other: dict[str, Any]) -> None:
2025-10-22 11:51:32 +08:00
for key, value in other.items():
if hasattr(self, key):
setattr(self, key, value)
else:
logger.warning(f"Unknown config key: {key}")
def get_ssrf_policy(self):
"""根据配置生成 SSRF 策略。"""
from yuxi.utils.net_security import SSRFPolicy
return SSRFPolicy(
allowed_domains=self.ssrf_allowed_domains,
blocked_domains=self.ssrf_blocked_domains,
dns_pinned_ttl=self.ssrf_dns_pinned_ttl,
)
2025-10-22 11:51:32 +08:00
2025-09-18 20:32:33 +08:00
config = Config()