ForcePilot/backend/package/yuxi/config/app.py
Kris 56022a9199 chore: 批量整理代码变更,修复细节与优化结构
- 修复 scheduler store 末尾多余逗号
- 将智能体名称从“语析”改为“Kris”
- 新增获取运行记录UID的权限校验接口
- 重构操作日志写入逻辑,使用独立会话避免事务污染
- 注册外部系统调度处理器
- 优化全局错误处理器,统一响应格式与序列化处理
- 新增调度任务运行日志的handler_name字段与索引
- 重构任务执行函数,新增payload覆盖与操作人审计参数
- 重写外部系统store,新增侧边栏折叠状态与持久化
- 优化会话、消息等模型的索引、约束与字段类型
- 新增多项配置项与环境变量支持
- 重构渠道相关模型,新增索引、约束与字段优化
- 新增渠道插件模型与内容审核模型的完善
2026-07-11 22:06:21 +08:00

335 lines
17 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, Literal
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_task_recycle_retention_days: int = Field(default=7, 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 抓取每分钟调用上限")
# === 渠道网关全局配置(扁平字段,见数据库设计方案 §11.1 ===
channel_outbox_ttl_seconds: int = Field(default=86400, description="Outbox 条目过期时间(秒,默认 24h")
channel_outbox_max_retry: int = Field(default=5, description="Outbox 最大重试次数")
channel_outbox_retry_backoff_schedule: list[int] = Field(
default_factory=lambda: [2, 4, 8, 16, 32],
description="Outbox 指数退避序列(秒)",
)
channel_audit_log_retention_days: int = Field(default=90, description="审计日志保留天数")
ext_system_audit_log_retention_days: int = Field(default=90, description="外部系统审计日志保留天数")
# === Webhook 回调限流配置(扁平字段) ===
webhook_callback_rate_limit_max_qps: int = Field(
default=10, ge=1, description="单个 Webhook 订阅回调每秒最大请求数"
)
webhook_callback_rate_limit_burst_capacity: int = Field(
default=20, ge=1, description="单个 Webhook 订阅回调令牌桶突发容量"
)
# === 实时指标后端配置进程级基础设施参数H-6 ===
realtime_metrics_backend: Literal["inmemory", "redis"] = Field(
default="inmemory",
description="实时指标后端inmemory=纯内存单实例redis=多实例共享)",
)
_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_task_recycle_retention_days = int(
os.getenv("SCHEDULER_TASK_RECYCLE_RETENTION_DAYS") or self.scheduler_task_recycle_retention_days or 7
)
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
# === Webhook 回调限流环境变量覆盖 ===
self.webhook_callback_rate_limit_max_qps = int(
os.getenv("WEBHOOK_CALLBACK_RATE_LIMIT_MAX_QPS") or self.webhook_callback_rate_limit_max_qps
)
self.webhook_callback_rate_limit_burst_capacity = int(
os.getenv("WEBHOOK_CALLBACK_RATE_LIMIT_BURST_CAPACITY") or self.webhook_callback_rate_limit_burst_capacity
)
# === 实时指标后端环境变量覆盖H-6 ===
self.realtime_metrics_backend = os.getenv("REALTIME_METRICS_BACKEND", self.realtime_metrics_backend)
@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},合法值: {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()