ForcePilot/backend/package/yuxi/config/app.py
Kris 2a401450cb refactor(scheduler): 整理代码格式与导入,新增调度器配置与handler注册框架
本次提交包含多类改进:
1.  清理冗余空行、修复导入格式与类型检查占位
2.  新增external_systems与scheduler上下文的handler注册框架
3.  补全调度器相关配置项与环境变量覆盖逻辑
4.  新增配置校验与敏感字段脱敏逻辑
5.  简化协议方法声明格式
2026-06-22 21:22:07 +08:00

317 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""应用配置模块。"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import tomli
import tomli_w
from pydantic import BaseModel, Field, PrivateAttr, model_validator
from yuxi.utils.logging_config import logger
class Config(BaseModel):
"""应用配置类。"""
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",
description="默认对话模型",
)
fast_model: str = Field(
default="siliconflow-cn:Pro/MiniMaxAI/MiniMax-M2.5",
description="快速响应模型",
)
embed_model: str = Field(
default="siliconflow-cn:Pro/BAAI/bge-m3",
description="默认 Embedding 模型",
)
reranker: str = Field(
default="siliconflow-cn:Pro/BAAI/bge-reranker-v2-m3",
description="默认 Re-Ranker 模型",
)
content_guard_llm_model: str = Field(
default="siliconflow-cn:Pro/MiniMaxAI/MiniMax-M2.5",
description="内容审查LLM模型",
)
default_agent_id: str = Field(default="ChatbotAgent", description="默认智能体ID")
sandbox_provider: str = Field(default="provisioner", description="沙箱提供者")
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="沙箱保活间隔")
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)
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:
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:
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):
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:
self.sandbox_provider = (os.getenv("SANDBOX_PROVIDER") or self.sandbox_provider or "provisioner").strip()
self.sandbox_provisioner_url = (
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
)
self.sandbox_max_output_bytes = int(
os.getenv("SANDBOX_MAX_OUTPUT_BYTES") or self.sandbox_max_output_bytes or 262144
)
self.sandbox_keepalive_interval_seconds = int(
os.getenv("SANDBOX_KEEPALIVE_INTERVAL_SECONDS") or self.sandbox_keepalive_interval_seconds or 30
)
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:
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():
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] = "***"
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,
}
config_dict["_config_items"] = fields_info
return config_dict
def update(self, other: dict[str, Any]) -> None:
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,
)
config = Config()