初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""核心基础设施。"""
+16
View File
@@ -0,0 +1,16 @@
"""配置集中入口。
所有配置集中在 ``core/config/``,按领域拆分子配置文件,主 ``Settings`` 多继承聚合。
详见 [后端分层架构设计](../../../../../docs/architecture/backend-layering-design.md) §7。
- ``.env`` 永远放在项目根目录(backend 父级),``Settings`` 初始化时以**绝对路径**
通过 ``_env_file`` 传入,不使用相对路径,不依赖 ``python-dotenv``。
- ``ai_provider/`` 只**消费**本目录的配置,不持有自己的
``settings.py`` / ``config.py``;领域数据结构(``ModelConfig`` 等)保留在
``ai_provider/config/models.py``。
"""
from app.core.config.sandbox_config import SandboxSettings
from app.core.config.settings import Settings, settings
__all__ = ["SandboxSettings", "Settings", "settings"]
+53
View File
@@ -0,0 +1,53 @@
"""应用基础配置:运行环境、端口、CORS、默认模型提示等。"""
from __future__ import annotations
from typing import Literal
from pydantic_settings import BaseSettings
class AppConfig(BaseSettings):
"""应用基础配置。"""
# ==================== 基础配置 ====================
app_name: str = "魂镜后端" # 应用名称
app_env: Literal["dev", "prod"] = "dev" # 运行环境:dev(开发) / prod(生产)
debug: bool = False # 调试模式
timezone: str = "Asia/Shanghai" # 时区
api_prefix: str = "/api/v1" # API 路由前缀
backend_host: str = "0.0.0.0" # 监听地址
backend_port: int = 8000 # 监听端口
cors_allow_origins: str = "http://localhost:3000" # CORS 允许的源,逗号分隔
# ==================== 业务默认值 ====================
default_owner_scope: str = "local-user" # 默认所有者作用域
default_model_hint: str = "deepseek-v4-flash" # 默认真实远程逻辑模型提示
# ==================== 默认人物模板配置 ====================
character_templates_path: str = (
"../config/character_templates.yaml" # 人物模板配置文件路径(相对 backend)
)
# ==================== 文件存储 ====================
avatar_dir: str = "../db/avatars" # 角色头像文件存储目录(相对 backend)
# ==================== 默认管理员用户 ====================
default_admin_username: str = "admin" # 种子默认管理员用户名
default_admin_email: str = "admin@hunjing.local" # 种子默认管理员邮箱
default_admin_password: str = "admin123" # 种子默认管理员密码(引导用,生产立即修改)
@property
def cors_origins(self) -> list[str]:
"""解析 CORS 允许的源为列表。"""
return [origin.strip() for origin in self.cors_allow_origins.split(",") if origin.strip()]
@property
def is_development(self) -> bool:
"""是否为开发环境。"""
return self.app_env == "dev"
@property
def is_production(self) -> bool:
"""是否为生产环境。"""
return self.app_env == "prod"
+29
View File
@@ -0,0 +1,29 @@
"""认证与鉴权配置。
当前(v1 单用户)仅含管理端点访问凭证 ``admin_token``;v2 多用户上线后扩展为
完整认证配置(JWT 签发密钥、access/refresh token TTL 等),见
[多用户与租户隔离设计](../../../docs/product/master-prd.md)。
"""
from __future__ import annotations
from pydantic_settings import BaseSettings
class AuthConfig(BaseSettings):
"""认证与鉴权配置。"""
# ==================== 管理端点访问(v1) ====================
# 司命台管理 API(/admin/*)的访问凭证。部署期配置,非用户登录凭证:
# 请求需在 Authorization 头携带 ``Bearer <ADMIN_TOKEN>``,经 require_admin 校验。
# 为空时管理端点 fail-closed(返回 503),强制部署时配置,避免裸奔。
# 生产环境务必使用强随机值;v2 多用户上线后由完整认证体系取代。
admin_token: str = "" # 管理端点访问凭证(为空则 /admin 返回 503)
# ==================== JWT 配置(v2 多用户) ====================
# JWT 签名密钥。为空时认证端点 fail-closed(503);生产环境务必配置强随机值。
jwt_secret: str = ""
# Access Token 有效期(秒),默认 15 分钟
access_token_ttl: int = 900
# Refresh Token 有效期(秒),默认 7 天
refresh_token_ttl: int = 604800
@@ -0,0 +1,27 @@
"""数据库配置:SQLite / PostgreSQL 连接参数。"""
from __future__ import annotations
from typing import Literal
from pydantic_settings import BaseSettings
class DatabaseConfig(BaseSettings):
"""数据库配置。
本地开发默认使用 SQLite(``aiosqlite``);正式环境通过 ``DATABASE_TYPE=pgsql``
切换到 PostgreSQL(``asyncpg``)。``DATABASE_URL`` 优先级最高,可直接覆盖。
"""
database_type: Literal["sqlite", "pgsql"] = "sqlite" # 数据库类型
sqlite_database_path: str = "../db/hunjing.sqlite3" # SQLite 数据库路径(相对 backend)
database_url: str = "" # 自定义数据库连接 URL(优先级最高)
postgres_host: str = "127.0.0.1" # PostgreSQL 主机
postgres_port: int = 5432 # PostgreSQL 端口
postgres_db: str = "hunjing" # PostgreSQL 数据库名
postgres_user: str = "hunjing" # PostgreSQL 用户名
postgres_password: str = "change-me" # PostgreSQL 密码
postgres_ssl: bool = False # 是否启用 SSL
postgres_pool_size: int = 10 # 连接池大小
postgres_max_overflow: int = 20 # 连接池最大溢出连接数
+15
View File
@@ -0,0 +1,15 @@
"""日志配置。"""
from __future__ import annotations
from pydantic_settings import BaseSettings
class LogConfig(BaseSettings):
"""日志配置(loguru)。"""
log_dir: str = "../logs" # 日志目录(相对于 backend 目录)
log_level: str = "INFO" # 日志级别:DEBUG/INFO/WARNING/ERROR
log_access: bool = True # 是否启用访问日志
log_retention_days: int = 14 # 日志保留天数
log_rotation_size: str = "10 MB" # 日志轮转大小
+46
View File
@@ -0,0 +1,46 @@
"""模型网关(神识)env 配置:各 provider 的 API Key / Base URL 与 endpoint 池策略。
注意区分**配置字段**与**领域数据结构**:
- 本文件只承载「从 env 读的开关 / 参数 / 密钥」。
- 领域数据结构(``ModelConfig`` / ``ModelEndpointConfig``,从 ``config/models.yaml`` 加载)
保留在 ``ai_provider/config/models.py``,不外迁到 ``core/config``。
"""
from __future__ import annotations
from pydantic_settings import BaseSettings
class ModelGatewayConfig(BaseSettings):
"""模型网关 env 配置。"""
# ==================== 模型配置文件 ====================
model_config_path: str = "../config/models.yaml" # 模型配置文件路径(相对 backend)
# ==================== provider API Key / Base URL ====================
openai_api_key: str = "" # OpenAI API Key
openai_base_url: str = "https://api.openai.com/v1" # OpenAI API 地址
deepseek_api_key: str = "" # DeepSeek API Key
deepseek_base_url: str = "https://api.deepseek.com" # DeepSeek API 地址(OpenAI 兼容)
xiaomi_mimo_api_key: str = "" # 小米 MiMo API Key
xiaomi_mimo_base_url: str = "https://api.xiaomimimo.com/v1" # 小米 MiMo API 地址
anthropic_api_key: str = "" # Anthropic API Key
anthropic_base_url: str = "https://api.anthropic.com" # Anthropic API 地址
volcengine_ark_api_key: str = "" # 火山方舟 / 豆包 API Key
volcengine_ark_base_url: str = "https://ark.cn-beijing.volces.com/api/v3" # 火山方舟 API 地址
custom_llm_api_key: str = "" # 自定义 LLM API Key
custom_llm_base_url: str = "" # 自定义 LLM API 地址
yuanqi_api_key: str = "" # 元炁本地推理引擎 API Key
yuanqi_base_url: str = "http://localhost:11434" # 元炁本地推理引擎 API 地址
# ==================== endpoint 池配置 ====================
# 物理 endpoint 池路由、failover 与重试策略(见 ai_provider/pool.py 与 resilience.py)
model_endpoint_pool_enabled: bool = True # 是否启用 endpoint 池路由(False 时走逻辑模型直连)
model_endpoint_pool_dry_run_selection: bool = False # dry-run:只预选并记录,不实际路由
model_endpoint_pool_expose_debug_headers: bool = False # 是否在响应头暴露 endpoint 路由调试信息
model_endpoint_pool_agent_failover_enabled: bool = (
False # Agent 调用是否允许跨 endpoint failover
)
model_endpoint_pool_per_endpoint_max_retries: int = 1 # 单 endpoint 失败时的本地重试次数
model_endpoint_pool_retry_base_delay_seconds: float = 0.25 # endpoint 本地重试基础退避(秒)
model_endpoint_pool_retry_max_delay_seconds: float = 2.0 # endpoint 本地重试最大退避(秒)
+99
View File
@@ -0,0 +1,99 @@
"""运行时与 SSE 配置:事件流、超时、工具运行时开关等。"""
from __future__ import annotations
from pydantic_settings import BaseSettings
class RuntimeConfig(BaseSettings):
"""SSE / 运行时 / 工具运行时配置。"""
# ==================== SSE 配置 ====================
enable_sse: bool = True # 是否启用 SSE
max_text_message_bytes: int = 32768 # 最大文本消息字节数
sse_poll_interval_seconds: float = 0.01 # SSE 轮询间隔(秒,10ms:前端逐段平滑增量)
sse_heartbeat_seconds: float = 15.0 # SSE 心跳间隔(秒)
run_timeout_seconds: int = 120 # 运行超时时间(秒)
# Agent 循环最大轮数:模型每轮仍带工具调用则继续,超限截断返回(见 agent.runtime.DEFAULT_MAX_ITERATIONS)
agent_max_iterations: int = 8
# 单次高风险工具确认挂起超时(秒):须小于 run_timeout_seconds,超时按拒绝处理;
# 若 >= run_timeout_seconds,run 整体超时会先到,导致确认中的 run 直接 failed
confirmation_timeout_seconds: int = 60
# 是否启用数据库持久化的确认存储(用于多 worker 部署);关闭则使用进程内 InMemoryConfirmationStore(默认)
use_db_confirmation_store: bool = False
run_max_events: int = 1000 # 最大事件数
event_payload_max_bytes: int = 65536 # 事件负载最大字节数
memory_injection_limit: int = 5 # 记忆注入条数上限
# ==================== 工具运行时配置 ====================
enable_tool_runtime: bool = True # 是否启用百工工具运行时(司命 + 沙箱)
tool_timeout_seconds: int = 600 # 单个工具调用超时秒数
# ==================== 洛书规划器 ====================
enable_deliberative_planning: bool = False # 是否启用显式多步规划(§10.2 增强能力)
# ==================== 大周天(长期演化循环) ====================
enable_big_cycle: bool = True # 是否启用大周天自动触发
big_cycle_memory_threshold: int = 20 # 触发大周天的记忆数阈值
big_cycle_min_interval_seconds: int = 300 # 两次大周天最小间隔(秒),避免频繁触发
big_cycle_model: str = "deepseek-v4-flash" # 大周天候选生成所用模型(models.yaml name 或 tag)
big_cycle_max_candidates: int = 10 # 单次大周天生成的演化候选上限
big_cycle_token_budget: int = 64000 # 单次大周天的 token 预算上限(超限跳过非关键阶段)
big_cycle_max_drift: float = 2.0 # 最大累积漂移值(超过后暂停自动演化)
big_cycle_drift_threshold: float = 0.6 # 单次演化候选的漂移阈值(>= 此值不自动应用)
# ==================== 内驱力(太一枢)[PLANNED] ====================
enable_intrinsic_drive: bool = False # 是否启用内驱力扫描(curiosity / exploration)
drive_scan_interval_seconds: int = 3600 # 内驱力扫描间隔秒数
drive_max_curiosity_per_scan: int = 5 # 单次扫描最多好奇问题数
drive_allow_interrupt: bool = False # 是否允许内驱力中断当前任务
# ==================== 司命安全策略 ====================
# 工具策略 YAML 路径(相对 backend);未配置时 SafetyGovernor 使用内置默认空策略。
safety_policy_path: str = ""
safety_always_confirm_high_risk: bool = True # 高风险动作是否总是确认
# ==================== MCP / 联网工具网络超时 ====================
mcp_list_timeout_seconds: float = 10.0 # MCP tools/list 请求超时(秒)
mcp_call_timeout_seconds: float = 30.0 # MCP tools/call 请求超时(秒)
tool_web_http_timeout_seconds: float = 15.0 # fetch_url 的 HTTP 超时(秒)
# ==================== MCP stdio 子进程 ====================
mcp_stdio_start_timeout_seconds: float = 15.0 # stdio 子进程启动超时(秒)
mcp_stdio_call_timeout_seconds: float = 30.0 # stdio 单次 tools/call 超时(秒)
mcp_stdio_stop_timeout_seconds: float = 5.0 # SIGTERM → SIGKILL 等待(秒)
# ==================== 向量记忆检索 ====================
vector_db_enabled: bool = False # 是否启用向量数据库(关则纯 SQL 检索)
vector_db_type: str = "chroma" # 向量库类型:chroma / milvus
chromadb_host: str = "" # ChromaDB HTTP 主机(空则内存模式)
chromadb_port: int = 8000 # ChromaDB HTTP 端口
chroma_collection: str = "hunjing_memories" # ChromaDB collection 名
chroma_persist_directory: str = "../db/chroma" # ChromaDB 持久化目录(相对 backend)
embedding_model_hint: str = "bge-m3" # embedding 模型逻辑名
# ==================== 自动记忆候选 [PLANNED] ====================
enable_memory_candidates: bool = False # 是否启用自动记忆候选抽取
memory_candidate_min_importance: float = 0.7 # 候选记忆重要性阈值(0.0~1.0)
# ==================== 审计日志(司命枢·起居注)[PLANNED] ====================
enable_audit_log: bool = True # 是否启用审计日志
# ==================== 感知(烛照枢)[PLANNED] ====================
enable_camera: bool = False # 是否启用摄像头(视觉感知)
enable_microphone: bool = False # 是否启用麦克风(听觉感知)
perception_save_raw_media: bool = False # 是否保存原始感知文件
perception_max_media_mb: int = 20 # 感知文件最大大小(MB)
# ==================== 语音服务 [PLANNED] ====================
enable_asr: bool = False # 是否启用 ASR(语音转文字)
enable_tts: bool = False # 是否启用 TTS(文字转语音)
enable_voice_training: bool = False # 是否启用语音训练(微调 TTS 音色)
enable_voice_chat: bool = False # 是否启用实时语音对话
# ==================== 导入限制 [PLANNED] ====================
import_max_file_mb: int = 20 # 人物资料导入文件最大大小(MB)
import_audio_max_duration_seconds: int = 600 # 语音样本最大时长(秒)
import_allowed_audio_mime_types: str = (
"audio/wav,audio/mpeg,audio/mp4,audio/flac" # 允许上传的语音样本 MIME 类型
)
+29
View File
@@ -0,0 +1,29 @@
"""乾坤沙箱配置。
从环境变量加载(与根目录 ``.env`` 统一)。字段名与 env 名一一对应(去除前缀后 snake_case
即 env 名),由 ``pydantic-settings`` 自动绑定,无需手动 ``os.getenv``。
- ``rust_sandbox_url`` 取自 ``RUST_SANDBOX_URL``(后端连沙箱的地址)。
- ``sandbox_auth_secret`` 与沙箱端 ``SANDBOX_AUTH_SECRET`` 共享,用于 HMAC 授权凭证签发。
"""
from __future__ import annotations
from pydantic_settings import BaseSettings
class SandboxSettings(BaseSettings):
"""沙箱客户端运行配置。"""
rust_sandbox_url: str = "http://127.0.0.1:8090" # RUST_SANDBOX_URL:后端连沙箱的地址
sandbox_auth_enabled: bool = True # SANDBOX_AUTH_ENABLED:是否启用 HMAC 授权
sandbox_auth_secret: str = "" # SANDBOX_AUTH_SECRET:与沙箱端共享的 HMAC 密钥
sandbox_default_timeout_ms: int = 30_000 # SANDBOX_DEFAULT_TIMEOUT_MS:默认执行超时
sandbox_auth_ttl_seconds: int = 30 # SANDBOX_AUTH_TTL_SECONDS:授权凭证有效期
def http_timeout(self, exec_timeout_ms: int | None) -> float:
"""HTTP 读取超时:执行超时 + 5s 余量,下限 10s。"""
exec_ms = (
exec_timeout_ms if exec_timeout_ms is not None else self.sandbox_default_timeout_ms
)
return max(exec_ms / 1000 + 5.0, 10.0)
+144
View File
@@ -0,0 +1,144 @@
"""主 Settings:多继承所有子配置,统一以绝对路径 ``_env_file`` 加载 ``.env``。
子配置见同目录下的 ``app_config`` / ``auth_config`` / ``database_config`` / ``log_config`` /
``runtime_config`` / ``model_config`` / ``sandbox_config``。本文件聚合字段,并承载
跨子配置的路径解析与计算属性。
配置优先级(自高到低):命令行参数 > 系统环境变量 > ``.env`` > 类属性默认值。
"""
from __future__ import annotations
from functools import cached_property
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.core.config.app_config import AppConfig
from app.core.config.auth_config import AuthConfig
from app.core.config.database_config import DatabaseConfig
from app.core.config.log_config import LogConfig
from app.core.config.model_config import ModelGatewayConfig
from app.core.config.runtime_config import RuntimeConfig
from app.core.config.sandbox_config import SandboxSettings
from app.core.config.time_config import TimeConfig
# backend 目录:app/core/config/settings.py → parents[3] = backend/
_BACKEND_DIR = Path(__file__).resolve().parents[3]
# .env 永远在项目根目录(backend 父级),以绝对路径定位
_ENV_FILE = _BACKEND_DIR.parent / ".env"
class Settings(
AppConfig,
AuthConfig,
DatabaseConfig,
LogConfig,
RuntimeConfig,
ModelGatewayConfig,
SandboxSettings,
TimeConfig,
BaseSettings,
):
"""应用配置类。
所有配置项可通过 ``.env`` 文件或环境变量覆盖。路径配置会自动解析为绝对路径。
"""
model_config = SettingsConfigDict(
env_file=str(_ENV_FILE), # 绝对路径,不使用 python-dotenv
env_file_encoding="utf-8",
case_sensitive=False, # 环境变量名不区分大小写
extra="ignore", # 忽略未定义的额外字段
)
# ==================== 路径解析 ====================
@cached_property
def backend_dir(self) -> Path:
"""backend 目录路径。"""
return _BACKEND_DIR
@cached_property
def project_root(self) -> Path:
"""项目根目录路径(backend 的父级)。"""
return _BACKEND_DIR.parent
def resolve_from_backend(self, value: str) -> Path:
"""解析相对于 backend 目录的路径为绝对路径。
Args:
value: 相对路径或绝对路径字符串
Returns:
解析后的绝对路径
"""
path = Path(value)
if path.is_absolute():
return path
return (self.backend_dir / path).resolve()
# ==================== 计算属性 ====================
@property
def resolved_database_url(self) -> str:
"""解析数据库连接 URL。
如果设置了 ``database_url`` 则直接使用,否则根据 ``database_type`` 构建。
"""
if self.database_url:
return self.database_url
if self.database_type == "sqlite":
db_path = self.resolve_from_backend(self.sqlite_database_path)
return f"sqlite+aiosqlite:///{db_path}"
password = self.postgres_password
ssl = "?ssl=require" if self.postgres_ssl else ""
return (
f"postgresql+asyncpg://{self.postgres_user}:{password}"
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}{ssl}"
)
@cached_property
def resolved_log_dir(self) -> Path:
"""解析日志目录为绝对路径。"""
return self.resolve_from_backend(self.log_dir)
@cached_property
def resolved_character_templates_path(self) -> Path:
"""解析人物模板配置文件路径。
如果配置文件不存在,尝试使用示例配置文件。
"""
configured = self.resolve_from_backend(self.character_templates_path)
if configured.exists():
return configured
example = self.project_root / "config" / "character_templates.example.yaml"
if example.exists():
return example
return configured
@cached_property
def resolved_model_config_path(self) -> Path:
"""解析模型配置文件路径。
如果配置文件不存在,尝试使用示例配置文件。
"""
configured = self.resolve_from_backend(self.model_config_path)
if configured.exists():
return configured
# 如果配置文件不存在,尝试使用示例配置
example = self.project_root / "config" / "models.example.yaml"
if example.exists():
return example
return configured
@cached_property
def resolved_avatar_dir(self) -> Path:
"""解析角色头像存储目录为绝对路径。"""
path = self.resolve_from_backend(self.avatar_dir)
path.mkdir(parents=True, exist_ok=True)
return path
# 全局配置实例
settings = Settings()
+49
View File
@@ -0,0 +1,49 @@
"""辰正时态配置。
所有字段可通过环境变量或 ``.env`` 覆盖(字段名即环境变量名,由 ``Settings.model_config``
统一加载,无前缀,与既有 ``AppConfig`` 等同级配置一致)。默认值对齐辰正设计 §7.1
离线档位与 §7.2/§7.3 触发阈值。v1 输入无 character 维度的时区/位置(character 模型
未持有时区字段),统一用此处的默认值;v2 从档案/会话覆盖。
"""
from __future__ import annotations
from pydantic_settings import BaseSettings
class TimeConfig(BaseSettings):
"""辰正时态服务配置。"""
# 时区与位置(v1 全局默认;v2 从档案/会话覆盖)
user_timezone: str = "Asia/Shanghai"
home_timezone: str = "Asia/Shanghai"
default_city: str = "上海"
default_locale: str = "zh-CN"
# 离线档位阈值(分钟),对齐设计 §7.1 / duration.py 默认
offline_active_minutes: float = 60
offline_short_absence_minutes: float = 720
offline_long_absence_minutes: float = 4320
offline_reunion_impact_minutes: float = 4320
# 感慨触发(§7.2)
sentiment_prewarm_days: int = 7
# 认识天数有感节点(逗号分隔;§7.2 duration_node)
sentiment_duration_nodes: str = "100,200,365,500,730,1000"
# 强节气名(§7.2 solar_term;立春/冬至/清明/中秋/除夕等)
sentiment_strong_solar_terms: str = "立春,清明,冬至,立夏,立秋,立冬"
# 关怀触发(§7.3)
care_enabled: bool = True
jetlag_threshold_minutes: int = 180
late_night_min_turns: int = 3
@property
def duration_nodes(self) -> set[int]:
"""解析 ``sentiment_duration_nodes`` 为整数集合。"""
return {int(x) for x in self.sentiment_duration_nodes.split(",") if x.strip()}
@property
def strong_solar_terms(self) -> set[str]:
"""解析强节气名集合。"""
return {s.strip() for s in self.sentiment_strong_solar_terms.split(",") if s.strip()}
+123
View File
@@ -0,0 +1,123 @@
"""统一错误响应与异常处理。"""
from contextvars import ContextVar
from typing import Any
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.core.ids import new_trace_id
from app.core.logging import log_with_context
from app.core.security import redact_value
from app.modules.safety.exceptions import SafetyError
trace_id_var: ContextVar[str | None] = ContextVar("trace_id", default=None)
ERROR_HTTP_STATUS: dict[str, int] = {
"invalid_request": status.HTTP_400_BAD_REQUEST,
"unauthorized": status.HTTP_401_UNAUTHORIZED,
"permission_denied": status.HTTP_403_FORBIDDEN,
"requires_confirmation": status.HTTP_403_FORBIDDEN,
"not_found": status.HTTP_404_NOT_FOUND,
"conflict": status.HTTP_409_CONFLICT,
"payload_too_large": status.HTTP_413_CONTENT_TOO_LARGE,
"unsupported_media_type": status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
"validation_failed": status.HTTP_422_UNPROCESSABLE_CONTENT,
"context_overflow": status.HTTP_400_BAD_REQUEST,
"run_timeout": status.HTTP_504_GATEWAY_TIMEOUT,
"model_error": status.HTTP_502_BAD_GATEWAY,
"model_rate_limited": status.HTTP_429_TOO_MANY_REQUESTS,
"model_timeout": status.HTTP_504_GATEWAY_TIMEOUT,
"model_config_error": status.HTTP_500_INTERNAL_SERVER_ERROR,
"model_unavailable": status.HTTP_503_SERVICE_UNAVAILABLE,
"model_provider_key_missing": status.HTTP_500_INTERNAL_SERVER_ERROR,
"tool_error": status.HTTP_502_BAD_GATEWAY,
"safety_rejected": status.HTTP_403_FORBIDDEN,
"rate_limited": status.HTTP_429_TOO_MANY_REQUESTS,
"internal_error": status.HTTP_500_INTERNAL_SERVER_ERROR,
}
class AppError(Exception):
def __init__(
self,
code: str,
message: str,
*,
status_code: int | None = None,
details: dict[str, Any] | None = None,
) -> None:
self.code = code
self.message = message
self.status_code = status_code or ERROR_HTTP_STATUS.get(code, status.HTTP_400_BAD_REQUEST)
self.details = details or {}
def current_trace_id() -> str:
trace_id = trace_id_var.get()
if trace_id is None:
trace_id = new_trace_id()
trace_id_var.set(trace_id)
return trace_id
def error_response(error: AppError) -> JSONResponse:
return JSONResponse(
status_code=error.status_code,
content={
"error": {
"code": error.code,
"message": error.message,
"trace_id": current_trace_id(),
"details": redact_value(error.details),
}
},
)
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def handle_app_error(_request: Request, exc: AppError) -> JSONResponse:
return error_response(exc)
@app.exception_handler(SafetyError)
async def handle_safety_error(_request: Request, exc: SafetyError) -> JSONResponse:
# 司命异常自带契约错误码(如 permission_denied → 403),按错误码映射 HTTP 状态。
return error_response(AppError(exc.code, exc.message))
@app.exception_handler(RequestValidationError)
async def handle_validation_error(
_request: Request, exc: RequestValidationError
) -> JSONResponse:
return error_response(
AppError(
"validation_failed",
"请求字段校验失败。",
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
details={"errors": exc.errors()},
)
)
@app.exception_handler(StarletteHTTPException)
async def handle_http_error(_request: Request, exc: StarletteHTTPException) -> JSONResponse:
code = "not_found" if exc.status_code == status.HTTP_404_NOT_FOUND else "invalid_request"
return error_response(AppError(code, str(exc.detail), status_code=exc.status_code))
@app.exception_handler(Exception)
async def handle_unexpected_error(_request: Request, exc: Exception) -> JSONResponse:
log_with_context(
"未处理异常",
level="ERROR",
trace_id=current_trace_id(),
error_type=exc.__class__.__name__,
)
return error_response(
AppError(
"internal_error",
"服务端内部错误。",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
details={"type": exc.__class__.__name__},
)
)
+131
View File
@@ -0,0 +1,131 @@
"""统一 ID 生成。"""
from uuid import uuid4
def new_id(prefix: str) -> str:
return f"{prefix}{uuid4().hex}"
def new_character_id() -> str:
return new_id("character_")
def new_session_id() -> str:
return new_id("session_")
def new_message_id() -> str:
return new_id("message_")
def new_run_id() -> str:
return new_id("run_")
def new_event_id() -> str:
return new_id("event_")
def new_trace_id() -> str:
return new_id("trace_")
def new_memory_id() -> str:
return new_id("memory_")
def new_memory_link_id() -> str:
return new_id("memory_link_")
def new_personality_id() -> str:
return new_id("personality_")
def new_personality_proposal_id() -> str:
return new_id("personality_proposal_")
def new_audit_id() -> str:
return new_id("audit_")
def new_goal_id() -> str:
return new_id("goal_")
def new_world_entity_id() -> str:
return new_id("world_entity_")
def new_world_relation_id() -> str:
return new_id("world_relation_")
def new_plan_id() -> str:
return new_id("plan_")
def new_import_job_id() -> str:
return new_id("import_")
def new_import_item_id() -> str:
return new_id("import_item_")
def new_profile_candidate_id() -> str:
return new_id("profile_candidate_")
def new_memory_candidate_id() -> str:
return new_id("memory_candidate_")
def new_cycle_id() -> str:
return new_id("cycle_")
def new_evolution_candidate_id() -> str:
return new_id("evolution_")
def new_curiosity_id() -> str:
return new_id("curiosity_")
def new_subgoal_id() -> str:
return new_id("subgoal_")
def new_skill_id() -> str:
return new_id("skill_")
def new_mcp_server_id() -> str:
return new_id("mcp_")
def new_anniversary_id() -> str:
return new_id("anniv_")
def new_user_id() -> str:
return new_id("user_")
def new_role_id() -> str:
return new_id("rl_")
def new_permission_id() -> str:
return new_id("pm_")
def new_lorebook_id() -> str:
return new_id("lorebook_")
def new_lorebook_entry_id() -> str:
return new_id("lorebook_entry_")
+235
View File
@@ -0,0 +1,235 @@
"""loguru 日志配置(后端日志系统的唯一出口)。
提供全局日志配置、标准 logging 接管、上下文管理和便捷的日志记录函数。
所有日志(含 uvicorn / SQLAlchemy 等第三方库)统一汇入 loguru,
文件统一存放在项目根目录的 logs 文件夹下。
"""
import logging
import sys
from contextvars import ContextVar
from pathlib import Path
from typing import Any
from loguru import logger
from app.core.config import settings
# ==================== 上下文变量 ====================
# 用于在异步请求中传递日志上下文
_request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
"""请求 ID 上下文变量,用于追踪单个请求的完整链路。"""
_user_id_var: ContextVar[str | None] = ContextVar("user_id", default=None)
"""用户 ID 上下文变量,用于记录当前操作用户。"""
class InterceptHandler(logging.Handler):
"""标准 ``logging`` → loguru 转发器。
uvicorn / SQLAlchemy / httpx / langchain 等第三方库一律使用标准 ``logging`` 模块;
把本 handler 装到 root logger 后,它们的日志会统一汇入 loguru,与应用自有日志
走同一 sink、格式与落盘策略,实现整个后端日志系统的单一出口。
注:此处 ``import logging`` 与「避免使用 logging 模块」规范并不冲突——那条规范
针对的是「用 logging 记业务日志」,而本 handler 的职责恰是把 logging 的输出
导入 loguru,是统一日志出口的必要接管机制。
"""
def emit(self, record: logging.LogRecord) -> None:
# 将标准 logging 的级别名映射为 loguru 级别
try:
level: str | int = logger.level(record.levelname).name
except ValueError:
level = record.levelno
# 沿调用栈回溯到真正的日志发出处,让 loguru 记录正确的文件名 / 行号
frame, depth = logging.currentframe(), 2
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def setup_logging() -> None:
"""配置全局日志系统。
配置内容:
1. 创建日志目录
2. 移除 loguru 默认处理器
3. 添加控制台输出(彩色,级别跟随 debug 开关与 LOG_LEVEL)
4. 添加主日志文件(按大小轮转、压缩)
5. 添加错误日志单独文件(保留更久)
6. 添加访问日志(可选)
7. 接管标准 logging,使 uvicorn / SQLAlchemy 等第三方日志汇入 loguru
日志文件位置:
- {project_root}/logs/app.log # 主日志
- {project_root}/logs/errors.log # 错误日志
- {project_root}/logs/access.log # 访问日志(可选)
"""
# 获取并创建日志目录
log_dir: Path = settings.resolved_log_dir
log_dir.mkdir(parents=True, exist_ok=True)
# 移除 loguru 默认的 stderr 处理器
logger.remove()
# 判断是否为开发环境。开发 reload 进程不使用异步队列,避免退出时残留 multiprocessing semaphore。
is_dev = settings.is_development
enqueue_logs = not is_dev
# 控制台输出格式(带颜色,便于阅读)
console_format = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan>"
"{extra} - <level>{message}</level>\n{exception}"
)
# 文件输出格式(纯文本,便于解析)
file_format = (
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | "
"{name}:{function}:{line}{extra} | {message}\n{exception}"
)
# ----- 控制台输出 -----
# 控制台级别跟随 debug 开关与 LOG_LEVEL:debug=true → DEBUG,否则用 LOG_LEVEL。
console_level = "DEBUG" if settings.debug else settings.log_level
logger.add(
sys.stderr, # 输出到标准错误
level=console_level, # 日志级别
format=console_format, # 彩色格式
colorize=is_dev, # 开发环境启用颜色
enqueue=enqueue_logs, # 生产环境异步写入,提高性能
backtrace=is_dev, # 开发环境显示完整回溯
diagnose=is_dev, # 开发环境显示诊断信息
)
# ----- 主日志文件 -----
# 记录所有级别日志,按大小轮转,自动压缩
logger.add(
log_dir / "app.log", # 日志文件路径
level=settings.log_level, # 日志级别
format=file_format, # 纯文本格式
rotation=settings.log_rotation_size, # 轮转大小:10 MB
retention=f"{settings.log_retention_days} days", # 保留天数:14天
compression="gz", # 压缩旧日志
encoding="utf-8", # 编码
enqueue=enqueue_logs, # 生产环境异步写入
backtrace=False, # 不保存完整回溯
diagnose=False, # 不保存诊断信息
)
# ----- 错误日志单独文件 -----
# 只记录 ERROR 及以上级别,保留更久,便于故障排查
logger.add(
log_dir / "errors.log", # 错误日志文件路径
level="ERROR", # 只记录 ERROR 及以上
format=file_format, # 纯文本格式
rotation="50 MB", # 轮转大小:50 MB
retention="30 days", # 保留天数:30天
compression="gz", # 压缩旧日志
encoding="utf-8", # 编码
enqueue=enqueue_logs, # 生产环境异步写入
backtrace=True, # 错误时保留完整堆栈
diagnose=True, # 错误时保留诊断信息
)
# ----- 访问日志(可选) -----
# 通过 filter 只记录标记为 access 类型的日志
if settings.log_access:
logger.add(
log_dir / "access.log", # 访问日志文件路径
level="INFO", # INFO 及以上
format=file_format, # 纯文本格式
rotation="100 MB", # 轮转大小:100 MB
retention="7 days", # 保留天数:7天
compression="gz", # 压缩旧日志
encoding="utf-8", # 编码
enqueue=enqueue_logs, # 生产环境异步写入
filter=lambda r: r["extra"].get("type") == "access", # 只记录访问日志
)
# ----- 接管标准 logging -----
# force=True 清掉第三方库自带的 handler(含 uvicorn 默认 dictConfig);
# level=0 让记录全量进入,最终级别过滤交由上方 loguru sink 完成。
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
# 应用已有自研访问日志中间件(trace_middleware + log_access,含 trace_id /
# method / path / status / duration 等结构化字段),关闭 uvicorn.access 以
# 避免访问日志重复落盘。
logging.getLogger("uvicorn.access").disabled = True
# ==================== 日志上下文管理 ====================
def set_request_id(request_id: str) -> None:
"""设置当前请求的 ID。
Args:
request_id: 请求唯一标识,用于链路追踪
"""
_request_id_var.set(request_id)
def set_user_id(user_id: str) -> None:
"""设置当前操作用户的 ID。
Args:
user_id: 用户唯一标识
"""
_user_id_var.set(user_id)
def clear_log_context() -> None:
"""清除当前日志上下文。
在请求结束后调用,避免上下文污染。
"""
_request_id_var.set(None)
_user_id_var.set(None)
def get_log_context() -> dict[str, Any]:
"""获取当前日志上下文。
Returns:
包含 request_id 和 user_id 的字典(如果已设置)
"""
context = {}
if rid := _request_id_var.get():
context["request_id"] = rid
if uid := _user_id_var.get():
context["user_id"] = uid
return context
def log_with_context(message: str, level: str = "INFO", **kwargs: Any) -> None:
"""带上下文记录日志。
自动附加当前请求的 request_id 和 user_id。
Args:
message: 日志消息
level: 日志级别(DEBUG/INFO/WARNING/ERROR)
**kwargs: 额外的上下文字段
"""
context = get_log_context()
context.update(kwargs)
logger.bind(**context).log(level, message)
def log_access(message: str, **kwargs: Any) -> None:
"""记录访问日志。
自动添加 type="access" 标记,会被 access.log 过滤器捕获。
Args:
message: 日志消息
**kwargs: 额外的上下文字段(如 method, path, status, duration 等)
"""
logger.bind(type="access", **kwargs).info(message)
+44
View File
@@ -0,0 +1,44 @@
"""敏感信息脱敏工具。"""
import re
from collections.abc import Mapping
from typing import Any
SENSITIVE_KEYS = {
"api_key",
"apikey",
"authorization",
"password",
"secret",
"token",
"access_token",
"refresh_token",
"private_key",
}
_REDACTED = "[REDACTED]"
_BEARER_RE = re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]+=*", re.IGNORECASE)
def redact_text(value: str) -> str:
return _BEARER_RE.sub("Bearer [REDACTED]", value)
def redact_mapping(data: Mapping[str, Any]) -> dict[str, Any]:
redacted: dict[str, Any] = {}
for key, value in data.items():
normalized = key.lower().replace("-", "_")
if normalized in SENSITIVE_KEYS or any(part in normalized for part in SENSITIVE_KEYS):
redacted[key] = _REDACTED
else:
redacted[key] = redact_value(value)
return redacted
def redact_value(value: Any) -> Any:
if isinstance(value, str):
return redact_text(value)
if isinstance(value, Mapping):
return redact_mapping(value)
if isinstance(value, list):
return [redact_value(item) for item in value]
return value
+15
View File
@@ -0,0 +1,15 @@
"""时间工具。"""
from datetime import UTC, datetime
def utc_now() -> datetime:
return datetime.now(UTC)
def to_iso(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")