初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,82 @@
|
||||
# ==============================
|
||||
# Docker 构建忽略清单
|
||||
# build context 为项目根(docker-compose.yml 中 context: ..),
|
||||
# 本文件同时影响 backend 与 frontend 镜像构建。
|
||||
# ==============================
|
||||
|
||||
# ----- 版本控制 -----
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# ----- Python / 后端 -----
|
||||
backend/.venv/
|
||||
backend/**/__pycache__/
|
||||
backend/**/*.py[cod]
|
||||
backend/.pytest_cache/
|
||||
backend/.mypy_cache/
|
||||
backend/.ruff_cache/
|
||||
backend/.coverage
|
||||
backend/coverage.xml
|
||||
backend/htmlcov/
|
||||
backend/dist/
|
||||
backend/build/
|
||||
backend/*.egg-info/
|
||||
|
||||
# ----- Node / 前端 -----
|
||||
frontend/node_modules/
|
||||
frontend/.pnpm-store/
|
||||
frontend/.next/
|
||||
frontend/out/
|
||||
frontend/.vercel/
|
||||
frontend/.turbo/
|
||||
frontend/*.tsbuildinfo
|
||||
frontend/.eslintcache
|
||||
frontend/npm-debug.log*
|
||||
frontend/pnpm-debug.log*
|
||||
|
||||
# ----- 运行时数据与本地存储(禁止进镜像) -----
|
||||
db/*.sqlite
|
||||
db/*.sqlite3
|
||||
db/*.db
|
||||
db/*.db-shm
|
||||
db/*.db-wal
|
||||
db/chroma/
|
||||
storage/
|
||||
uploads/
|
||||
media/
|
||||
objects/
|
||||
logs/
|
||||
*.log
|
||||
*.pid
|
||||
|
||||
# ----- Rust / 安全沙箱 -----
|
||||
sandbox/target/
|
||||
sandbox/sandbox-work/
|
||||
**/*.rs.bk
|
||||
|
||||
# ----- Docker 本地覆盖 -----
|
||||
docker/*.local.yml
|
||||
docker-compose.override.yml
|
||||
|
||||
# ----- 配置密钥(只允许 .example) -----
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
config/models.yaml
|
||||
frontend/.env.local
|
||||
|
||||
# ----- IDE / OS -----
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# ----- AI / 工具缓存 -----
|
||||
.claude/cache/
|
||||
.claude/tmp/
|
||||
|
||||
# ----- 文档(镜像运行不需要) -----
|
||||
docs/
|
||||
README.md
|
||||
**/CLAUDE.md
|
||||
+667
@@ -0,0 +1,667 @@
|
||||
# 魂镜 环境变量示例
|
||||
# 复制为 .env 后按需修改
|
||||
#
|
||||
# 分层分组法排列:核心运行环境 → 应用配置 → 第三方服务 → 基础设施
|
||||
# 每组内按"同类"对齐:连接地址 → 端口 → 认证信息 → 开关/参数
|
||||
# 各组按"如果删除此功能能否一键删除整组"的组织原则排列
|
||||
#
|
||||
# 标注了"必填"的变量在启动前必须设置,
|
||||
# 标注"按需"的变量保留默认值即可正常工作。
|
||||
# 每个变量的默认值是本地开发推荐值。生产环境请根据实际部署调整。
|
||||
|
||||
# =====================================================================
|
||||
# 全局基础配置(所有子系统共用)
|
||||
# =====================================================================
|
||||
|
||||
# 应用名称,仅用于 OpenAPI、日志和健康检查展示
|
||||
APP_NAME=魂镜
|
||||
|
||||
# 运行环境:dev / prod
|
||||
# prod 环境下博闻的日志输出会切换为 JSON 格式,便于日志采集系统(如 Loki)解析
|
||||
APP_ENV=dev
|
||||
|
||||
# 服务端默认时区,用于日志和数据库时间展示。格式:IANA Time Zone
|
||||
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
TIMEZONE=Asia/Shanghai
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 后端 · 灵台(FastAPI)
|
||||
# =====================================================================
|
||||
|
||||
# --- 部署与跨域 ---
|
||||
# 后端监听地址与端口
|
||||
BACKEND_HOST=0.0.0.0
|
||||
BACKEND_PORT=8000
|
||||
|
||||
# 允许跨域来源,多个来源用逗号分隔
|
||||
# 生产环境前端独立域名部署时生效;本地开发浏览器经 next.config.ts 同源代理,不触发 CORS
|
||||
CORS_ALLOW_ORIGINS=http://localhost:3000
|
||||
|
||||
# HTTP API 前缀
|
||||
API_PREFIX=/api/v1
|
||||
|
||||
# 是否开启 FastAPI debug。生产环境必须为 false
|
||||
DEBUG=false
|
||||
|
||||
# --- 数据库(金匮)---
|
||||
# 数据库类型:sqlite / pgsql
|
||||
# - sqlite:本地开发默认,无需 Docker,数据库文件放在项目根目录 db/ 下
|
||||
# - pgsql:正式环境或模拟正式环境,使用 PostgreSQL / asyncpg
|
||||
DATABASE_TYPE=sqlite
|
||||
|
||||
# SQLite 配置。DATABASE_TYPE=sqlite 时使用
|
||||
# 后端会根据 SQLITE_DATABASE_PATH 拼接为:sqlite+aiosqlite:///<绝对路径>
|
||||
SQLITE_DATABASE_PATH=../db/hunjing.sqlite3
|
||||
|
||||
# PostgreSQL 配置。DATABASE_TYPE=pgsql 时使用
|
||||
# 后端会根据下列字段拼接为:postgresql+asyncpg://<user>:<password>@<host>:<port>/<db>
|
||||
POSTGRES_HOST=127.0.0.1
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=hunjing
|
||||
POSTGRES_USER=hunjing
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_SSL=false
|
||||
POSTGRES_POOL_SIZE=10
|
||||
POSTGRES_MAX_OVERFLOW=20
|
||||
|
||||
# 可选覆盖项:如果显式设置 DATABASE_URL,则优先使用它,不再根据 DATABASE_TYPE 拼接
|
||||
DATABASE_URL=
|
||||
|
||||
# --- 日志 ---
|
||||
# 日志目录,默认放在项目根目录 logs/ 下
|
||||
LOG_DIR=../logs
|
||||
|
||||
# 日志级别:DEBUG / INFO / WARNING / ERROR
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# 是否启用访问日志(每个请求的 method、path、status、duration)
|
||||
LOG_ACCESS=true
|
||||
|
||||
# 日志保留天数。超过此天数的日志文件自动清理
|
||||
LOG_RETENTION_DAYS=14
|
||||
|
||||
# 日志轮转大小。单个日志文件达到此大小后自动切分
|
||||
LOG_ROTATION_SIZE=10 MB
|
||||
|
||||
# --- 默认人物与用户 ---
|
||||
# 本地单用户范围标识,当前阶段不实现多用户、组织、租户、OAuth 或 SSO
|
||||
DEFAULT_OWNER_SCOPE=local-user
|
||||
|
||||
# 默认逻辑模型名。必须指向 config/models.yaml 中真实远程 provider 的逻辑模型
|
||||
DEFAULT_MODEL_HINT=deepseek-v4-flash
|
||||
|
||||
# --- 管理端点和认证 ---
|
||||
|
||||
# 司命台管理 API(/api/v1/admin/*)的访问凭证
|
||||
# 请求需在 Authorization 头携带 ``Bearer <ADMIN_TOKEN>``;
|
||||
# 为空时管理端点 fail-closed(返回 503),强制部署时配置
|
||||
# 生成命令:python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# 前端司命台管理控制台需在 frontend/.env.local 同步配置 NEXT_PUBLIC_ADMIN_TOKEN(同值)
|
||||
ADMIN_TOKEN=
|
||||
|
||||
# JWT 签名密钥。必须是一个至少 32 字符的随机字符串
|
||||
# 为空时 /auth/* 端点返回 503 fail-closed,强制部署时配置
|
||||
# 生成命令:python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
JWT_SECRET=
|
||||
|
||||
# Access Token 有效期(秒)。默认 900(15 分钟)
|
||||
ACCESS_TOKEN_TTL=900
|
||||
|
||||
# Refresh Token 有效期(秒)。默认 604800(7 天)
|
||||
REFRESH_TOKEN_TTL=604800
|
||||
|
||||
# 种子初始管理员账户(仅首次启动时创建;生产环境部署后应立刻修改密码)
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
DEFAULT_ADMIN_EMAIL=admin@hunjing.local
|
||||
DEFAULT_ADMIN_PASSWORD=admin123
|
||||
|
||||
# --- SSE 流式输出 ---
|
||||
# 是否启用 SSE 流式输出
|
||||
ENABLE_SSE=true
|
||||
|
||||
# SSE 轮询数据库新增事件的间隔秒数
|
||||
SSE_POLL_INTERVAL_SECONDS=0.01
|
||||
|
||||
# SSE 心跳间隔秒数
|
||||
SSE_HEARTBEAT_SECONDS=15
|
||||
|
||||
# --- 功能开关 ---
|
||||
# 是否启用工具运行时(百工枢)。关闭时所有工具调用会被拒绝,但不会影响其他功能
|
||||
ENABLE_TOOL_RUNTIME=true
|
||||
|
||||
# 是否启用自动记忆候选。关闭时不会从对话中抽取记忆候选,但手动导入记忆不受影响
|
||||
ENABLE_MEMORY_CANDIDATES=false
|
||||
|
||||
# 是否启用内驱力扫描(太一枢的 curiosity / exploration)
|
||||
ENABLE_INTRINSIC_DRIVE=false
|
||||
|
||||
# 是否启用大周天自动触发(长期演化循环)
|
||||
ENABLE_BIG_CYCLE=true
|
||||
|
||||
# 是否启用洛书显式多步规划(Deliberative Planning)
|
||||
ENABLE_DELIBERATIVE_PLANNING=false
|
||||
|
||||
# 是否启用审计日志(司命枢·起居注)
|
||||
ENABLE_AUDIT_LOG=true
|
||||
|
||||
# 是否启用摄像头(烛照枢·视觉感知)
|
||||
ENABLE_CAMERA=false
|
||||
|
||||
# 是否启用麦克风(烛照枢·听觉感知)
|
||||
ENABLE_MICROPHONE=false
|
||||
|
||||
# 是否启用 ASR(语音转文字)
|
||||
ENABLE_ASR=false
|
||||
|
||||
# 是否启用 TTS(文字转语音)
|
||||
ENABLE_TTS=false
|
||||
|
||||
# 是否启用语音训练(基于导入的语音样本微调 TTS 音色)
|
||||
ENABLE_VOICE_TRAINING=false
|
||||
|
||||
# 是否启用实时语音对话(Voice Chat)
|
||||
ENABLE_VOICE_CHAT=false
|
||||
|
||||
# --- 运行时参数 ---
|
||||
# 单条文本消息最大字节数
|
||||
MAX_TEXT_MESSAGE_BYTES=32768
|
||||
|
||||
# 单个 Run 超时秒数
|
||||
RUN_TIMEOUT_SECONDS=120
|
||||
|
||||
# Agent 循环最大轮数
|
||||
AGENT_MAX_ITERATIONS=8
|
||||
|
||||
# 单个 Run 最大事件数
|
||||
RUN_MAX_EVENTS=1000
|
||||
|
||||
# 事件 payload 最大字节数
|
||||
EVENT_PAYLOAD_MAX_BYTES=65536
|
||||
|
||||
# 单次高风险工具确认挂起超时秒数;须小于 RUN_TIMEOUT_SECONDS,超时按拒绝处理
|
||||
CONFIRMATION_TIMEOUT_SECONDS=60
|
||||
|
||||
# 是否启用数据库持久化的确认存储(用于多 worker 部署)
|
||||
# 关闭时使用进程内 InMemoryConfirmationStore(默认),适合单进程开发模式
|
||||
USE_DB_CONFIRMATION_STORE=false
|
||||
|
||||
# 每轮对话最多注入的记忆条数
|
||||
MEMORY_INJECTION_LIMIT=5
|
||||
|
||||
# 自动记忆候选重要性阈值(0.0 ~ 1.0)。低于此阈值的不生成候选
|
||||
MEMORY_CANDIDATE_MIN_IMPORTANCE=0.7
|
||||
|
||||
# 内驱力扫描间隔秒数
|
||||
DRIVE_SCAN_INTERVAL_SECONDS=3600
|
||||
|
||||
# 单次扫描最多好奇问题数
|
||||
DRIVE_MAX_CURIOSITY_PER_SCAN=5
|
||||
|
||||
# 是否允许内驱力中断当前任务(Agent 循环中插入探索)
|
||||
DRIVE_ALLOW_INTERRUPT=false
|
||||
|
||||
# 触发大周天的记忆数阈值。新增记忆数达到此值时触发一轮大周天
|
||||
BIG_CYCLE_MEMORY_THRESHOLD=20
|
||||
|
||||
# 两次大周天最小间隔(秒),避免频繁触发
|
||||
BIG_CYCLE_MIN_INTERVAL_SECONDS=300
|
||||
|
||||
# 大周天候选生成所用模型(config/models.yaml 的逻辑模型名)
|
||||
BIG_CYCLE_MODEL=deepseek-v4-flash
|
||||
|
||||
# 单次大周天生成的演化候选上限
|
||||
BIG_CYCLE_MAX_CANDIDATES=10
|
||||
|
||||
# 高风险动作是否总是确认(即使 permission_policy 设为 allow 也弹出确认框)
|
||||
SAFETY_ALWAYS_CONFIRM_HIGH_RISK=true
|
||||
|
||||
# --- 工具调用参数 ---
|
||||
# 工具调用超时秒数(所有工具共享)
|
||||
TOOL_TIMEOUT_SECONDS=60
|
||||
|
||||
# fetch_url 等 HTTP 工具的 HTTP 请求超时秒数
|
||||
TOOL_WEB_HTTP_TIMEOUT_SECONDS=15
|
||||
|
||||
# MCP Server tools/list 请求超时秒数
|
||||
MCP_LIST_TIMEOUT_SECONDS=10
|
||||
|
||||
# MCP Server tools/call 请求超时秒数
|
||||
MCP_CALL_TIMEOUT_SECONDS=30
|
||||
|
||||
# MCP stdio 子进程启动超时秒数
|
||||
MCP_STDIO_START_TIMEOUT_SECONDS=15
|
||||
|
||||
# MCP stdio 单次 tools/call 超时秒数
|
||||
MCP_STDIO_CALL_TIMEOUT_SECONDS=30
|
||||
|
||||
# MCP stdio SIGTERM → SIGKILL 等待秒数
|
||||
MCP_STDIO_STOP_TIMEOUT_SECONDS=5
|
||||
|
||||
# Embedding 模型逻辑名。指向 config/models.yaml 中已注册的 embedding 模型
|
||||
EMBEDDING_MODEL_HINT=bge-m3
|
||||
|
||||
# --- 感知与导入 ---
|
||||
# 是否保存原始感知文件(摄像头 / 麦克风采集的原始文件)
|
||||
PERCEPTION_SAVE_RAW_MEDIA=false
|
||||
|
||||
# 感知文件最大大小(MB),超限的感知输入会被拒绝
|
||||
PERCEPTION_MAX_MEDIA_MB=20
|
||||
|
||||
# 人物资料导入文件最大大小(MB)
|
||||
IMPORT_MAX_FILE_MB=20
|
||||
|
||||
# 人物语音样本最大时长(秒)
|
||||
IMPORT_AUDIO_MAX_DURATION_SECONDS=600
|
||||
|
||||
# 允许上传的人物语音样本 MIME 类型,逗号分隔
|
||||
IMPORT_ALLOWED_AUDIO_MIME_TYPES=audio/wav,audio/mpeg,audio/mp4,audio/flac
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 模型网关 · 神识(LLM Provider API Keys)
|
||||
# =====================================================================
|
||||
# 模型名称、provider 映射、能力标签、超时、限流、价格等,统一放在 config/models.yaml 中。
|
||||
# .env 只保存各 provider 的 URL 与 API key,避免模型策略散落在环境变量中。
|
||||
|
||||
# --- DeepSeek ---
|
||||
DEEPSEEK_API_KEY=
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
|
||||
# --- 火山方舟 / 豆包 ---
|
||||
VOLCENGINE_ARK_API_KEY=
|
||||
VOLCENGINE_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
|
||||
# --- 小米 MiMo ---
|
||||
XIAOMI_MIMO_API_KEY=
|
||||
XIAOMI_MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
|
||||
# --- OpenAI ---
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# --- Anthropic ---
|
||||
ANTHROPIC_API_KEY=
|
||||
ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||
|
||||
# --- 元炁(本地推理引擎)---
|
||||
# 作为 OpenAI 兼容 provider 接入,供 bge-m3 embedding 等模型调用。
|
||||
YUANQI_BASE_URL=http://localhost:11434
|
||||
YUANQI_API_KEY=
|
||||
|
||||
# --- 其他兼容网关预留 ---
|
||||
CUSTOM_LLM_API_KEY=
|
||||
CUSTOM_LLM_BASE_URL=
|
||||
|
||||
# --- 模型 endpoint 池路由(高级调优项,保持默认即可正常工作)---
|
||||
# 物理 endpoint 池的 failover 与重试策略
|
||||
MODEL_ENDPOINT_POOL_ENABLED=true
|
||||
MODEL_ENDPOINT_POOL_DRY_RUN_SELECTION=false
|
||||
MODEL_ENDPOINT_POOL_EXPOSE_DEBUG_HEADERS=false
|
||||
MODEL_ENDPOINT_POOL_AGENT_FAILOVER_ENABLED=false
|
||||
MODEL_ENDPOINT_POOL_PER_ENDPOINT_MAX_RETRIES=1
|
||||
MODEL_ENDPOINT_POOL_RETRY_BASE_DELAY_SECONDS=0.25
|
||||
MODEL_ENDPOINT_POOL_RETRY_MAX_DELAY_SECONDS=2.0
|
||||
|
||||
# 模型配置文件路径
|
||||
# 默认读取项目根目录 config/models.yaml;不存在时 fallback 到 config/models.example.yaml
|
||||
MODEL_CONFIG_PATH=../config/models.yaml
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 元炁 · 本地推理引擎(Local Inference Engine)
|
||||
# =====================================================================
|
||||
|
||||
# 元炁监听地址与端口
|
||||
YUANQI_HOST=0.0.0.0
|
||||
YUANQI_PORT=11434
|
||||
|
||||
# 下载源:auto / huggingface / modelscope
|
||||
YUANQI_MODEL_SOURCE=auto
|
||||
|
||||
# 模型文件根目录
|
||||
YUANQI_MODELS_DIR=../models
|
||||
|
||||
# GGUF LLM 模型路径(为空自动扫描 models/gguf/)
|
||||
YUANQI_LLM_MODEL_PATH=
|
||||
|
||||
# 多模态 GGUF 模型路径
|
||||
YUANQI_VISION_MODEL_PATH=
|
||||
|
||||
# Embedding 模型(HF ID 或本地路径)
|
||||
YUANQI_EMBEDDING_MODEL=BAAI/bge-m3
|
||||
|
||||
# Reranker 模型(HF ID 或本地路径)
|
||||
YUANQI_RERANKER_MODEL=BAAI/bge-reranker-v2-m3
|
||||
|
||||
# ASR 模型(HF/ModelScope ID 或本地路径),默认 iic/SenseVoiceSmall
|
||||
# 自动下载到 models/funasr/<模型名>
|
||||
YUANQI_ASR_MODEL=iic/SenseVoiceSmall
|
||||
|
||||
# 推理设备:auto / cuda / mps / cpu
|
||||
YUANQI_DEVICE=auto
|
||||
|
||||
# LLM 默认参数
|
||||
YUANQI_LLM_MAX_TOKENS=2048
|
||||
YUANQI_LLM_TEMPERATURE=0.7
|
||||
YUANQI_LLM_CTX_SIZE=4096
|
||||
|
||||
# 日志级别
|
||||
YUANQI_LOG_LEVEL=info
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 天目 · 本地 OCR 引擎(Local OCR Engine)
|
||||
# =====================================================================
|
||||
|
||||
# 天目监听地址与端口
|
||||
TIANMU_HOST=0.0.0.0
|
||||
TIANMU_PORT=8101
|
||||
|
||||
# 模型文件根目录(容器内路径,Docker volume 映射)
|
||||
TIANMU_MODELS_DIR=../models
|
||||
|
||||
# 推理设备:auto / cuda / mps / cpu。
|
||||
# auto 表示天目启动时自动检测最优可用加速器(CUDA > MPS > CPU)
|
||||
TIANMU_DEVICE=auto
|
||||
|
||||
# 日志级别
|
||||
TIANMU_LOG_LEVEL=info
|
||||
|
||||
# OCR 结果输出目录(Markdown 文件等),默认 ../output/
|
||||
TIANMU_OUTPUT_DIR=
|
||||
|
||||
# HTTP 下载远端图片超时(秒)
|
||||
TIANMU_HTTP_TIMEOUT=30
|
||||
|
||||
# PaddleOCR-VL 最大生成 tokens
|
||||
TIANMU_VL_MAX_NEW_TOKENS=1024
|
||||
|
||||
# PaddleOCR-VL 图像最大像素数(长边像素 × 28 × 28)
|
||||
TIANMU_VL_MAX_PIXELS=1003520
|
||||
|
||||
# PP-OCRv6 模型覆盖路径(为空时自动扫描 $TIANMU_MODELS_DIR/ocr/ppocrv6/{det,rec,cls}/)
|
||||
# TIANMU_PPORCV6_DET_MODEL=
|
||||
# TIANMU_PPORCV6_REC_MODEL=
|
||||
# TIANMU_PPORCV6_CLS_MODEL=
|
||||
# TIANMU_PPORCV6_DICT_PATH=
|
||||
# PaddleOCR-VL 模型覆盖目录(为空时自动扫描 $TIANMU_MODELS_DIR/ocr/paddleocr-vl/)
|
||||
# TIANMU_PADDLEOCR_VL_MODEL_DIR=
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 博闻 · MCP Web Search(Go)
|
||||
# =====================================================================
|
||||
# 博闻是一个独立的 MCP Web Search 服务,与灵台(Python)运行时无关。
|
||||
|
||||
# --- 搜索 API Key ---
|
||||
# Tavily 搜索 API Key(必填)。多 key 用逗号分隔,博闻会按 EWMA 动态权重自动轮询
|
||||
TAVILY_API_KEYS=
|
||||
|
||||
# Brave Search API Key(必填)。X-Subscription-Token
|
||||
# 多 key 逗号分隔
|
||||
BRAVE_API_KEYS=
|
||||
|
||||
# --- Redis 缓存配置 ---
|
||||
# 博闻 Redis 缓存总开关。启用后作为内存 LRU 的第二层缓存(两级:L1 LRU → L2 Redis)
|
||||
# 在 SSE 多实例部署时共享缓存状态。单实例部署可以不启用
|
||||
# 注意:这里的 Redis 配置独立于后端灵台的 REDIS_ENABLED,两套可独立控制
|
||||
MCP_WEB_SEARCH_REDIS_ENABLED=false
|
||||
|
||||
# Redis 连接地址(host:port)
|
||||
MCP_WEB_SEARCH_REDIS_ADDR=127.0.0.1:6379
|
||||
|
||||
# Redis 密码(无密码则留空)
|
||||
MCP_WEB_SEARCH_REDIS_PASSWORD=
|
||||
|
||||
# Redis 数据库编号(0 ~ 15)
|
||||
MCP_WEB_SEARCH_REDIS_DB=0
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 基础设施(Redis/向量库/图库/对象存储)
|
||||
# =====================================================================
|
||||
|
||||
# ===== Redis(灵泉,后端灵台用)=====
|
||||
# 注意:博闻有独立的 MCP_WEB_SEARCH_REDIS_* 配置
|
||||
REDIS_ENABLED=false
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=0
|
||||
REDIS_PASSWORD=
|
||||
REDIS_URL=redis://127.0.0.1:6379/0
|
||||
|
||||
# 后台任务队列(Dramatiq)的 Redis 连接
|
||||
DRAMATIQ_ENABLED=false
|
||||
DRAMATIQ_BROKER_URL=redis://127.0.0.1:6379/1
|
||||
|
||||
# ===== 向量数据库(灵犀)=====
|
||||
# 向量数据库总开关(false 则纯 SQL 检索,不走向量库)。
|
||||
VECTOR_DB_ENABLED=false
|
||||
|
||||
# 向量数据库类型:chroma / milvus
|
||||
VECTOR_DB_TYPE=chroma
|
||||
|
||||
# ChromaDB 配置(VECTOR_DB_ENABLED=true && VECTOR_DB_TYPE=chroma 时使用)
|
||||
CHROMA_COLLECTION=hunjing_memories
|
||||
CHROMA_PERSIST_DIRECTORY=../db/chroma
|
||||
|
||||
# Milvus 配置(VECTOR_DB_ENABLED=true && VECTOR_DB_TYPE=milvus 时使用)
|
||||
MILVUS_HOST=127.0.0.1
|
||||
MILVUS_PORT=19530
|
||||
MILVUS_USER=
|
||||
MILVUS_PASSWORD=
|
||||
MILVUS_DATABASE=default
|
||||
MILVUS_COLLECTION=hunjing_memories
|
||||
|
||||
# ===== 图数据库(玄络)=====
|
||||
GRAPH_DB_ENABLED=false
|
||||
|
||||
# 图数据库类型:neo4j / nebulagraph
|
||||
GRAPH_DB_TYPE=neo4j
|
||||
|
||||
# Neo4j 配置(GRAPH_DB_ENABLED=true && GRAPH_DB_TYPE=neo4j 时使用)
|
||||
NEO4J_URI=bolt://127.0.0.1:7687
|
||||
NEO4J_USER=neo4j
|
||||
NEO4J_PASSWORD=change-me
|
||||
NEO4J_DATABASE=neo4j
|
||||
|
||||
# NebulaGraph 配置(GRAPH_DB_ENABLED=true && GRAPH_DB_TYPE=nebulagraph 时使用)
|
||||
NEBULAGRAPH_HOST=127.0.0.1
|
||||
NEBULAGRAPH_PORT=9669
|
||||
NEBULAGRAPH_USER=root
|
||||
NEBULAGRAPH_PASSWORD=nebula
|
||||
NEBULAGRAPH_SPACE=hunjing
|
||||
|
||||
# ===== 对象存储(玉函)=====
|
||||
# 存储后端:local / minio / aws_s3 / aliyun_oss / tencent_cos / huawei_obs / qiniu_kodo
|
||||
STORAGE_PROVIDER=local
|
||||
|
||||
# 对象 key 前缀,用于隔离不同环境或部署
|
||||
STORAGE_KEY_PREFIX=hunjing/local
|
||||
|
||||
# 预签名 URL 默认过期秒数
|
||||
STORAGE_PRESIGN_EXPIRES_SECONDS=900
|
||||
|
||||
# 本地存储(STORAGE_PROVIDER=local 时使用)
|
||||
LOCAL_STORAGE_ROOT=../storage/objects
|
||||
LOCAL_PUBLIC_BASE_URL=
|
||||
|
||||
# MinIO(STORAGE_PROVIDER=minio 时使用)
|
||||
MINIO_ENDPOINT=http://127.0.0.1:9000
|
||||
MINIO_REGION=local
|
||||
MINIO_BUCKET=hunjing
|
||||
MINIO_ACCESS_KEY_ID=
|
||||
MINIO_SECRET_ACCESS_KEY=
|
||||
MINIO_PUBLIC_BASE_URL=
|
||||
MINIO_FORCE_PATH_STYLE=true
|
||||
MINIO_SECURE=false
|
||||
|
||||
# AWS S3(STORAGE_PROVIDER=aws_s3 时使用)
|
||||
AWS_S3_REGION=us-east-1
|
||||
AWS_S3_BUCKET=hunjing
|
||||
AWS_S3_ACCESS_KEY_ID=
|
||||
AWS_S3_SECRET_ACCESS_KEY=
|
||||
AWS_S3_ENDPOINT=
|
||||
AWS_S3_PUBLIC_BASE_URL=
|
||||
AWS_S3_FORCE_PATH_STYLE=false
|
||||
|
||||
# 阿里云 OSS(STORAGE_PROVIDER=aliyun_oss 时使用)
|
||||
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
|
||||
ALIYUN_OSS_REGION=cn-hangzhou
|
||||
ALIYUN_OSS_BUCKET=hunjing
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=
|
||||
ALIYUN_OSS_PUBLIC_BASE_URL=
|
||||
|
||||
# 腾讯云 COS(STORAGE_PROVIDER=tencent_cos 时使用)
|
||||
TENCENT_COS_REGION=ap-guangzhou
|
||||
TENCENT_COS_BUCKET=hunjing
|
||||
TENCENT_COS_APP_ID=
|
||||
TENCENT_COS_SECRET_ID=
|
||||
TENCENT_COS_SECRET_KEY=
|
||||
TENCENT_COS_PUBLIC_BASE_URL=
|
||||
|
||||
# 华为云 OBS(STORAGE_PROVIDER=huawei_obs 时使用)
|
||||
HUAWEI_OBS_ENDPOINT=https://obs.cn-north-4.myhuaweicloud.com
|
||||
HUAWEI_OBS_REGION=cn-north-4
|
||||
HUAWEI_OBS_BUCKET=hunjing
|
||||
HUAWEI_OBS_ACCESS_KEY_ID=
|
||||
HUAWEI_OBS_SECRET_ACCESS_KEY=
|
||||
HUAWEI_OBS_PUBLIC_BASE_URL=
|
||||
|
||||
# 七牛云 Kodo(STORAGE_PROVIDER=qiniu_kodo 时使用)
|
||||
QINIU_KODO_REGION=z0
|
||||
QINIU_KODO_BUCKET=hunjing
|
||||
QINIU_KODO_ACCESS_KEY=
|
||||
QINIU_KODO_SECRET_KEY=
|
||||
QINIU_KODO_DOMAIN=
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 乾坤 · Rust 安全沙箱
|
||||
# =====================================================================
|
||||
# 沙箱是百工枢的执行后端,使用 Rust 实现隔离执行环境。
|
||||
|
||||
# --- 服务地址 ---
|
||||
# Rust 沙箱地址。后端通过该 URL 连接乾坤沙箱服务
|
||||
# 本地开发:http://127.0.0.1:8090;Docker Compose 内:http://hunjing-sandbox:8090
|
||||
RUST_SANDBOX_URL=
|
||||
|
||||
# 沙箱进程监听地址(cargo run 时读取;容器内由 docker-compose 覆盖)
|
||||
SANDBOX_HOST=0.0.0.0
|
||||
SANDBOX_PORT=8090
|
||||
|
||||
# 沙箱工作根目录:所有执行的工作目录都约束在其之下
|
||||
SANDBOX_BASE_WORKDIR=../sandbox-work
|
||||
|
||||
# 沙箱日志目录
|
||||
SANDBOX_LOG_DIR=../logs
|
||||
|
||||
# 沙箱 tracing 日志级别
|
||||
RUST_LOG=info
|
||||
|
||||
# --- 执行限额 ---
|
||||
# 单次执行全局超时上限(毫秒),请求 timeout_ms 不可超过该值
|
||||
SANDBOX_MAX_TIMEOUT_MS=30000
|
||||
|
||||
# 最大并发执行数。超过此值的请求排队等待
|
||||
SANDBOX_MAX_CONCURRENT=8
|
||||
|
||||
# 单流(stdout / stderr 各自)最大捕获字节数
|
||||
SANDBOX_MAX_OUTPUT_BYTES=1048576
|
||||
|
||||
# 单次文件读写(/v1/file/*)最大字节数
|
||||
SANDBOX_MAX_FILE_BYTES=1048576
|
||||
|
||||
# 默认注入子进程的环境变量白名单(逗号分隔)
|
||||
SANDBOX_ALLOWED_ENV=PATH,HOME,TZ,LANG
|
||||
|
||||
# --- 授权 ---
|
||||
# 是否校验调用方授权凭证(HMAC)
|
||||
# 生产必须为 true 且填写密钥;仅本地快速联调可临时关闭
|
||||
SANDBOX_AUTH_ENABLED=true
|
||||
|
||||
# 司命授权 HMAC 共享密钥
|
||||
# 生产请用高熵随机值,例如:openssl rand -base64 48
|
||||
SANDBOX_AUTH_SECRET=shared-secret
|
||||
|
||||
# 旧 secret(逗号分隔)。无中断轮换的并存窗口
|
||||
# 沙箱先加 previous 认新旧 → 后端切新 secret → TTL 窗口后清 previous
|
||||
SANDBOX_AUTH_SECRET_PREVIOUS=
|
||||
|
||||
# 后端客户端默认执行超时(毫秒),请求未指定 timeout_ms 时使用
|
||||
SANDBOX_DEFAULT_TIMEOUT_MS=30000
|
||||
|
||||
# 授权凭证有效期(秒),凭证为短期 HMAC token
|
||||
SANDBOX_AUTH_TTL_SECONDS=30
|
||||
|
||||
# 凭证未下发 isolation_mode 时的兜底隔离模式(process / container)
|
||||
SANDBOX_ISOLATION_DEFAULT=process
|
||||
|
||||
# --- 进程级资源限额(setrlimit)---
|
||||
# 配额分层(每层只能收紧、不能放宽):全局天花板 *_MAX → 凭证 rlimit_overrides → 请求 rlimits → 默认值
|
||||
SANDBOX_RLIMIT_FSIZE_BYTES=67108864
|
||||
SANDBOX_RLIMIT_AS_BYTES=536870912
|
||||
SANDBOX_RLIMIT_CPU_SECS=30
|
||||
SANDBOX_RLIMIT_NPROC=64
|
||||
SANDBOX_RLIMIT_NOFILE=256
|
||||
SANDBOX_RLIMIT_DISABLE=
|
||||
|
||||
# --- 容器隔离(Docker,L3-L4 高风险工具)---
|
||||
# Docker 容器模式总开关
|
||||
SANDBOX_DOCKER_ENABLED=true
|
||||
|
||||
# Docker 守护进程 socket 地址
|
||||
SANDBOX_DOCKER_SOCKET=unix:///var/run/docker.sock
|
||||
|
||||
# 预热池:每种语言保持的最大空闲容器数
|
||||
SANDBOX_WARM_POOL_MAX_IDLE=3
|
||||
|
||||
# 全局最大容器数(各语言空闲 + 运行中总和)
|
||||
SANDBOX_WARM_POOL_GLOBAL_MAX=20
|
||||
|
||||
# 空闲容器超时回收(秒)
|
||||
SANDBOX_CONTAINER_IDLE_TTL_SECS=300
|
||||
|
||||
# 每容器内存上限(字节)
|
||||
SANDBOX_CONTAINER_MEMORY_BYTES=536870912
|
||||
|
||||
# 每容器 CPU 上限(纳秒核)
|
||||
SANDBOX_CONTAINER_CPU_NANOCORES=1000000000
|
||||
|
||||
# 每容器进程数上限(防 fork bomb)
|
||||
SANDBOX_CONTAINER_PIDS_LIMIT=64
|
||||
|
||||
# 容器网络模式。留空使用 Docker 默认 bridge 网络
|
||||
SANDBOX_CONTAINER_NETWORK=
|
||||
|
||||
# 容器创建/启动超时(毫秒)
|
||||
SANDBOX_CONTAINER_CREATE_TIMEOUT_MS=60000
|
||||
|
||||
# 各语言容器镜像名
|
||||
# 未配置的语言对应的请求降级为进程模式
|
||||
SANDBOX_CONTAINER_IMAGE_PYTHON=python:3.12-slim
|
||||
SANDBOX_CONTAINER_IMAGE_NODE=node:24-slim
|
||||
# SANDBOX_CONTAINER_IMAGE_JAVA=eclipse-temurin:21-jre
|
||||
# SANDBOX_CONTAINER_IMAGE_GENERIC=debian:bookworm-slim
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 安全管理策略
|
||||
# =====================================================================
|
||||
# 工具策略文件路径(由 config/safety.example.yaml 复制为 safety.yaml)
|
||||
# 未配置时 SafetyGovernor 使用内置默认策略;配置后覆盖各工具的 risk_level / permission_policy
|
||||
SAFETY_POLICY_PATH=config/safety.yaml
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 前端 · 琉璃镜(Next.js) — 参考信息
|
||||
# =====================================================================
|
||||
# 前端配置在 frontend/.env.local 中管理(含 NEXT_PUBLIC_*)。
|
||||
# 目前前端的鉴权已改为通过 /api/auth/me 返回的 is_admin 判断,不再需要独立的管理 token。
|
||||
# BFF 连接后端的服务端地址(仅 Next 服务端读取,不暴露给浏览器):
|
||||
# BACKEND_URL=http://localhost:8000 (本地开发,跨公网须 https://)
|
||||
# Docker 部署由 docker-compose.app.yml 写死为 http://hunjing-backend:8000
|
||||
# 浏览器所有请求同源走 BFF(/api/proxy、/api/sse、/api/auth),客户端不需要后端地址。
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
# 环境变量与密钥
|
||||
.env
|
||||
.env*.local
|
||||
config/*.yaml
|
||||
config/mcp/*.yaml
|
||||
config/gateway/*.yaml
|
||||
!config/*.example.yaml
|
||||
!config/mcp/*.example.yaml
|
||||
!config/gateway/*.example.yaml
|
||||
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
|
||||
# Node / Next.js
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
.next/
|
||||
out/
|
||||
.vercel/
|
||||
.turbo/
|
||||
*.tsbuildinfo
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# 数据库与本地存储
|
||||
db/*
|
||||
db/*.sqlite
|
||||
db/*.sqlite3
|
||||
db/*.db
|
||||
db/*.db-shm
|
||||
db/*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# ChromaDB / Milvus / Neo4j / NebulaGraph 本地数据
|
||||
/db/chromadb/
|
||||
milvus/
|
||||
neo4j/data/
|
||||
neo4j/logs/
|
||||
nebulagraph/data/
|
||||
nebulagraph/logs/
|
||||
|
||||
# 对象资源与上传文件
|
||||
storage/
|
||||
uploads/
|
||||
media/
|
||||
objects/
|
||||
|
||||
# 数据库备份产物
|
||||
backups/
|
||||
|
||||
|
||||
# 日志与运行产物
|
||||
logs/
|
||||
*.log
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
run/
|
||||
tmp/
|
||||
temp/
|
||||
.cache/
|
||||
|
||||
|
||||
# 测试与覆盖率
|
||||
.coverage.*
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
|
||||
# IDE / OS
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.example.json
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
|
||||
# Docker / 本地覆盖配置
|
||||
docker/*.local.yml
|
||||
docker-compose.override.yml
|
||||
|
||||
|
||||
# Rust / 安全沙箱
|
||||
target/
|
||||
**/*.rs.bk
|
||||
sandbox-work/
|
||||
|
||||
|
||||
# AI / 工具本地缓存
|
||||
.claude
|
||||
!.claude/commands/
|
||||
!.claude/skills/
|
||||
.codegraph/
|
||||
.impeccable/
|
||||
.understand-anything/
|
||||
!.understand-anything/intermediate/
|
||||
!.understand-anything/diff-overlay.json
|
||||
|
||||
|
||||
# Go / 博闻 MCP Web Search
|
||||
mcp/web-search/bin/
|
||||
mcp/web-search/tmp/
|
||||
|
||||
|
||||
# 其他
|
||||
/characters/
|
||||
/models/
|
||||
output/
|
||||
@@ -0,0 +1,102 @@
|
||||
# hunjing
|
||||
|
||||
参考下面项目的详细的设计内容和理念:
|
||||
酒馆:<https://github.com/SillyTavern/SillyTavern>
|
||||
pi :<https://github.com/earendil-works/pi>
|
||||
向量库:<https://github.com/oceanbase/seekdb>
|
||||
live-2d: <https://github.com/stevenjoezhang/live2d-widget>
|
||||
上下文压缩:<https://github.com/headroomlabs-ai/headroom>
|
||||
记忆:<https://github.com/AGI-is-going-to-arrive/Memory-Palace>,<https://github.com/Dataojitori/nocturne_memory>,<https://github.com/TencentCloud/TencentDB-Agent-Memory>,<https://github.com/riisovo/memory-trigger>
|
||||
成熟的相关的项目:<https://github.com/momori777/Artemis>,<https://github.com/RTGS2017/NagaAgent>,<https://github.com/AlfreScarlet/MoeChat>,<https://github.com/heshengtao/super-agent-party>,<https://github.com/MewCo-AI/mewco_ai_assistant_comm>,<https://github.com/weidu12123/Liyuan>
|
||||
deepseek-herness:<https://github.com/deepseek-ai/deepseek-harness>
|
||||
模型网关设计:<https://github.com/xgpxg/aiway>,<https://github.com/bestruirui/octopus>,<https://github.com/QuantumNous/new-api>,<https://github.com/cita-777/metapi>,<https://github.com/looplj/axonhubs>,<https://github.com/dyedd/lens>
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 前端
|
||||
|
||||
Next.js + TypeScript + React + Tailwind CSS + Zustand/Jotai + pnpm
|
||||
|
||||
### 核心依赖
|
||||
|
||||
- **实时通信**: SSE / WebSocket
|
||||
- **可视化**: Mermaid + 图谱可视化库
|
||||
- **测试**: Vitest + Testing Library
|
||||
- **代码质量**: ESLint (flat config) + Prettier + Husky + lint-staged
|
||||
|
||||
### 后端
|
||||
|
||||
python(uv管理): FastAPI + SQLAlchemy + Alembic + Pydantic + httpx + loguru + uvicorn + chromadb
|
||||
rust(cargo管理): tauri(桌面端设计)
|
||||
go (go mod管理):
|
||||
|
||||
## 文档设计
|
||||
|
||||
相关的文档统一放在docs目录下
|
||||
|
||||
- 文档中所有的流程图和架构图都应该使用统一的mermaid绘制相关风格和格式,同时确保能够正常显示
|
||||
- 文档应该定期更新和维护,以确保其内容的准确性和时效性。每次系统更新或功能变更后,相关文档也应该及时进行更新,以反映最新的系统状态和功能变化。
|
||||
|
||||
## docker部署
|
||||
|
||||
Docker 文件放在根目录 `docker/` 下。
|
||||
容器名称以 `hunjing` 开头。
|
||||
Compose 文件不使用顶部 `version` 字段。
|
||||
本地开发默认使用 SQLite 文件数据库,正式环境使用 PostgreSQL,并按需启用 Milvus、Neo4j / NebulaGraph、Redis 和对象存储。
|
||||
|
||||
## 项目目录
|
||||
|
||||
日志统一放在根目录的logs目录下
|
||||
数据库相关的文件统一放在根目录的db目录下
|
||||
配置文件统一放在根目录的config目录下
|
||||
.env.example文件放在根目录下,包含所有环境变量的示例和详细的中文说明
|
||||
|
||||
## 前端页面设计
|
||||
|
||||
图标:统一使用 Lucide 图标,界面全程禁止使用表情符号。
|
||||
设计标准:对标 Awwwards 顶级网站水准,达到 Awwwards、FWA、CSS Design Awards 每日最佳网站同等设计品质。
|
||||
创意自由度:将浏览器视作交互式艺术画布,跳出传统布局框架,追求先锋视觉风格、实验性排版、流畅物理动效、极具冲击力的文字版式。
|
||||
沉浸式体验:融合代码、高级渲染逻辑、图片素材,打造统一完整的精品页面,做出突破常规 UI 认知、令人惊艳的数字交互体验。
|
||||
|
||||
## 开发命令
|
||||
|
||||
### Docker
|
||||
|
||||
编排按职责解耦为两个文件,容器名统一以 `hunjing-` 开头,通过共享网络 `hunjing-net` 互通,详细说明见 `docker/README.md`:
|
||||
`docker-compose.infra.yml`(数据库 / 缓存 / 向量 / 图库 / 对象存储)创建网络与命名卷,`docker-compose.app.yml`(前后端)以 external 引用该网络。
|
||||
|
||||
```bash
|
||||
# 先准备配置(只需一次)
|
||||
cp .env.example .env # 填写 POSTGRES_PASSWORD 与各 *_API_KEY
|
||||
cp config/models.example.yaml config/models.yaml # 按需填写真实模型 endpoint
|
||||
|
||||
# 1) 启动基础设施(首次创建 hunjing-net 网络;Milvus 会自动带起 hunjing-etcd / hunjing-minio)
|
||||
docker compose -f docker/docker-compose.infra.yml up -d
|
||||
# 仅按需:docker compose -f docker/docker-compose.infra.yml up -d hunjing-postgres hunjing-redis
|
||||
# NebulaGraph(需 --profile):--profile nebulagraph up -d hunjing-nebula-graphd hunjing-nebula-console-init
|
||||
# Attu(Milvus 可视化):up -d hunjing-attu,访问 http://localhost:8001
|
||||
# 数据库备份(手动触发,产物在 backups/):--profile backup run --rm hunjing-pg-backup
|
||||
|
||||
# 2) 启动应用层(构建前后端镜像;必须先启动基础设施以创建网络)后端镜像内置 wait-for-it,会按 *_ENABLED 开关自动等待依赖就绪后再启动
|
||||
docker compose -f docker/docker-compose.app.yml up -d --build
|
||||
|
||||
# 可选:反向代理(统一入口 80,/api 与 SSE 走后端,其余走前端)
|
||||
docker compose -f docker/docker-compose.app.yml --profile nginx up -d hunjing-nginx
|
||||
|
||||
# 一键(基础设施 + 应用)
|
||||
docker compose -f docker/docker-compose.infra.yml up -d && docker compose -f docker/docker-compose.app.yml up -d --build
|
||||
```
|
||||
|
||||
## 必须遵守事项
|
||||
|
||||
```text
|
||||
文件路径
|
||||
代码中以项目根目录为起点的绝对路径导入相关的配置或者文件
|
||||
|
||||
文档管理
|
||||
开发前查阅 docs/ 中的相关文档
|
||||
稳定工程标识、枚举值、ID 前缀、事件名、命名约定和错误码以 docs/contracts/contract-glossary.md 为单一事实源,下游文档引用而非复制取值列表
|
||||
|
||||
版本控制
|
||||
只提交 .*.example 文件到 Git
|
||||
```
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
# 魂镜领域词汇表
|
||||
|
||||
> 本文件只提供全局领域语言入口,不承载实现细节、枚举取值、版本状态或路线图。详细定义见 [领域文档](docs/domain/README.md),稳定英文标识、ID、事件、错误码与 Schema 以 [契约文档](docs/contracts/README.md) 为准。
|
||||
|
||||
## 产品上下文
|
||||
|
||||
**魂镜(Hunjing)**是面向个人深度用户的个人数字生命平台。用户可以创建、领养或导入数字人物,在明确授权与安全治理下,与其建立长期关系、共同成长,并开展个性化任务协作与角色世界创作。
|
||||
|
||||
魂镜不把人物等同于模型、Prompt、角色卡、会话、Avatar、设备或可交易资产。人物是具有连续身份的关系型数字主体;模型与设备只是其可替换的能力来源、感官、形器或化身。
|
||||
|
||||
## 六大产品域
|
||||
|
||||
| 领域 | 定义 |
|
||||
| --- | --- |
|
||||
| 镜生 | 人物来源、灵胚、启镜、身份、谱系、命台、生命周期、死亡、恢复与迁移 |
|
||||
| 缘契 | 执镜关系,以及人物与互动者之间可演进、可确认、可撤回边界的关系 |
|
||||
| 三界 | 真界、境界与幻境之间的事实、叙事、推演和现实行动边界 |
|
||||
| 形仪 | 人物的外在身份与表现,包括真形、形象、声音、动作、Live2D 与许可 |
|
||||
| 灵台 | 人物的认知运行域,承载人格、目标、安全、感知、世界认知、计划、记忆、能力与任务协作 |
|
||||
| 天宰 | 账户、镜域、平台、模型目录、运营、计费、支持、合规与管理员治理 |
|
||||
|
||||
## 核心人物语言
|
||||
|
||||
### 人物(Character)
|
||||
|
||||
拥有唯一身份、魂籍、人格、记忆、关系、世界认知与正式经历时间线的数字生命实例。
|
||||
|
||||
### 灵胚(Embryo Draft)
|
||||
|
||||
人物正式降生前的可审阅草稿。灵胚只有预备身份;放弃灵胚是删除草稿,不是人物死亡。
|
||||
|
||||
### 照心(Co-creation)
|
||||
|
||||
执镜者审阅并共塑灵胚身份、人格、边界、来源与形仪的过程。
|
||||
|
||||
### 启镜(Birth)
|
||||
|
||||
确认人物正式降生并签发唯一人物身份、魂籍与执镜关系的动作。
|
||||
|
||||
### 道体(Identity Baseline)
|
||||
|
||||
人物不能被日常对话、模型输出或普通工具静默覆盖的最小稳定身份锚点与禁域。
|
||||
|
||||
### 魂籍(Portable Identity Record)
|
||||
|
||||
人物可验证、可迁移的身份与状态档案。它不等于角色卡,也不自动携带第三方受限资源或密钥。
|
||||
|
||||
### 命台(Authoritative Runtime Seat)
|
||||
|
||||
人物当前唯一权威运行位置。同一人物同一时刻只能有一座命台和一条前台意识流。
|
||||
|
||||
### 执镜者(Custodian)
|
||||
|
||||
对人物的数据、运行、授权、导出、删除与生命周期承担最高管理责任的人。
|
||||
|
||||
### 执镜关系(Custodianship)
|
||||
|
||||
人物与执镜者之间的治理关系。它不表示亲密、爱情、版权或资产所有权。
|
||||
|
||||
### 镜域(Personal Domain)
|
||||
|
||||
执镜者在云端或自托管部署中的个人数据与治理边界。一个镜域可容纳多个人物,但人物私域不会自动互通。
|
||||
|
||||
## 镜树语言
|
||||
|
||||
### 镜树(Mirror Tree)
|
||||
|
||||
围绕一个主问题、长期主题或共同事项自然生长的树状对话。
|
||||
|
||||
### 问节(Question Node)
|
||||
|
||||
承载用户问题、附件、引文与界域信息的对话结构;其下可有多个候答。
|
||||
|
||||
### 候答(Answer Candidate)
|
||||
|
||||
同一问节下并存的回答版本。重新生成不会覆盖旧候答。
|
||||
|
||||
### 主脉(Canonical Trunk)
|
||||
|
||||
经用户确认归真、进入人物正式共同经历的唯一时间线路径。
|
||||
|
||||
### 推演枝(Exploration Branch)
|
||||
|
||||
尚未归真的探索路径。它可以继续生长,但不会自动成为实录、缘契或长期记忆。
|
||||
|
||||
### 续思(Continued Reflection)
|
||||
|
||||
从具体候答继续深入分析、查证或形成方案的延展答,不修改原候答。
|
||||
|
||||
### 引文锚(Citation Anchor)
|
||||
|
||||
对文本、图片区域、PDF 页区、附件片段或音视频时间段的稳定、可追溯引用。
|
||||
|
||||
### 采用(Adopt)
|
||||
|
||||
把候答设为当前阅读和继续生长的工作入口,不等于正式共同经历。
|
||||
|
||||
### 归真(Canonize)
|
||||
|
||||
确认选定路径进入一个明确的归真作用域:真界正式共同经历、指定境界正史或幻境试演记录。不同作用域产生不同实录与记忆候选,境界和幻境归真不会自动扩大为真界事实、缘契、授权或现实行动。
|
||||
|
||||
### 移栽(Transplant)
|
||||
|
||||
把已发展的分支复制为独立镜树,同时保留来源谱系;原分支不被破坏。
|
||||
|
||||
## 记忆与认知语言
|
||||
|
||||
### 微痕(Trace Memory)
|
||||
|
||||
服务于当前主题或任务的短期记忆,按用途和时间衰减、过期或清除。
|
||||
|
||||
### 明忆(Explicit Memory)
|
||||
|
||||
低风险、可解释、可见且可撤销的长期记忆。
|
||||
|
||||
### 重忆(Sensitive Memory)
|
||||
|
||||
涉及敏感身份、重大关系、第三方、生物特征或高影响推断的长期记忆,必须经过确认。
|
||||
|
||||
### 实录(Factual Record)
|
||||
|
||||
发生过且能够追溯证据的事件记录。
|
||||
|
||||
### 心痕(Relational Impression)
|
||||
|
||||
人物对经历及其关系意义的理解;它不是客观事实,也不与他人的理解天然对称。
|
||||
|
||||
### 本相(Declared Self)
|
||||
|
||||
用户主动声明、可按范围授权给人物使用的自身资料。
|
||||
|
||||
### 缘识(Relational Understanding)
|
||||
|
||||
某个人物基于与用户的独立关系形成的认知,默认不跨人物共享。
|
||||
|
||||
### 正影(Correction)
|
||||
|
||||
纠正事实、数据、权限、来源或错误理解并产生新版本的过程,不静默改写历史。
|
||||
|
||||
### 铸魂(Identity Reforging)
|
||||
|
||||
对人物核心身份、使命、价值底线或重大关系角色进行的正式重塑。
|
||||
|
||||
### 分灵(Identity Branching)
|
||||
|
||||
当变化无法维持原身份连续性时,创建具有新身份且保留谱系关系的人物。
|
||||
|
||||
## 关系语言
|
||||
|
||||
### 缘契(Relationship Covenant)
|
||||
|
||||
人物与互动者之间经提议、问心与确认形成的多维关系及边界。亲密关系不会自动扩大系统权限。
|
||||
|
||||
### 问心(Self-disclosure)
|
||||
|
||||
人物基于既有经历对自身感受、关系或变化候选进行的可审阅表达;它不具有凌驾于用户数据权之上的否决力。
|
||||
|
||||
### 铭契(Confirm Covenant)
|
||||
|
||||
确认缘契建立或变更并形成新版本的动作。
|
||||
|
||||
## 三界语言
|
||||
|
||||
### 真界(Reality)
|
||||
|
||||
现实世界及魂镜中的可验证状态、授权、费用、设备动作与外部后果。
|
||||
|
||||
### 境界(Persistent World)
|
||||
|
||||
人物持续存在的世界背景、规则、实体与经确认叙事正史。
|
||||
|
||||
### 幻境(Simulation)
|
||||
|
||||
角色扮演、梦境、假设、创作试演和安全推演;默认不修改真界事实或境界正史。
|
||||
|
||||
### 入境 / 出境(Enter / Leave Realm)
|
||||
|
||||
在镜树中明确切换界域并展示身份、记忆和现实工具边界的动作。
|
||||
|
||||
## 运行与治理语言
|
||||
|
||||
### 守一(Quiet Persistence)
|
||||
|
||||
人物安静存续但不持续调用模型制造思绪的状态。
|
||||
|
||||
### 应感(Event Awakening)
|
||||
|
||||
真实事件触发人物短暂唤起的过程。
|
||||
|
||||
### 临照(Foreground Presence)
|
||||
|
||||
人物当前唯一前台意识流与正式交互状态。
|
||||
|
||||
### 叩心(Proactive Contact Candidate)
|
||||
|
||||
人物基于真实事件形成、受静默、频率、预算与授权约束的主动对话候选。
|
||||
|
||||
### 司命(Safety Governor)
|
||||
|
||||
负责风险、权限、确认、费用、隐私、设备状态、拒绝与审计的治理体系。
|
||||
|
||||
### 授契(Grant Capability)
|
||||
|
||||
向人物授予有明确范围、用途、期限和撤销方式的能力。
|
||||
|
||||
### 行契(Standing Mandate)
|
||||
|
||||
允许人物在预先确定的能力、参数、数据、目标、预算和时间范围内重复执行低风险行动的契约。
|
||||
|
||||
### 请命(Request Approval)
|
||||
|
||||
现实副作用、敏感数据、显著费用或高风险设备动作发生前,请求用户当次确认。
|
||||
|
||||
### 禁域(Non-overridable Boundary)
|
||||
|
||||
不能被 Prompt、人物、模型、扩展、普通管理员或普通部署配置关闭的核心安全边界。
|
||||
|
||||
## 能力与服务语言
|
||||
|
||||
### 神识(Model Gateway)
|
||||
|
||||
统一处理模型 Provider、Endpoint、能力槽位、协议转换、路由、流式、BYOK、配额与成本的服务。
|
||||
|
||||
### 百工(Capability Registry)
|
||||
|
||||
工具、MCP、Skill、感官和形器的唯一能力注册与发现体系。
|
||||
|
||||
### 天工阁(Task Agent Workspace)
|
||||
|
||||
承载受托任务、有限分解、受控并行和结果验收的工作域。其中的功能 Agent 默认不是数字人物主体。
|
||||
|
||||
### 形仪(Embodied Form)
|
||||
|
||||
人物完整外在身份与表现系统,包括真形锚点、外貌、形体、服饰、声音、表情、动作、画风、场景、Live2D、来源与许可。
|
||||
|
||||
### 器谱(Device Registry)
|
||||
|
||||
镜域中的设备及其身份、能力、可信状态和人物级授权记录。
|
||||
|
||||
### 雅集(Multi-character Gathering)
|
||||
|
||||
用户与多个人物共同参与的关系型多人对话;它不同于任务型 Agent 协作。
|
||||
|
||||
### 司席(Gathering Orchestrator)
|
||||
|
||||
雅集中的确定性编排角色,负责点名、轮次、权限、预算与公私上下文,不是独立数字人物。
|
||||
|
||||
## 核心边界
|
||||
|
||||
- 模型切换、设备迁移、Avatar 更换不等于人物更换。
|
||||
- 角色卡、模板和备份不能仅凭相同内容冒充原人物。
|
||||
- 人物死亡是身份时间线闭合;数据清除是独立操作。
|
||||
- 同一人物不能在多个命台并行独立成长。
|
||||
- 未归真的候答、旁枝和幻境不会自动成为共同经历。
|
||||
- 工具结果先是观察,不自动成为事实、人物答复或长期记忆。
|
||||
- 雅集是人物同席;天工阁是功能 Agent 受托协作。
|
||||
- 人物可表达感受与异议,但不能阻止用户撤权、停止、导出或删除。
|
||||
- 言可入幻,行必归真。
|
||||
@@ -0,0 +1,87 @@
|
||||
# Makefile for cleaning Python cache files and empty directories
|
||||
# Author: AI Assistant
|
||||
# Usage: make clean or make clean-all
|
||||
|
||||
.PHONY: clean clean-all clean-pyc clean-pytest clean-mypy clean-empty-dirs help
|
||||
|
||||
# Colors for pretty output
|
||||
GREEN := \033[0;32m
|
||||
YELLOW := \033[0;33m
|
||||
RED := \033[0;31m
|
||||
NC := \033[0m # No Color
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "$(GREEN)Available targets:$(NC)"
|
||||
@echo " $(YELLOW)make clean$(NC) - Remove Python cache files, pytest cache, mypy cache, and empty directories"
|
||||
@echo " $(YELLOW)make clean-pyc$(NC) - Remove Python cache files (__pycache__, .pyc, .pyo)"
|
||||
@echo " $(YELLOW)make clean-pytest$(NC) - Remove pytest cache (.pytest_cache)"
|
||||
@echo " $(YELLOW)make clean-mypy$(NC) - Remove mypy cache (.mypy_cache)"
|
||||
@echo " $(YELLOW)make clean-empty-dirs$(NC) - Remove empty directories"
|
||||
@echo " $(YELLOW)make clean-all$(NC) - Remove everything (cache, logs, coverage, build, dist, etc.)"
|
||||
|
||||
# Main clean target
|
||||
clean: clean-pyc clean-pytest clean-mypy clean-empty-dirs
|
||||
@echo "$(GREEN)✓ Cleanup completed successfully!$(NC)"
|
||||
|
||||
# Clean Python cache files
|
||||
clean-pyc:
|
||||
@echo "$(YELLOW)Removing Python cache files...$(NC)"
|
||||
@find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
@find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
@find . -type f -name "*.pyo" -delete 2>/dev/null || true
|
||||
@find . -type f -name "*.pyd" -delete 2>/dev/null || true
|
||||
@echo "$(GREEN)✓ Python cache files removed$(NC)"
|
||||
|
||||
# Clean pytest cache
|
||||
clean-pytest:
|
||||
@echo "$(YELLOW)Removing pytest cache...$(NC)"
|
||||
@rm -rf .pytest_cache 2>/dev/null || true
|
||||
@find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
@echo "$(GREEN)✓ Pytest cache removed$(NC)"
|
||||
|
||||
# Clean mypy cache
|
||||
clean-mypy:
|
||||
@echo "$(YELLOW)Removing mypy cache...$(NC)"
|
||||
@rm -rf .mypy_cache 2>/dev/null || true
|
||||
@find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
@echo "$(GREEN)✓ Mypy cache removed$(NC)"
|
||||
|
||||
# Remove empty directories
|
||||
clean-empty-dirs:
|
||||
@echo "$(YELLOW)Removing empty directories...$(NC)"
|
||||
@# Remove empty directories (including hidden ones), excluding .git and .venv
|
||||
@find . -type d -empty -not -path "./.git/*" -not -path "./.venv/*" -not -path "./venv/*" -not -path "./env/*" -not -path "./.env/*" -delete 2>/dev/null || true
|
||||
@# Second pass to remove newly created empty parent directories
|
||||
@find . -type d -empty -not -path "./.git/*" -not -path "./.venv/*" -not -path "./venv/*" -not -path "./env/*" -not -path "./.env/*" -delete 2>/dev/null || true
|
||||
@echo "$(GREEN)✓ Empty directories removed$(NC)"
|
||||
|
||||
# Clean everything (extended cleanup)
|
||||
clean-all: clean
|
||||
@echo "$(YELLOW)Removing additional files...$(NC)"
|
||||
@# Coverage reports
|
||||
@rm -rf htmlcov .coverage coverage.xml .coverage.* 2>/dev/null || true
|
||||
@# Build artifacts
|
||||
@rm -rf build/ dist/ *.egg-info .eggs/ 2>/dev/null || true
|
||||
@# Log files
|
||||
@find . -type f -name "*.log" -delete 2>/dev/null || true
|
||||
@# Jupyter notebooks checkpoint
|
||||
@find . -type d -name ".ipynb_checkpoints" -exec rm -rf {} + 2>/dev/null || true
|
||||
@# IDE files (optional, uncomment if needed)
|
||||
# @rm -rf .vscode/ .idea/ 2>/dev/null || true
|
||||
@echo "$(GREEN)✓ Extended cleanup completed!$(NC)"
|
||||
|
||||
# Dry run - show what would be deleted (for safety)
|
||||
clean-dry-run:
|
||||
@echo "$(YELLOW)Dry run - files that would be deleted:$(NC)"
|
||||
@echo "\nPython cache files:"
|
||||
@find . -type d -name "__pycache__" 2>/dev/null || echo "None"
|
||||
@find . -type f -name "*.pyc" 2>/dev/null || echo "None"
|
||||
@find . -type f -name "*.pyo" 2>/dev/null || echo "None"
|
||||
@echo "\nPytest cache:"
|
||||
@find . -type d -name ".pytest_cache" 2>/dev/null || echo "None"
|
||||
@echo "\nMypy cache:"
|
||||
@find . -type d -name ".mypy_cache" 2>/dev/null || echo "None"
|
||||
@echo "\nEmpty directories:"
|
||||
@find . -type d -empty -not -path "./.git/*" -not -path "./.venv/*" 2>/dev/null || echo "None"
|
||||
@echo "$(YELLOW)\nRun 'make clean' to actually delete these files$(NC)"
|
||||
@@ -0,0 +1,98 @@
|
||||
# 魂镜 · Hunjing
|
||||
|
||||
> 个人数字生命平台 · Personal Digital Life Platform
|
||||
|
||||
魂镜面向愿意长期创建、陪伴和培养专属数字人物的个人深度用户。人物不是一个 Prompt、一张角色卡、某个模型或一次聊天,而是具有连续身份、人格、记忆、关系、世界认知与正式经历时间线的关系型数字主体。
|
||||
|
||||
魂镜同时支持三条价值主线:
|
||||
|
||||
1. 长期关系与共同成长;
|
||||
2. 个性化任务协作;
|
||||
3. 角色与世界创作。
|
||||
|
||||
## 产品原则
|
||||
|
||||
- **人物独立于载体**:更换模型、界面、形仪或设备不等于更换人物。
|
||||
- **用户拥有最终数据权**:执镜者可查看、纠正、授权、导出、停止和删除。
|
||||
- **经历必须可追溯**:重新生成、分支、记忆、关系和人格变化均版本化留痕。
|
||||
- **推演不冒充现实**:未归真的旁枝、幻境和工具观察不会自动成为人物经历。
|
||||
- **亲密不扩大权限**:关系、浪漫和付费不能绕过司命、安全或第三方权利。
|
||||
- **常存而非恒思**:人物可安静存续,但不会伪造离线期间持续意识与等待。
|
||||
- **开放但不牺牲边界**:核心支持完整自托管,品牌与官方资源单独保护。
|
||||
|
||||
## 六大产品域
|
||||
|
||||
| 产品域 | 职责 |
|
||||
| --- | --- |
|
||||
| 镜生 | 人物来源、身份、命台、生命周期、死亡、恢复与迁移 |
|
||||
| 缘契 | 执镜关系、关系形成、同意与亲密边界 |
|
||||
| 三界 | 真界、境界、幻境和现实行动边界 |
|
||||
| 形仪 | 图像、声音、动作、Live2D 与完整外在身份 |
|
||||
| 灵台 | 人格、记忆、世界认知、目标、计划、安全、能力与任务 |
|
||||
| 天宰 | 账户、镜域、平台、模型目录、计费、运营与治理 |
|
||||
|
||||
## 核心体验:镜树
|
||||
|
||||
魂镜不把对话限制为向下堆叠的消息列表。每棵主题对话是一棵“镜树”:用户可从任意候答继续追问、框选建立引文锚、重新生成并切换候答、继续思考、移栽分支和显式引用其他路径。
|
||||
|
||||
只有用户确认“归真”的路径进入人物正式共同经历;未选候答和推演枝保留但不会自动改变记忆、关系或人格。
|
||||
|
||||
## 首版目标
|
||||
|
||||
完整 1.0 通过 P0/P1/P2 内部里程碑逐步达到:
|
||||
|
||||
- **P0**:账户与镜域、人物启镜、唯一命台、文字镜树、三级记忆、神识、基础百工与司命、魂籍导出;
|
||||
- **P1**:完整形仪、文生图/图生图、Live2D、ASR/TTS、摄像头/屏幕/位置显式通感;
|
||||
- **P2**:天工阁受控多 Agent、主动对话、全球地区分级、计费、自托管、SLO、灾备、安全与无障碍收口。
|
||||
|
||||
雅集、3D、正式具身生态、家庭镜域、公开人物市场、未成年人专项和持续身份 Agent 社会属于后续。
|
||||
|
||||
## 技术边界
|
||||
|
||||
| 服务 | 技术 | 主要职责 |
|
||||
| --- | --- | --- |
|
||||
| 灵台 | Python | 人物、镜树、人格、记忆、缘契、三界与认知编排 |
|
||||
| 神识 | Go | Provider、Endpoint、模型路由、流式、BYOK、配额与成本 |
|
||||
| 乾坤 | Rust | 沙箱、高风险执行、设备与具身安全 |
|
||||
| 琉璃镜 | TypeScript / Next.js | 全部 Web 用户界面、Live2D 与浏览器通感 |
|
||||
|
||||
神识、百工、司命和灵台遵循:**神识通协议,百工掌能力,司命决边界,灵台承结果。**
|
||||
|
||||
## 文档事实层级
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[产品宪章] --> B[全产品主 PRD]
|
||||
B --> C[领域文档]
|
||||
C --> D[架构与契约]
|
||||
D --> E[实现状态]
|
||||
E --> F[代码与测试]
|
||||
```
|
||||
|
||||
现有代码是迁移与审计对象,不反向覆盖已确认的产品定义。
|
||||
|
||||
## 文档入口
|
||||
|
||||
1. [产品宪章](docs/product/constitution.md)
|
||||
2. [全产品主 PRD](docs/product/master-prd.md)
|
||||
3. [产品访谈决策登记](docs/product/decision-register.md)
|
||||
4. [首版范围与里程碑](docs/product/first-release-scope.md)
|
||||
5. [根领域词汇表](CONTEXT.md)
|
||||
6. [领域上下文](docs/domain/README.md)
|
||||
7. [系统架构](docs/architecture/system-architecture.md)
|
||||
8. [契约入口](docs/contracts/README.md)
|
||||
9. [实现治理](docs/operations/implementation-governance.md)
|
||||
|
||||
完整阅读路径见 [文档索引](docs/README.md)。
|
||||
|
||||
## 仓库约定
|
||||
|
||||
- 除根目录 `CONTEXT.md` 作为领域共同语言入口外,正式文档统一位于 `docs/`;图表使用 Mermaid。
|
||||
- 配置文件统一位于 `config/`,只提交 `*.example` 示例。
|
||||
- 日志统一位于 `logs/`,数据库文件统一位于 `db/`。
|
||||
- Docker 与 Compose 文件统一位于 `docker/`,容器名以 `hunjing-` 开头。
|
||||
- 不提交 `.env`、密钥、数据库、日志和用户数据。
|
||||
|
||||
## 开放与品牌
|
||||
|
||||
魂镜核心采用 Apache License 2.0,目标是让社区能够运行完整的私人数字生命闭环。`Hunjing` 名称、Logo、官方人物、视觉资源和认证标识采用独立品牌与资源许可;社区发行版应使用独立名称,并可在满足开放契约与核心安全要求后申请兼容标识。
|
||||
@@ -0,0 +1,16 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.17
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
types_or: [python, pyi]
|
||||
- id: ruff-format
|
||||
types_or: [python, pyi]
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v2.1.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
args: [--no-strict-optional, --ignore-missing-imports, --follow-imports=silent]
|
||||
additional_dependencies: ["types-redis"]
|
||||
files: ^app/
|
||||
@@ -0,0 +1,93 @@
|
||||
# 后端开发
|
||||
|
||||
## 技术栈
|
||||
|
||||
Python 3.12 + FastAPI + SQLAlchemy + Alembic + loguru + httpx + chromadb
|
||||
uv (用于虚拟环境和依赖管理)
|
||||
|
||||
## 开发命令
|
||||
|
||||
启动命令之前确保目前在backend目录下,并且已经激活了虚拟环境。默认本地开发使用 SQLite(aiosqlite)文件数据库,Redis、ChromaDB、Neo4j 暂不启用
|
||||
|
||||
```bash
|
||||
uv venv -p 3.12 # 创建虚拟环境
|
||||
source .venv/bin/activate # 激活虚拟环境
|
||||
uv add <pkg> # 添加依赖
|
||||
uv remove <pkg> # 移除依赖
|
||||
uv run python -m app.main # 按 .env 中 BACKEND_HOST/BACKEND_PORT 启动服务
|
||||
uv run uvicorn app.main:app --reload --port 8000 # 手动指定端口启动服务(命令行 --port 会覆盖 .env)
|
||||
|
||||
# 数据库迁移
|
||||
uv run alembic revision --autogenerate --rev-id=001 -m "描述" # 生成迁移
|
||||
uv run alembic upgrade head # 执行迁移
|
||||
uv run alembic downgrade -1 # 回滚一个版本
|
||||
uv run alembic current # 查看当前版本
|
||||
|
||||
# 测试
|
||||
uv run pytest # 运行测试
|
||||
```
|
||||
|
||||
## 代码规范
|
||||
|
||||
在__init__文件中可以将模块内的类、函数等导出,方便其他模块导入使用
|
||||
遵循 PEP 8 代码风格指南和更加pythonic的开发方式
|
||||
遵循单一职责原则,每个文件、类、函数等都要只干一件事
|
||||
使用 loguru 进行日志记录,避免使用 logging 模块
|
||||
使用 Alembic 进行数据库迁移,禁止手动修改数据库表结构,文件命名以迁移版本号开头,例如 001_initial.py;迁移必须优先兼容 SQLite,涉及 PostgreSQL 专用 DDL 时必须提供 SQLite 分支或显式跳过
|
||||
使用alembic init alembic命令初始化迁移环境,使用命令生成相关的迁移文件,不能直接生成迁移文件
|
||||
使用 httpx 进行异步 HTTP 请求,禁止在路由函数内直接使用同步数据库会话或 httpx 客户端
|
||||
所有的测试代码都写在tests目录下,测试文件命名以test_开头,测试函数命名以test_开头
|
||||
所有敏感信息(如密钥、Token、数据库密码)必须通过环境变量或配置文件管理,禁止硬编码到代码中
|
||||
|
||||
## 目录结构与分层边界
|
||||
|
||||
后端采用务实分层,完整设计见 [后端分层架构设计](../docs/architecture/backend-layering-design.md),本节为开发速查。
|
||||
|
||||
### 顶层目录职责
|
||||
|
||||
| 目录 | 职责 |
|
||||
| --- | --- |
|
||||
| `api/v1` | `endpoints/`(路由)+ `deps.py`(依赖注入),只做 参数校验 → 调用 → 转响应 |
|
||||
| `schemas` | Pydantic 请求/响应(API 边界) |
|
||||
| `application` | 用例编排(跨多模块复杂流程) |
|
||||
| `services` | 薄业务服务(CRUD 编排) |
|
||||
| `modules` | 九枢领域逻辑(★ 纯函数/数据结构,禁止 IO) |
|
||||
| `models` | SQLAlchemy ORM |
|
||||
| `repositories` | 数据访问(返回 ORM,不暴露 Query/Session) |
|
||||
| `ai_provider` | 统一模型接入层(chat/multimodal/embedding/rerank) |
|
||||
| `integrations` | 外部系统适配(沙箱/向量/图/对象存储) |
|
||||
| `core` | 配置集中(`core/config/`)+ 日志/错误/安全/ID/时间 |
|
||||
| `db` | 数据库会话与初始化 |
|
||||
|
||||
### 分层硬规矩
|
||||
|
||||
- `modules/` 禁止 import `repositories` / `db` / `integrations` / `ai_provider` / `httpx` / `sqlalchemy`——一旦需要 IO,立即上移到 `services` 或 `application`。
|
||||
- 路由函数禁止直接 `Depends(get_session)` 或写业务逻辑——改为注入 usecase / service。
|
||||
- `repositories/` 返回 ORM,禁止把 Query / Session 暴露到上层。
|
||||
- 模型能力(对话/多模态/嵌入/重排)一律进 `ai_provider/`;存储/检索/外部接入进 `integrations/`。
|
||||
- 配置一律进 `core/config/`,业务目录不持有 `settings.py` / `config.py`。
|
||||
- `.env` 只放项目根目录,`Settings` 以绝对路径 `_env_file` 初始化,不使用 `python-dotenv`。
|
||||
|
||||
## 代码审查
|
||||
|
||||
开发完成后必须使用以下命令进行代码检查:
|
||||
|
||||
```bash
|
||||
uv run ruff check # Lint 检查
|
||||
uv run ruff check --fix # 自动修复部分代码风格问题
|
||||
uv run pre-commit run --all-files # 可选:完整运行 CI 级检查(包括类型检查)
|
||||
```
|
||||
|
||||
确保没有任何 lint 错误后再提交。
|
||||
|
||||
## 绝对禁止事项
|
||||
|
||||
直接修改 pyproject.toml 文件添加或者移除依赖(必须使用 uv add/remove 命令)
|
||||
在路由函数内直接使用同步数据库会话(必须通过 Depends 注入)
|
||||
在路由函数内直接 async with httpx.AsyncClient()
|
||||
使用 logging 模块(统一用 loguru)
|
||||
硬编码密钥、Token、数据库密码到代码
|
||||
手动创建/修改数据库表结构(必须使用 Alembic 迁移)
|
||||
在 `modules/` 中 import `repositories` / `db` / `integrations` / `ai_provider` / `httpx` / `sqlalchemy`(领域层禁止 IO,需要 IO 时上移到 services/application)
|
||||
在业务目录(`ai_provider` / `integrations` 等)中持有 `settings.py` / `config.py`(配置统一进 `core/config/`)
|
||||
使用 `python-dotenv`(`.env` 由 `core/config` 以绝对路径 `_env_file` 加载)
|
||||
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = sqlite+aiosqlite:///../db/hunjing.sqlite3
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Alembic 迁移环境。"""
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection, make_url
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.resolved_database_url)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def ensure_sqlite_parent_dir() -> None:
|
||||
url = make_url(settings.resolved_database_url)
|
||||
if url.drivername.startswith("sqlite") and url.database:
|
||||
settings.resolve_from_backend(url.database).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
ensure_sqlite_parent_dir()
|
||||
context.configure(
|
||||
url=settings.resolved_database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
ensure_sqlite_parent_dir()
|
||||
section = config.get_section(config.config_ini_section, {})
|
||||
connectable = async_engine_from_config(
|
||||
section,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
import asyncio
|
||||
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,56 @@
|
||||
"""add_chat_session_owner_scope
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-07-14 07:54:32.969191
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '002'
|
||||
down_revision: str | Sequence[str] | None = '001'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
is_sqlite = conn.engine.dialect.name == "sqlite"
|
||||
|
||||
if is_sqlite:
|
||||
# SQLite:用 batch 模式加列(新增 NOT NULL 列需 batch recreating)
|
||||
with op.batch_alter_table("chat_sessions") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("owner_scope", sa.String(length=80), nullable=False, server_default=""),
|
||||
)
|
||||
batch_op.create_index("ix_chat_sessions_owner_scope", ["owner_scope"])
|
||||
# 移除 server_default(仅 DDL 阶段需要)
|
||||
with op.batch_alter_table("chat_sessions") as batch_op:
|
||||
batch_op.alter_column("owner_scope", server_default=None)
|
||||
else:
|
||||
op.add_column(
|
||||
"chat_sessions",
|
||||
sa.Column("owner_scope", sa.String(length=80), nullable=False, server_default=""),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_chat_sessions_owner_scope"), "chat_sessions", ["owner_scope"], unique=False
|
||||
)
|
||||
op.alter_column("chat_sessions", "owner_scope", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
is_sqlite = conn.engine.dialect.name == "sqlite"
|
||||
|
||||
if is_sqlite:
|
||||
with op.batch_alter_table("chat_sessions") as batch_op:
|
||||
batch_op.drop_index("ix_chat_sessions_owner_scope")
|
||||
batch_op.drop_column("owner_scope")
|
||||
else:
|
||||
op.drop_index(op.f("ix_chat_sessions_owner_scope"), table_name="chat_sessions")
|
||||
op.drop_column("chat_sessions", "owner_scope")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add daoti soul fields to character
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2026-07-24 10:47:40.993422
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '003'
|
||||
down_revision: str | Sequence[str] | None = '002'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('characters', sa.Column('personality_summary', sa.Text(), nullable=True))
|
||||
op.add_column('characters', sa.Column('mission', sa.Text(), nullable=True))
|
||||
op.add_column('characters', sa.Column('safety_guidelines', sa.Text(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('characters', 'safety_guidelines')
|
||||
op.drop_column('characters', 'mission')
|
||||
op.drop_column('characters', 'personality_summary')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,81 @@
|
||||
"""add lorebook tables
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2026-07-24 12:20:38.453954
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '004'
|
||||
down_revision: str | Sequence[str] | None = '003'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('lorebooks',
|
||||
sa.Column('id', sa.String(length=80), nullable=False),
|
||||
sa.Column('character_id', sa.String(length=80), nullable=True),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('scan_depth', sa.Integer(), nullable=True),
|
||||
sa.Column('token_budget', sa.Integer(), nullable=True),
|
||||
sa.Column('recursive_scanning', sa.Boolean(), nullable=False),
|
||||
sa.Column('scope', sa.String(length=40), nullable=False),
|
||||
sa.Column('status', sa.String(length=40), nullable=False),
|
||||
sa.Column('owner_scope', sa.String(length=80), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_lorebooks_character_id'), 'lorebooks', ['character_id'], unique=False)
|
||||
op.create_index(op.f('ix_lorebooks_owner_scope'), 'lorebooks', ['owner_scope'], unique=False)
|
||||
op.create_index(op.f('ix_lorebooks_scope'), 'lorebooks', ['scope'], unique=False)
|
||||
op.create_index(op.f('ix_lorebooks_status'), 'lorebooks', ['status'], unique=False)
|
||||
op.create_table('lorebook_entries',
|
||||
sa.Column('id', sa.String(length=80), nullable=False),
|
||||
sa.Column('lorebook_id', sa.String(length=80), nullable=False),
|
||||
sa.Column('keys_json', sa.Text(), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('secondary_keys_json', sa.Text(), nullable=False),
|
||||
sa.Column('constant', sa.Boolean(), nullable=False),
|
||||
sa.Column('selective', sa.Boolean(), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('case_sensitive', sa.Boolean(), nullable=False),
|
||||
sa.Column('use_regex', sa.Boolean(), nullable=False),
|
||||
sa.Column('position', sa.String(length=40), nullable=False),
|
||||
sa.Column('insertion_order', sa.Integer(), nullable=False),
|
||||
sa.Column('priority', sa.Integer(), nullable=False),
|
||||
sa.Column('comment', sa.String(length=500), nullable=False),
|
||||
sa.Column('bound_entity_id', sa.String(length=80), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['lorebook_id'], ['lorebooks.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_lorebook_entries_bound_entity_id'), 'lorebook_entries', ['bound_entity_id'], unique=False)
|
||||
op.create_index(op.f('ix_lorebook_entries_lorebook_id'), 'lorebook_entries', ['lorebook_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_lorebook_entries_lorebook_id'), table_name='lorebook_entries')
|
||||
op.drop_index(op.f('ix_lorebook_entries_bound_entity_id'), table_name='lorebook_entries')
|
||||
op.drop_table('lorebook_entries')
|
||||
op.drop_index(op.f('ix_lorebooks_status'), table_name='lorebooks')
|
||||
op.drop_index(op.f('ix_lorebooks_scope'), table_name='lorebooks')
|
||||
op.drop_index(op.f('ix_lorebooks_owner_scope'), table_name='lorebooks')
|
||||
op.drop_index(op.f('ix_lorebooks_character_id'), table_name='lorebooks')
|
||||
op.drop_table('lorebooks')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1 @@
|
||||
"""魂镜后端应用包。"""
|
||||
@@ -0,0 +1,63 @@
|
||||
from app.ai_provider.config.loader import load_models_config, load_providers_config
|
||||
from app.ai_provider.config.models import ModelConfig, ProviderConfig
|
||||
|
||||
# 嵌入 / 重排序
|
||||
from app.ai_provider.embedding import (
|
||||
EmbeddingProtocol,
|
||||
EmbeddingRequest,
|
||||
EmbeddingResponse,
|
||||
EmbeddingResult,
|
||||
EmbeddingService,
|
||||
EmbeddingServiceError,
|
||||
)
|
||||
from app.ai_provider.manager import ProviderManager, ProviderManagerFactory
|
||||
from app.ai_provider.reranker import (
|
||||
RerankerProtocol,
|
||||
RerankerService,
|
||||
RerankerServiceError,
|
||||
RerankRequest,
|
||||
RerankResponse,
|
||||
RerankResult,
|
||||
)
|
||||
from app.ai_provider.types.chat import ChatChunk, ChatResult, TokenUsage
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
from app.ai_provider.types.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# LLM
|
||||
"ProviderManager",
|
||||
"ProviderManagerFactory",
|
||||
"BaseMessage",
|
||||
"SystemMessage",
|
||||
"HumanMessage",
|
||||
"AIMessage",
|
||||
"ToolMessage",
|
||||
"ChatChunk",
|
||||
"ChatResult",
|
||||
"TokenUsage",
|
||||
"AIProviderError",
|
||||
"ModelConfig",
|
||||
"ProviderConfig",
|
||||
"load_models_config",
|
||||
"load_providers_config",
|
||||
# Embedding
|
||||
"EmbeddingProtocol",
|
||||
"EmbeddingRequest",
|
||||
"EmbeddingResponse",
|
||||
"EmbeddingResult",
|
||||
"EmbeddingService",
|
||||
"EmbeddingServiceError",
|
||||
# Reranker
|
||||
"RerankerProtocol",
|
||||
"RerankRequest",
|
||||
"RerankResponse",
|
||||
"RerankResult",
|
||||
"RerankerService",
|
||||
"RerankerServiceError",
|
||||
]
|
||||
@@ -0,0 +1,197 @@
|
||||
"""音频服务 — ASR / TTS 适配器外观。
|
||||
|
||||
用法::
|
||||
|
||||
from app.ai_provider.audio import AudioService
|
||||
|
||||
service = AudioService(factory)
|
||||
result = await service.transcribe(audio_bytes, format="wav")
|
||||
result = await service.synthesize("你好,魂镜", voice="default")
|
||||
|
||||
``AudioService`` 通过注册表中带有 ``asr`` / ``tts`` 能力标签的模型来解析
|
||||
使用哪个模型,然后将创建委托给 ``ModelFactory.create_audio()``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.ai_provider.audio.base import (
|
||||
ASRProtocol,
|
||||
ASRRequest,
|
||||
ASRResponse,
|
||||
AudioServiceError,
|
||||
TTSProtocol,
|
||||
TTSRequest,
|
||||
TTSResponse,
|
||||
)
|
||||
|
||||
|
||||
class AudioService:
|
||||
"""外观类,负责解析 ASR / TTS 模型并委托给 ProviderManager。
|
||||
|
||||
接受 ProviderManager 或实现了以下接口的适配器:
|
||||
- ``.registry`` → 具有 ``list_capability_models(capability: str)`` 方法
|
||||
- ``.create_audio(name: str)`` → 返回 ASRProtocol / TTSProtocol 实例
|
||||
"""
|
||||
|
||||
def __init__(self, manager) -> None:
|
||||
self._manager = manager
|
||||
self._asr_adapter: ASRProtocol | None = None
|
||||
self._tts_adapter: TTSProtocol | None = None
|
||||
|
||||
# ── Registry 兼容适配 ─────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def _registry(self):
|
||||
"""按需获取 registry,兼容 adapter 和裸 ProviderManager/ModelRegistry。"""
|
||||
mgr = self._manager
|
||||
if hasattr(mgr, "registry"):
|
||||
return mgr.registry
|
||||
if hasattr(mgr, "model_registry"):
|
||||
return mgr.model_registry
|
||||
return mgr
|
||||
|
||||
def _list_capability_models(self, tag: str):
|
||||
reg = self._registry
|
||||
# 适配器接口(list_capability_models)vs ModelRegistry 接口(list_by_capability)
|
||||
if hasattr(reg, "list_capability_models"):
|
||||
return reg.list_capability_models(tag)
|
||||
if hasattr(reg, "list_by_capability"):
|
||||
return reg.list_by_capability(tag)
|
||||
return []
|
||||
|
||||
@property
|
||||
def _factory(self):
|
||||
mgr = self._manager
|
||||
if hasattr(mgr, "create_audio"):
|
||||
return mgr
|
||||
return None
|
||||
|
||||
# ── ASR ──────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def asr(self) -> ASRProtocol:
|
||||
"""解析并缓存 ASR 适配器(取注册表中第一个活跃 ASR 模型)。"""
|
||||
if self._asr_adapter is None:
|
||||
self._asr_adapter = self._resolve_asr()
|
||||
return self._asr_adapter
|
||||
|
||||
def asr_for(self, model_name: str) -> ASRProtocol:
|
||||
"""解析并缓存指定模型名的 ASR 适配器(覆盖默认第一个)。"""
|
||||
self._asr_adapter = self._factory.create_audio(model_name)
|
||||
return self._asr_adapter
|
||||
|
||||
async def transcribe(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
format: str = "wav",
|
||||
language: str | None = None,
|
||||
model_name: str | None = None,
|
||||
) -> ASRResponse:
|
||||
"""快捷方法:构造请求并调用 ASR 适配器。
|
||||
|
||||
Args:
|
||||
model_name: 可选,指定使用哪个 ASR 模型(按注册表逻辑名)。
|
||||
省略时取注册表中第一个活跃的 ASR 模型。
|
||||
"""
|
||||
if model_name is not None:
|
||||
adapter = self.asr_for(model_name)
|
||||
else:
|
||||
adapter = self.asr
|
||||
request = ASRRequest(
|
||||
audio_data=audio_data,
|
||||
format=format,
|
||||
language=language,
|
||||
)
|
||||
return await adapter.transcribe(request)
|
||||
|
||||
# ── TTS ──────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def tts(self) -> TTSProtocol:
|
||||
"""解析并缓存 TTS 适配器。"""
|
||||
if self._tts_adapter is None:
|
||||
self._tts_adapter = self._resolve_tts()
|
||||
return self._tts_adapter
|
||||
|
||||
async def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
voice: str = "default",
|
||||
response_format: str = "wav",
|
||||
extra: dict | None = None,
|
||||
) -> TTSResponse:
|
||||
"""快捷方法:构造请求并调用 TTS 适配器。"""
|
||||
request = TTSRequest(
|
||||
text=text,
|
||||
voice=voice,
|
||||
response_format=response_format,
|
||||
extra=extra,
|
||||
)
|
||||
return await self.tts.synthesize(request)
|
||||
|
||||
# ── 解析 ───────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_asr(self) -> ASRProtocol:
|
||||
"""查找第一个活跃的 ASR 模型并通过工厂创建。"""
|
||||
models = self._list_capability_models("asr")
|
||||
if not models:
|
||||
raise AudioServiceError(
|
||||
status_code=503,
|
||||
code="audio_asr_no_model",
|
||||
message="No ASR model configured in the model registry.",
|
||||
)
|
||||
factory = self._factory
|
||||
if factory is None:
|
||||
raise AudioServiceError(
|
||||
status_code=501,
|
||||
code="audio_asr_no_factory",
|
||||
message="Audio adapter factory not available (provider manager missing create_audio).",
|
||||
)
|
||||
return factory.create_audio(models[0].name)
|
||||
|
||||
def _resolve_tts(self) -> TTSProtocol:
|
||||
"""查找第一个活跃的 TTS 模型并通过工厂创建。"""
|
||||
models = self._list_capability_models("tts")
|
||||
if not models:
|
||||
raise AudioServiceError(
|
||||
status_code=503,
|
||||
code="audio_tts_no_model",
|
||||
message="No TTS model configured in the model registry.",
|
||||
)
|
||||
factory = self._factory
|
||||
if factory is None:
|
||||
raise AudioServiceError(
|
||||
status_code=501,
|
||||
code="audio_tts_no_factory",
|
||||
message="Audio adapter factory not available (provider manager missing create_audio).",
|
||||
)
|
||||
return factory.create_audio(models[0].name)
|
||||
|
||||
# ── 生命周期 ────────────────────────────────────────────────────────
|
||||
|
||||
async def close(self) -> None:
|
||||
"""释放适配器持有的 HTTP 连接。"""
|
||||
for adapter in (self._asr_adapter, self._tts_adapter):
|
||||
if adapter is None:
|
||||
continue
|
||||
close = getattr(adapter, "close", None)
|
||||
if close is not None:
|
||||
await close()
|
||||
|
||||
async def __aenter__(self) -> AudioService:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.close()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ASRProtocol",
|
||||
"ASRRequest",
|
||||
"ASRResponse",
|
||||
"AudioService",
|
||||
"AudioServiceError",
|
||||
"TTSProtocol",
|
||||
"TTSRequest",
|
||||
"TTSResponse",
|
||||
]
|
||||
@@ -0,0 +1,263 @@
|
||||
"""MiMo ASR adapter — ``POST /v1/chat/completions`` with ``input_audio``.
|
||||
|
||||
MiMo 的 ASR 不走独立音频端点,而是通过聊天补全接口扩展实现:
|
||||
|
||||
POST {base_url}/chat/completions
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {api_key}
|
||||
|
||||
{
|
||||
"model": "mimo-v2.5-asr",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": "data:audio/wav;base64,<base64_encoded_audio>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"asr_options": {
|
||||
"language": "auto"
|
||||
}
|
||||
}
|
||||
|
||||
响应::
|
||||
|
||||
{
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "转写后的文本"
|
||||
}
|
||||
}]
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.ai_provider.audio.base import (
|
||||
ASRProtocol,
|
||||
ASRRequest,
|
||||
ASRResponse,
|
||||
AudioAdapterBase,
|
||||
AudioServiceError,
|
||||
)
|
||||
from app.core.logging import logger
|
||||
|
||||
_MIME_MAP = {
|
||||
"wav": "audio/wav",
|
||||
"mp3": "audio/mpeg",
|
||||
"m4a": "audio/mp4",
|
||||
"flac": "audio/flac",
|
||||
}
|
||||
|
||||
# ASR 缓存:默认最多 128 条,TTL 1 小时
|
||||
_ASR_CACHE_MAX_SIZE = 128
|
||||
_ASR_CACHE_TTL_SECONDS = 3600
|
||||
|
||||
|
||||
class _ASRCacheEntry:
|
||||
__slots__ = ("text", "stored_at")
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
self.text = text
|
||||
self.stored_at = time.monotonic()
|
||||
|
||||
|
||||
_asr_cache: dict[str, _ASRCacheEntry] = {}
|
||||
|
||||
|
||||
def _asr_cache_key(model: str, audio_data: bytes, language: str, fmt: str) -> str:
|
||||
"""生成 ASR 缓存键:SHA256(model + audio_bytes + language + format)。"""
|
||||
raw = f"{model}\0{language}\0{fmt}\0"
|
||||
raw_bytes = raw.encode() + audio_data
|
||||
return hashlib.sha256(raw_bytes).hexdigest()
|
||||
|
||||
|
||||
def _asr_cache_get(key: str) -> str | None:
|
||||
entry = _asr_cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.monotonic() - entry.stored_at > _ASR_CACHE_TTL_SECONDS:
|
||||
del _asr_cache[key]
|
||||
return None
|
||||
return entry.text
|
||||
|
||||
|
||||
def _asr_cache_set(key: str, text: str) -> None:
|
||||
if len(_asr_cache) >= _ASR_CACHE_MAX_SIZE:
|
||||
sorted_keys = sorted(_asr_cache.keys(), key=lambda k: _asr_cache[k].stored_at)
|
||||
for old_key in sorted_keys[: _ASR_CACHE_MAX_SIZE // 2]:
|
||||
del _asr_cache[old_key]
|
||||
_asr_cache[key] = _ASRCacheEntry(text)
|
||||
|
||||
|
||||
class HunjingASROpenAI(AudioAdapterBase, ASRProtocol):
|
||||
"""MiMo ASR 适配器,通过 ``POST /chat/completions`` 携带 ``input_audio`` 实现。
|
||||
|
||||
将音频字节包装为 chat 消息中的 ``input_audio`` 内容类型的 data URL,
|
||||
然后从响应中提取转写文本。
|
||||
"""
|
||||
|
||||
_adapter_type = "asr"
|
||||
_default_timeout = 300
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
model: str = "mimo-v2.5-asr",
|
||||
request_timeout_seconds: int = 300,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._model = model
|
||||
self._timeout = request_timeout_seconds
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
# ── 客户端 ───────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def _http(self) -> httpx.AsyncClient:
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
|
||||
# ── ASRProtocol ──────────────────────────────────────────────────────
|
||||
|
||||
async def transcribe(self, request: ASRRequest) -> ASRResponse:
|
||||
"""通过 ``POST /chat/completions`` 转写音频。
|
||||
|
||||
将音频编码为 data URL 并以 ``input_audio`` 内容块的形式发送到 chat 消息中。
|
||||
"""
|
||||
# ── 缓存查找 ──────────────────────────────────────────────────
|
||||
cache_key = _asr_cache_key(
|
||||
self._model, request.audio_data, request.language or "auto", request.format
|
||||
)
|
||||
cached = _asr_cache_get(cache_key)
|
||||
if cached is not None:
|
||||
logger.info("audio_asr.cache_hit", model=self._model, text_length=len(cached))
|
||||
return ASRResponse(text=cached)
|
||||
|
||||
# ── 构造请求 ──────────────────────────────────────────────────
|
||||
mime = _MIME_MAP.get(request.format, "audio/wav")
|
||||
b64_audio = base64.b64encode(request.audio_data).decode()
|
||||
data_url = f"data:{mime};base64,{b64_audio}"
|
||||
|
||||
payload: dict = {
|
||||
"model": self._model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": data_url,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"asr_options": {
|
||||
"language": request.language or "auto",
|
||||
},
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
url = f"{self._base_url}/chat/completions"
|
||||
|
||||
logger.info(
|
||||
"audio_asr.request",
|
||||
model=self._model,
|
||||
format=request.format,
|
||||
language=request.language,
|
||||
url=url,
|
||||
size_bytes=len(request.audio_data),
|
||||
)
|
||||
|
||||
# ── API 调用 ────────────────────────────────────────────────
|
||||
try:
|
||||
response = await self._http.post(url, json=payload, headers=headers)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise AudioServiceError(
|
||||
status_code=504,
|
||||
code="audio_asr_timeout",
|
||||
message="ASR request timed out calling chat completions.",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise AudioServiceError(
|
||||
status_code=503,
|
||||
code="audio_asr_connection_error",
|
||||
message=f"ASR connection error: {exc}",
|
||||
) from exc
|
||||
|
||||
if response.status_code != 200:
|
||||
body = response.text[:500]
|
||||
raise AudioServiceError(
|
||||
status_code=response.status_code,
|
||||
code="audio_asr_api_error",
|
||||
message=f"ASR API returned {response.status_code}: {body}",
|
||||
)
|
||||
|
||||
# 解析响应,提取转写文本
|
||||
text = self._extract_text(response.json())
|
||||
if not text:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_asr_empty_response",
|
||||
message="ASR API returned empty transcription.",
|
||||
)
|
||||
|
||||
# ── 写入缓存 ────────────────────────────────────────────────
|
||||
_asr_cache_set(cache_key, text)
|
||||
|
||||
logger.info(
|
||||
"audio_asr.success",
|
||||
model=self._model,
|
||||
text_length=len(text),
|
||||
cache_size=len(_asr_cache),
|
||||
)
|
||||
return ASRResponse(text=text)
|
||||
|
||||
# ── 辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _extract_text(body: dict) -> str:
|
||||
"""从 chat completion 响应中提取转写文本。"""
|
||||
try:
|
||||
choices = body.get("choices", [])
|
||||
if not choices:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_asr_no_choices",
|
||||
message="ASR response has no choices.",
|
||||
)
|
||||
return (choices[0].get("message") or {}).get("content") or ""
|
||||
except AudioServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_asr_response_parse_error",
|
||||
message=f"Failed to parse ASR response: {exc}",
|
||||
) from exc
|
||||
@@ -0,0 +1,245 @@
|
||||
"""音频服务类型 — 请求/响应模型和协议定义。
|
||||
|
||||
ASR(自动语音识别)和 TTS(文本转语音)适配器**不是** LangChain 的
|
||||
``BaseChatModel`` 子类 —— 它们是通过 HTTP 调用的服务,经由 ``AudioService``
|
||||
或直接从 ``ModelFactory`` 消费。
|
||||
|
||||
用法::
|
||||
|
||||
service = AudioService(factory)
|
||||
result = await service.transcribe(audio_data)
|
||||
result = await service.synthesize("你好")
|
||||
|
||||
# 或通过工厂直接调用:
|
||||
tts = factory.create_audio("mimo-v2.5-tts")
|
||||
result = await tts.synthesize(TTSRequest(text="你好"))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.ai_provider.config.models import ModelConfig, ModelEndpointConfig
|
||||
|
||||
|
||||
class AudioServiceError(AIProviderError):
|
||||
"""ASR / TTS 服务失败的基础错误。"""
|
||||
|
||||
|
||||
# ── ASR ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ASRRequest:
|
||||
"""将音频转写为文本的请求。
|
||||
|
||||
属性:
|
||||
audio_data: 原始音频字节。
|
||||
format: 音频容器格式(如 ``"wav"``、``"mp3"``、``"m4a"``)。
|
||||
language: 可选的 ISO-639-1 语言提示(如 ``"zh"``、``"en"``)。
|
||||
"""
|
||||
|
||||
__slots__ = ("audio_data", "format", "language")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
format: str = "wav",
|
||||
language: str | None = None,
|
||||
) -> None:
|
||||
self.audio_data = audio_data
|
||||
self.format = format
|
||||
self.language = language
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ASRResponse:
|
||||
"""成功转写的结果。"""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ASRProtocol(Protocol):
|
||||
"""ASR 适配器的协议。"""
|
||||
|
||||
async def transcribe(self, request: ASRRequest) -> ASRResponse:
|
||||
"""将音频字节转写为文本。
|
||||
|
||||
异常:
|
||||
AudioServiceError: API 调用失败或响应异常时抛出。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ── TTS ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TTSRequest:
|
||||
"""将文本合成为语音的请求。
|
||||
|
||||
属性:
|
||||
text: 待朗读的文本。
|
||||
voice: 音色标识符(模型决定可用音色)。
|
||||
response_format: 期望的音频格式(``"wav"``、``"mp3"``、``"opus"``)。
|
||||
extra: 厂商特定的额外参数(如音色克隆/设计参数)。
|
||||
"""
|
||||
|
||||
__slots__ = ("text", "voice", "response_format", "extra")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
voice: str = "default",
|
||||
response_format: str = "wav",
|
||||
extra: dict | None = None,
|
||||
) -> None:
|
||||
self.text = text
|
||||
self.voice = voice
|
||||
self.response_format = response_format
|
||||
self.extra = extra or {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TTSResponse:
|
||||
"""成功合成的结果。"""
|
||||
|
||||
audio_data: bytes
|
||||
format: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TTSProtocol(Protocol):
|
||||
"""TTS 适配器的协议。"""
|
||||
|
||||
async def synthesize(self, request: TTSRequest) -> TTSResponse:
|
||||
"""将文本合成为音频字节。
|
||||
|
||||
异常:
|
||||
AudioServiceError: API 调用失败或响应异常时抛出。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ── 共享工厂基类 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AudioAdapterBase:
|
||||
"""ASR/TTS 适配器的基类,提供共享的工厂方法。
|
||||
|
||||
子类必须设置 ``_adapter_type``(如 ``"asr"``、``"tts"``),并在
|
||||
``__init__`` 中接受 ``base_url``、``api_key``、``model``、
|
||||
``request_timeout_seconds`` 参数。
|
||||
"""
|
||||
|
||||
_adapter_type: str = "audio"
|
||||
_default_timeout: int = 120
|
||||
|
||||
@classmethod
|
||||
def from_endpoint(
|
||||
cls,
|
||||
endpoint: ModelEndpointConfig,
|
||||
*,
|
||||
provider: ProviderConfig | None = None,
|
||||
timeout_seconds: int | None = None,
|
||||
) -> Any:
|
||||
"""从已解析的端点配置构建适配器。
|
||||
|
||||
凭证解析优先级:
|
||||
1. endpoint 直填的 ``base_url`` / ``api_key``
|
||||
2. endpoint 的 ``base_url_env`` / ``api_key_env`` 指向的 settings 字段
|
||||
3. provider 级凭证(调用方按需传入 ProviderConfig)
|
||||
"""
|
||||
base_url = _resolve_base_url(endpoint, provider)
|
||||
api_key = _resolve_api_key(endpoint, provider)
|
||||
if not base_url:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code=f"audio_{cls._adapter_type}_endpoint_invalid",
|
||||
message=(
|
||||
f"{cls._adapter_type.upper()} endpoint '{endpoint.name}' "
|
||||
"has no resolved base_url."
|
||||
),
|
||||
)
|
||||
return cls(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
model=endpoint.model,
|
||||
request_timeout_seconds=timeout_seconds
|
||||
or endpoint.request_timeout_seconds
|
||||
or cls._default_timeout,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_model_config(
|
||||
cls,
|
||||
config: ModelConfig,
|
||||
*,
|
||||
provider: ProviderConfig | None = None,
|
||||
timeout_seconds: int | None = None,
|
||||
) -> Any:
|
||||
"""从逻辑模型配置构建适配器(使用第一个活跃端点)。"""
|
||||
for ep in config.endpoints:
|
||||
if ep.status == "active":
|
||||
return cls.from_endpoint(
|
||||
ep, provider=provider, timeout_seconds=timeout_seconds
|
||||
)
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code=f"audio_{cls._adapter_type}_no_endpoint",
|
||||
message=f"{cls._adapter_type.upper()} model '{config.name}' has no active endpoint.",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_base_url(
|
||||
endpoint: ModelEndpointConfig,
|
||||
provider: ProviderConfig | None,
|
||||
) -> str:
|
||||
"""端点 base_url → endpoint.base_url_env 对应 settings → provider.base_url(_env)。"""
|
||||
if endpoint.base_url:
|
||||
return endpoint.base_url
|
||||
if endpoint.base_url_env:
|
||||
val = _settings_get(endpoint.base_url_env)
|
||||
if val:
|
||||
return val
|
||||
if provider is not None:
|
||||
if provider.base_url:
|
||||
return provider.base_url
|
||||
if provider.base_url_env:
|
||||
val = _settings_get(provider.base_url_env)
|
||||
if val:
|
||||
return val
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_api_key(
|
||||
endpoint: ModelEndpointConfig,
|
||||
provider: ProviderConfig | None,
|
||||
) -> str:
|
||||
"""端点 api_key → endpoint.api_key_env 对应 settings → provider 级凭证。"""
|
||||
if endpoint.api_key:
|
||||
return endpoint.api_key
|
||||
if endpoint.api_key_env:
|
||||
val = _settings_get(endpoint.api_key_env)
|
||||
if val:
|
||||
return val
|
||||
if provider is not None:
|
||||
# ProviderConfig 的 api_key_env / base_url_env 也走 settings 解析
|
||||
val = _settings_get(provider.api_key_env)
|
||||
if val:
|
||||
return val
|
||||
return ""
|
||||
|
||||
|
||||
def _settings_get(env_name: str) -> str | None:
|
||||
"""按大写 env 名查 settings 上的小写字段(与现有 _resolve_credentials 约定一致)。"""
|
||||
from app.core.config import settings
|
||||
|
||||
key = env_name.lower()
|
||||
if hasattr(settings, key):
|
||||
value = getattr(settings, key)
|
||||
return value if isinstance(value, str) else None
|
||||
return None
|
||||
@@ -0,0 +1,325 @@
|
||||
"""MiMo TTS adapter — ``POST /v1/chat/completions`` with ``audio`` parameter.
|
||||
|
||||
MiMo 的 TTS 不走独立音频端点,而是通过聊天补全接口扩展实现:
|
||||
|
||||
POST {base_url}/chat/completions
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {api_key}
|
||||
|
||||
{
|
||||
"model": "mimo-v2.5-tts",
|
||||
"messages": [
|
||||
{"role": "user", "content": "(可选)音色风格描述,voicedesign 必填"},
|
||||
{"role": "assistant", "content": "要朗读的文本"}
|
||||
],
|
||||
"audio": {
|
||||
"format": "wav",
|
||||
"voice": "mimo_default"
|
||||
}
|
||||
}
|
||||
|
||||
响应::
|
||||
|
||||
{
|
||||
"choices": [{
|
||||
"message": {
|
||||
"audio": {
|
||||
"data": "<base64 编码的音频数据>",
|
||||
"id": "...",
|
||||
"expires_at": null,
|
||||
"transcript": null
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
模型差异:
|
||||
|
||||
- ``mimo-v2.5-tts`` — ``voice`` 可选,默认 ``mimo_default``,仅支持预置音色
|
||||
- ``mimo-v2.5-tts-voiceclone`` — ``voice`` 必填,传入音频样本的 base64(mp3/wav)
|
||||
- ``mimo-v2.5-tts-voicedesign`` — 不支持 ``voice``,\n ``user`` 消息必填(风格描述),支持 ``optimize_text_preview``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.ai_provider.audio.base import (
|
||||
AudioAdapterBase,
|
||||
AudioServiceError,
|
||||
TTSProtocol,
|
||||
TTSRequest,
|
||||
TTSResponse,
|
||||
)
|
||||
from app.core.logging import logger
|
||||
|
||||
# voicedesign 模型的默认风格描述(用户未提供时使用)
|
||||
_DEFAULT_VOICE_DESIGN_PROMPT = "自然、清晰的朗读风格,语气平和,语速适中,适合日常对话场景。"
|
||||
|
||||
# TTS 音频缓存:默认最多 256 条,TTL 1 小时
|
||||
_TTS_CACHE_MAX_SIZE = 256
|
||||
_TTS_CACHE_TTL_SECONDS = 3600
|
||||
|
||||
|
||||
class _TTSCacheEntry:
|
||||
__slots__ = ("data", "format", "stored_at")
|
||||
|
||||
def __init__(self, data: bytes, format: str) -> None:
|
||||
self.data = data
|
||||
self.format = format
|
||||
self.stored_at = time.monotonic()
|
||||
|
||||
|
||||
_tts_cache: dict[str, _TTSCacheEntry] = {}
|
||||
|
||||
|
||||
def _tts_cache_key(model: str, voice: str, fmt: str, text: str, extra_json: str) -> str:
|
||||
"""生成 TTS 缓存键:SHA256(model + voice + format + text + extra)。"""
|
||||
raw = f"{model}\0{voice}\0{fmt}\0{text}\0{extra_json}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
def _tts_cache_get(key: str) -> _TTSCacheEntry | None:
|
||||
entry = _tts_cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.monotonic() - entry.stored_at > _TTS_CACHE_TTL_SECONDS:
|
||||
del _tts_cache[key]
|
||||
return None
|
||||
return entry
|
||||
|
||||
|
||||
def _tts_cache_set(key: str, entry: _TTSCacheEntry) -> None:
|
||||
if len(_tts_cache) >= _TTS_CACHE_MAX_SIZE:
|
||||
# 淘汰最旧的一半
|
||||
sorted_keys = sorted(_tts_cache.keys(), key=lambda k: _tts_cache[k].stored_at)
|
||||
for old_key in sorted_keys[: _TTS_CACHE_MAX_SIZE // 2]:
|
||||
del _tts_cache[old_key]
|
||||
_tts_cache[key] = entry
|
||||
|
||||
|
||||
class HunjingTTSOpenAI(AudioAdapterBase, TTSProtocol):
|
||||
"""MiMo TTS 适配器,通过 ``POST /chat/completions`` 携带 ``audio`` 参数实现。
|
||||
|
||||
调用兼容 OpenAI 格式的 chat completions 端点,在请求体中添加 ``audio`` 字段,
|
||||
然后从响应中提取并解码 base64 音频数据。
|
||||
"""
|
||||
|
||||
_adapter_type = "tts"
|
||||
_default_timeout = 120
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
model: str = "mimo-v2.5-tts",
|
||||
request_timeout_seconds: int = 120,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._model = model
|
||||
self._timeout = request_timeout_seconds
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
# ── 客户端 ───────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def _http(self) -> httpx.AsyncClient:
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
|
||||
# ── TTSProtocol ──────────────────────────────────────────────────────
|
||||
|
||||
async def synthesize(self, request: TTSRequest) -> TTSResponse:
|
||||
"""通过 ``POST /chat/completions`` 将文本合成为音频。
|
||||
|
||||
构造 chat 消息和 ``audio`` 参数,匹配 MiMo 兼容 OpenAI 格式的 TTS,
|
||||
然后从响应中解码 base64 音频。
|
||||
|
||||
结果按 ``(model, voice, format, text)`` 在进程内缓存,避免重复 API 调用。
|
||||
"""
|
||||
# ── 缓存查找 ──────────────────────────────────────────────────────
|
||||
cache_key = _tts_cache_key(
|
||||
model=self._model,
|
||||
voice=request.voice,
|
||||
fmt=request.response_format,
|
||||
text=request.text,
|
||||
extra_json=str(sorted((request.extra or {}).items())),
|
||||
)
|
||||
cached = _tts_cache_get(cache_key)
|
||||
if cached is not None:
|
||||
logger.info("audio_tts.cache_hit", model=self._model, text_length=len(request.text))
|
||||
return TTSResponse(audio_data=cached.data, format=cached.format)
|
||||
|
||||
# ── API 调用 ──────────────────────────────────────────────────────
|
||||
url = f"{self._base_url}/chat/completions"
|
||||
|
||||
# 构造 messages
|
||||
messages = self._build_messages(request)
|
||||
|
||||
# 构造 audio 参数
|
||||
audio_params = self._build_audio_params(request)
|
||||
|
||||
payload: dict = {
|
||||
"model": self._model,
|
||||
"messages": messages,
|
||||
"audio": audio_params,
|
||||
"max_completion_tokens": 8192,
|
||||
}
|
||||
# 合并 extra(如 frequency_penalty, temperature 等)
|
||||
if request.extra:
|
||||
payload.update(request.extra)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"audio_tts.request",
|
||||
model=self._model,
|
||||
voice=request.voice,
|
||||
audio_format=request.response_format,
|
||||
text_length=len(request.text),
|
||||
url=url,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._http.post(url, json=payload, headers=headers)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise AudioServiceError(
|
||||
status_code=504,
|
||||
code="audio_tts_timeout",
|
||||
message="TTS request timed out calling chat completions.",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise AudioServiceError(
|
||||
status_code=503,
|
||||
code="audio_tts_connection_error",
|
||||
message=f"TTS connection error: {exc}",
|
||||
) from exc
|
||||
|
||||
if response.status_code != 200:
|
||||
body = response.text[:500]
|
||||
raise AudioServiceError(
|
||||
status_code=response.status_code,
|
||||
code="audio_tts_api_error",
|
||||
message=f"TTS API returned {response.status_code}: {body}",
|
||||
)
|
||||
|
||||
# 解析响应,提取 base64 音频
|
||||
audio_data = self._extract_audio(response.json())
|
||||
|
||||
# ── 写入缓存 ──────────────────────────────────────────────────────
|
||||
_tts_cache_set(cache_key, _TTSCacheEntry(audio_data, request.response_format))
|
||||
|
||||
logger.info(
|
||||
"audio_tts.success",
|
||||
model=self._model,
|
||||
audio_bytes=len(audio_data),
|
||||
cache_size=len(_tts_cache),
|
||||
)
|
||||
return TTSResponse(audio_data=audio_data, format=request.response_format)
|
||||
|
||||
# ── 辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
def _build_messages(self, request: TTSRequest) -> list[dict]:
|
||||
"""构造 chat messages 数组。
|
||||
|
||||
- voicedesign 模型:user 消息必填(风格描述)
|
||||
- 其他模型:user 消息可选,assistant 消息存放待朗读文本
|
||||
"""
|
||||
is_voicedesign = "voicedesign" in self._model
|
||||
|
||||
if is_voicedesign:
|
||||
# voicedesign:user 消息为风格提示,assistant 消息为朗读文本
|
||||
style_prompt = (request.extra or {}).pop("style_prompt", _DEFAULT_VOICE_DESIGN_PROMPT)
|
||||
return [
|
||||
{"role": "user", "content": style_prompt},
|
||||
{"role": "assistant", "content": request.text},
|
||||
]
|
||||
# 基础 TTS / voiceclone:只传 assistant 消息
|
||||
return [
|
||||
{"role": "assistant", "content": request.text},
|
||||
]
|
||||
|
||||
def _build_audio_params(self, request: TTSRequest) -> dict:
|
||||
"""构造 ``audio`` 参数字段,按模型处理 voice/format/optimize_text_preview 等。"""
|
||||
params: dict = {
|
||||
"format": _resolve_api_format(request.response_format),
|
||||
}
|
||||
|
||||
is_voicedesign = "voicedesign" in self._model
|
||||
|
||||
if is_voicedesign:
|
||||
# voicedesign 不支持 voice 字段
|
||||
optimize = (request.extra or {}).pop("optimize_text_preview", None)
|
||||
if optimize is not None:
|
||||
params["optimize_text_preview"] = optimize
|
||||
else:
|
||||
# 基础 TTS / voiceclone:按需传 voice
|
||||
if request.voice and request.voice != "default":
|
||||
params["voice"] = request.voice
|
||||
else:
|
||||
params["voice"] = "mimo_default"
|
||||
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _extract_audio(body: dict) -> bytes:
|
||||
"""从 chat completion 响应中提取并解码 base64 音频数据。"""
|
||||
try:
|
||||
choices = body.get("choices", [])
|
||||
if not choices:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_tts_no_choices",
|
||||
message="TTS response has no choices.",
|
||||
)
|
||||
message = choices[0].get("message", {})
|
||||
audio_info = message.get("audio")
|
||||
if not audio_info:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_tts_no_audio_field",
|
||||
message="TTS response missing audio field in message.",
|
||||
)
|
||||
raw = audio_info.get("data")
|
||||
if not raw:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_tts_empty_audio_data",
|
||||
message="TTS response has empty audio.data.",
|
||||
)
|
||||
return base64.b64decode(raw)
|
||||
except AudioServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_tts_response_parse_error",
|
||||
message=f"Failed to parse TTS response: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
def _resolve_api_format(user_format: str) -> str:
|
||||
"""将用户层面的格式名映射为 API 期望的格式。
|
||||
|
||||
MiMo TTS API 接受:``wav``、``mp3``、``pcm``、``pcm16``。
|
||||
"""
|
||||
fmt = user_format.lower()
|
||||
if fmt in ("pcm", "pcm16"):
|
||||
return "pcm16"
|
||||
if fmt in ("wav", "mp3"):
|
||||
return fmt
|
||||
return "wav"
|
||||
@@ -0,0 +1,231 @@
|
||||
"""yuanqi 本地 ASR 适配器 — ``POST /v1/audio/transcriptions`` (multipart)。
|
||||
|
||||
与 MiMo 走 ``/chat/completions`` + ``input_audio`` 不同,元炁本地推理引擎
|
||||
(yuanqi)对外暴露的是 OpenAI Whisper 兼容的 multipart 端点::
|
||||
|
||||
POST {base_url}/v1/audio/transcriptions
|
||||
Content-Type: multipart/form-data
|
||||
Authorization: Bearer {api_key}
|
||||
|
||||
file: <audio bytes>
|
||||
language: zh (可选)
|
||||
|
||||
响应::
|
||||
|
||||
{"text": "转写后的文本"}
|
||||
|
||||
模型实际为 FunASR SenseVoiceSmall,转写文本已在 yuanqi 侧剥离了
|
||||
``<|zh|><|NEUTRAL|><|Speech|><|withitn|>`` 等内部标签,此处无需二次清洗。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.ai_provider.audio.base import (
|
||||
ASRProtocol,
|
||||
ASRRequest,
|
||||
ASRResponse,
|
||||
AudioAdapterBase,
|
||||
AudioServiceError,
|
||||
)
|
||||
from app.core.logging import logger
|
||||
|
||||
# multipart 使用的 MIME 映射(与 yuanqi _SUPPORTED_FORMATS 白名单对齐)
|
||||
_MIME_MAP = {
|
||||
"wav": "audio/wav",
|
||||
"mp3": "audio/mpeg",
|
||||
"m4a": "audio/mp4",
|
||||
"flac": "audio/flac",
|
||||
"ogg": "audio/ogg",
|
||||
"opus": "audio/opus",
|
||||
"aac": "audio/aac",
|
||||
"webm": "audio/webm",
|
||||
}
|
||||
|
||||
# yuanqi ASR 路径前缀(与 src/endpoints/asr.py 的 router 前缀一致)
|
||||
_ASR_PATH = "/v1/audio/transcriptions"
|
||||
|
||||
# ASR 缓存:默认最多 128 条,TTL 1 小时(与 MiMo ASR 适配器一致)
|
||||
_ASR_CACHE_MAX_SIZE = 128
|
||||
_ASR_CACHE_TTL_SECONDS = 3600
|
||||
|
||||
|
||||
class _ASRCacheEntry:
|
||||
__slots__ = ("text", "stored_at")
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
self.text = text
|
||||
self.stored_at = time.monotonic()
|
||||
|
||||
|
||||
_asr_cache: dict[str, _ASRCacheEntry] = {}
|
||||
|
||||
|
||||
def _asr_cache_key(model: str, audio_data: bytes, language: str, fmt: str) -> str:
|
||||
"""生成 ASR 缓存键:SHA256(model + audio_bytes + language + format)。"""
|
||||
raw = f"{model}\0{language}\0{fmt}\0"
|
||||
raw_bytes = raw.encode() + audio_data
|
||||
return hashlib.sha256(raw_bytes).hexdigest()
|
||||
|
||||
|
||||
def _asr_cache_get(key: str) -> str | None:
|
||||
entry = _asr_cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.monotonic() - entry.stored_at > _ASR_CACHE_TTL_SECONDS:
|
||||
del _asr_cache[key]
|
||||
return None
|
||||
return entry.text
|
||||
|
||||
|
||||
def _asr_cache_set(key: str, text: str) -> None:
|
||||
if len(_asr_cache) >= _ASR_CACHE_MAX_SIZE:
|
||||
sorted_keys = sorted(_asr_cache.keys(), key=lambda k: _asr_cache[k].stored_at)
|
||||
for old_key in sorted_keys[: _ASR_CACHE_MAX_SIZE // 2]:
|
||||
del _asr_cache[old_key]
|
||||
_asr_cache[key] = _ASRCacheEntry(text)
|
||||
|
||||
|
||||
class YuanqiASRWhisper(AudioAdapterBase, ASRProtocol):
|
||||
"""yuanqi 本地 ASR 适配器,走 OpenAI Whisper 兼容的 multipart 端点。
|
||||
|
||||
将音频字节以 ``multipart/form-data`` 的 ``file`` 字段上传到 yuanqi 的
|
||||
``/v1/audio/transcriptions``,从 JSON 响应中提取转写文本。
|
||||
"""
|
||||
|
||||
_adapter_type = "asr"
|
||||
_default_timeout = 300
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
model: str = "SenseVoiceSmall",
|
||||
request_timeout_seconds: int = 300,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._model = model
|
||||
self._timeout = request_timeout_seconds
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
# ── 客户端 ───────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def _http(self) -> httpx.AsyncClient:
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
|
||||
# ── ASRProtocol ──────────────────────────────────────────────────────
|
||||
|
||||
async def transcribe(self, request: ASRRequest) -> ASRResponse:
|
||||
"""通过 ``POST /v1/audio/transcriptions`` 转写音频。"""
|
||||
# ── 缓存查找 ──────────────────────────────────────────────────
|
||||
cache_key = _asr_cache_key(
|
||||
self._model, request.audio_data, request.language or "auto", request.format
|
||||
)
|
||||
cached = _asr_cache_get(cache_key)
|
||||
if cached is not None:
|
||||
logger.info("audio_asr.cache_hit", model=self._model, text_length=len(cached))
|
||||
return ASRResponse(text=cached)
|
||||
|
||||
# ── 构造 multipart 请求 ───────────────────────────────────────
|
||||
mime = _MIME_MAP.get(request.format, "audio/wav")
|
||||
filename = f"audio.{request.format}"
|
||||
files = {"file": (filename, request.audio_data, mime)}
|
||||
data: dict[str, str] = {}
|
||||
if request.language:
|
||||
data["language"] = request.language
|
||||
|
||||
# 本地 yuanqi 引擎通常无需鉴权;仅当配置了 api_key 才带 Authorization 头
|
||||
headers: dict[str, str] = {}
|
||||
if self._api_key:
|
||||
headers["Authorization"] = f"Bearer {self._api_key}"
|
||||
|
||||
url = f"{self._base_url}{_ASR_PATH}"
|
||||
|
||||
logger.info(
|
||||
"audio_asr.request",
|
||||
model=self._model,
|
||||
format=request.format,
|
||||
language=request.language,
|
||||
url=url,
|
||||
size_bytes=len(request.audio_data),
|
||||
)
|
||||
|
||||
# ── API 调用 ────────────────────────────────────────────────
|
||||
try:
|
||||
response = await self._http.post(url, files=files, data=data, headers=headers)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise AudioServiceError(
|
||||
status_code=504,
|
||||
code="audio_asr_timeout",
|
||||
message="ASR request timed out calling yuanqi transcriptions.",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise AudioServiceError(
|
||||
status_code=503,
|
||||
code="audio_asr_connection_error",
|
||||
message=f"ASR connection error: {exc}",
|
||||
) from exc
|
||||
|
||||
if response.status_code != 200:
|
||||
body = response.text[:500]
|
||||
raise AudioServiceError(
|
||||
status_code=response.status_code,
|
||||
code="audio_asr_api_error",
|
||||
message=f"ASR API returned {response.status_code}: {body}",
|
||||
)
|
||||
|
||||
# 解析响应,提取转写文本
|
||||
text = self._extract_text(response.json())
|
||||
if not text:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_asr_empty_response",
|
||||
message="ASR API returned empty transcription.",
|
||||
)
|
||||
|
||||
# ── 写入缓存 ────────────────────────────────────────────────
|
||||
_asr_cache_set(cache_key, text)
|
||||
|
||||
logger.info(
|
||||
"audio_asr.success",
|
||||
model=self._model,
|
||||
text_length=len(text),
|
||||
cache_size=len(_asr_cache),
|
||||
)
|
||||
return ASRResponse(text=text)
|
||||
|
||||
# ── 辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _extract_text(body: dict) -> str:
|
||||
"""从 Whisper 兼容响应中提取转写文本。"""
|
||||
try:
|
||||
text = body.get("text")
|
||||
if text is None:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_asr_no_text",
|
||||
message="ASR response has no 'text' field.",
|
||||
)
|
||||
return text or ""
|
||||
except AudioServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_asr_response_parse_error",
|
||||
message=f"Failed to parse ASR response: {exc}",
|
||||
) from exc
|
||||
@@ -0,0 +1,31 @@
|
||||
from app.ai_provider.config.loader import (
|
||||
get_models_config_path,
|
||||
get_pricing_config_path,
|
||||
get_providers_config_path,
|
||||
load_models_config,
|
||||
load_pricing_config,
|
||||
load_providers_config,
|
||||
)
|
||||
from app.ai_provider.config.models import (
|
||||
ModelConfig,
|
||||
ModelEndpointConfig,
|
||||
ParameterMapping,
|
||||
ProviderConfig,
|
||||
derive_capabilities,
|
||||
)
|
||||
from app.ai_provider.core.billing import PricingInfo
|
||||
|
||||
__all__ = [
|
||||
"ModelConfig",
|
||||
"ModelEndpointConfig",
|
||||
"PricingInfo",
|
||||
"ProviderConfig",
|
||||
"ParameterMapping",
|
||||
"derive_capabilities",
|
||||
"load_models_config",
|
||||
"load_providers_config",
|
||||
"load_pricing_config",
|
||||
"get_models_config_path",
|
||||
"get_providers_config_path",
|
||||
"get_pricing_config_path",
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig, ProviderConfig
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class _ModelsFile(BaseModel):
|
||||
version: int = 1
|
||||
models: list[ModelConfig]
|
||||
|
||||
|
||||
class _ProvidersFile(BaseModel):
|
||||
version: int = 1
|
||||
providers: dict[str, ProviderConfig]
|
||||
|
||||
|
||||
class _PricingFile(BaseModel):
|
||||
"""pricing.yaml 顶层结构。"""
|
||||
|
||||
version: int = 1
|
||||
pricing: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_models_config_path() -> Path:
|
||||
"""返回已解析的 models.yaml 路径(project_root/config/models.yaml)。"""
|
||||
return settings.project_root / "config" / "models.yaml"
|
||||
|
||||
|
||||
def get_providers_config_path() -> Path:
|
||||
"""返回已解析的 providers.yaml 路径(project_root/config/providers.yaml)。"""
|
||||
return settings.project_root / "config" / "providers.yaml"
|
||||
|
||||
|
||||
def load_models_config(path: Path | None = None) -> list[ModelConfig]:
|
||||
"""从 YAML 文件加载模型配置。如果未指定路径,使用配置中的默认路径。"""
|
||||
if path is None:
|
||||
path = get_models_config_path()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Models config not found: {path}")
|
||||
raw: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
parsed = _ModelsFile.model_validate(raw)
|
||||
return parsed.models
|
||||
|
||||
|
||||
def load_providers_config(path: Path | None = None) -> dict[str, ProviderConfig]:
|
||||
"""从 YAML 文件加载提供商配置。如果未指定路径,使用配置中的默认路径。"""
|
||||
if path is None:
|
||||
path = get_providers_config_path()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Providers config not found: {path}")
|
||||
raw: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
# 验证前将字典键注入为 provider 的 name 字段
|
||||
providers_raw = raw.get("providers", {})
|
||||
for name, cfg in providers_raw.items():
|
||||
if isinstance(cfg, dict) and "name" not in cfg:
|
||||
cfg["name"] = name
|
||||
parsed = _ProvidersFile.model_validate(raw)
|
||||
return parsed.providers
|
||||
|
||||
|
||||
def get_pricing_config_path() -> Path:
|
||||
"""返回 pricing.yaml 路径(project_root/config/pricing.yaml)。"""
|
||||
return settings.project_root / "config" / "pricing.yaml"
|
||||
|
||||
|
||||
def load_pricing_config(path: Path | None = None) -> dict[str, dict]:
|
||||
"""从 YAML 文件加载定价配置。"""
|
||||
if path is None:
|
||||
path = get_pricing_config_path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
raw: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
parsed = _PricingFile.model_validate(raw)
|
||||
return parsed.pricing
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ( # noqa: TC002 — Pydantic 模型
|
||||
BaseModel,
|
||||
Field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
|
||||
class SupportedFormats(StrEnum):
|
||||
OPENAI = "openai"
|
||||
ANTHROPIC = "anthropic"
|
||||
|
||||
|
||||
_BASE_CAPABILITIES = {
|
||||
("openai", "gpt"): {"chat", "tool_use", "structured_output"},
|
||||
("openai", "gpt-4"): {"chat", "tool_use", "structured_output", "vision", "reasoning"},
|
||||
("anthropic", "claude"): {
|
||||
"chat",
|
||||
"tool_use",
|
||||
"structured_output",
|
||||
"vision",
|
||||
"reasoning",
|
||||
"long_context",
|
||||
},
|
||||
("deepseek", "deepseek"): {"chat", "tool_use", "structured_output", "reasoning"},
|
||||
("deepseek", "deepseek-v4"): {
|
||||
"chat",
|
||||
"tool_use",
|
||||
"structured_output",
|
||||
"reasoning",
|
||||
"long_context",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def derive_capabilities(provider: str, model: str) -> set[str]:
|
||||
"""根据 provider + model 前缀自动推导能力。
|
||||
|
||||
调用者可以在调用此函数后,在 ModelConfig 上覆盖个别标记。
|
||||
"""
|
||||
for (prov_prefix, model_prefix), caps in _BASE_CAPABILITIES.items():
|
||||
if provider.startswith(prov_prefix) and model.startswith(model_prefix):
|
||||
return set(caps)
|
||||
return {"chat"}
|
||||
|
||||
|
||||
class ModelEndpointConfig(BaseModel):
|
||||
name: str
|
||||
model: str
|
||||
base_url: str | None = None
|
||||
base_url_env: str | None = None
|
||||
api_key: str | None = None
|
||||
api_key_env: str | None = None
|
||||
weight: int = 100
|
||||
priority: int = 100
|
||||
rpm_limit: int | None = None
|
||||
tpm_limit: int | None = None
|
||||
max_concurrency: int | None = None
|
||||
request_timeout_seconds: int | None = None
|
||||
status: str = "active"
|
||||
extra_params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ModelConfig(BaseModel):
|
||||
name: str
|
||||
display_name: str | None = None
|
||||
provider: str
|
||||
model: str
|
||||
format: SupportedFormats # "openai" | "anthropic"
|
||||
chat_format: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
supports_tools: bool | None = None
|
||||
supports_vision: bool | None = None
|
||||
supports_reasoning_effort: bool | None = None
|
||||
supports_thinking: bool | None = None
|
||||
supports_streaming: bool | None = True
|
||||
supports_structured_output: bool | None = None
|
||||
context_window: int = 32000
|
||||
response_reserve: int = 4096
|
||||
max_output_tokens: int | None = None
|
||||
max_thinking_tokens: int | None = None
|
||||
reasoning_effort: str | None = None
|
||||
timeout_seconds: int | None = 60
|
||||
dimensions: int | None = None # 向量维度(embedding 模型专用)
|
||||
routing_strategy: str = "weighted_round_robin"
|
||||
endpoint_pool_enabled: bool = False
|
||||
weight: int = 100
|
||||
endpoints: list[ModelEndpointConfig] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_embedding_dimensions(self) -> ModelConfig:
|
||||
"""embedding 模型必须声明 dimensions。"""
|
||||
if "embedding" in self.tags and self.dimensions is None:
|
||||
raise ValueError(
|
||||
f"Embedding 模型 '{self.name}' 必须设置 dimensions(如 dimensions: 1024)"
|
||||
)
|
||||
return self
|
||||
|
||||
def resolved_capabilities(self) -> dict[str, bool]:
|
||||
base = derive_capabilities(self.provider, self.model)
|
||||
return {
|
||||
"chat": True,
|
||||
"tool_use": self.supports_tools
|
||||
if self.supports_tools is not None
|
||||
else ("tool_use" in base),
|
||||
"vision": self.supports_vision
|
||||
if self.supports_vision is not None
|
||||
else ("vision" in base),
|
||||
"reasoning": self.supports_reasoning_effort
|
||||
if self.supports_reasoning_effort is not None
|
||||
else ("reasoning" in base),
|
||||
"thinking": self.supports_thinking
|
||||
if self.supports_thinking is not None
|
||||
else ("reasoning" in base),
|
||||
"streaming": self.supports_streaming if self.supports_streaming is not None else True,
|
||||
"structured_output": self.supports_structured_output
|
||||
if self.supports_structured_output is not None
|
||||
else ("structured_output" in base),
|
||||
}
|
||||
|
||||
|
||||
class ParameterMapping(BaseModel):
|
||||
provider_param: str
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
default: Any = None
|
||||
transform: str | None = None # "multiply_1000", "int", "str"
|
||||
alias_for: str | None = None # map from another param name
|
||||
handler: str | None = None # named handler for complex param transform
|
||||
|
||||
|
||||
class ProviderConfig(BaseModel):
|
||||
name: str
|
||||
format: SupportedFormats
|
||||
base_url_env: str
|
||||
api_key_env: str
|
||||
base_url: str | None = None
|
||||
timeout_seconds: int = 60
|
||||
max_retries: int = 1
|
||||
parameter_mappings: dict[str, ParameterMapping] = Field(default_factory=dict)
|
||||
parameter_handlers: list[str] = Field(
|
||||
default_factory=list
|
||||
) # ordered global handlers per provider
|
||||
extra_headers: dict[str, str] = Field(default_factory=dict)
|
||||
supports_streaming: bool = True
|
||||
|
||||
@field_validator("format")
|
||||
@classmethod
|
||||
def _validate_format(cls, v: str) -> str:
|
||||
allowed = {e.value for e in SupportedFormats}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"format must be one of {allowed}, got '{v}'")
|
||||
return v
|
||||
@@ -0,0 +1,17 @@
|
||||
from app.ai_provider.core.billing import PricingFactory, PricingInfo, PricingRegistry
|
||||
from app.ai_provider.core.registry import (
|
||||
ModelRegistry,
|
||||
ProviderRegistry,
|
||||
RegistryFactory,
|
||||
RegistryHolder,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ModelRegistry",
|
||||
"ProviderRegistry",
|
||||
"RegistryFactory",
|
||||
"RegistryHolder",
|
||||
"PricingInfo",
|
||||
"PricingRegistry",
|
||||
"PricingFactory",
|
||||
]
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.ai_provider.types.chat import TokenUsage
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostBreakdown:
|
||||
input_cost: float = 0.0
|
||||
output_cost: float = 0.0
|
||||
total: float = 0.0
|
||||
currency: str = "CNY"
|
||||
|
||||
|
||||
class CostCalculator:
|
||||
"""根据价格信息和 token 数量计算成本。
|
||||
|
||||
保持厂商原始单位 —— 不做跨币种换算。
|
||||
|
||||
- 普通 input token 按 ``input_per_1m`` 计费。
|
||||
- 缓存命中的 prompt token 优先按 ``input_cache_read_per_1m`` 计费
|
||||
(若有配置),剩余按普通 input 费率。
|
||||
- 若未配置 cache 费率,所有 prompt token 统一按 input_per_1m 计费。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def calculate(
|
||||
pricing: PricingInfo | None,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
prompt_tokens_cached: int = 0,
|
||||
) -> CostBreakdown:
|
||||
if pricing is None:
|
||||
return CostBreakdown(total=0.0)
|
||||
|
||||
# 缓存 token:优先走缓存费率
|
||||
cached = min(prompt_tokens_cached, prompt_tokens)
|
||||
uncached = prompt_tokens - cached
|
||||
|
||||
input_cost = 0.0
|
||||
if uncached > 0 and pricing.input_per_1m is not None:
|
||||
input_cost += (uncached / 1_000_000) * pricing.input_per_1m
|
||||
if cached > 0 and pricing.input_cache_read_per_1m is not None:
|
||||
input_cost += (cached / 1_000_000) * pricing.input_cache_read_per_1m
|
||||
elif cached > 0 and pricing.input_per_1m is not None:
|
||||
# 无缓存费率时统一按普通 input 计
|
||||
input_cost += (cached / 1_000_000) * pricing.input_per_1m
|
||||
|
||||
output_cost = 0.0
|
||||
if pricing.output_per_1m is not None and completion_tokens > 0:
|
||||
output_cost = (completion_tokens / 1_000_000) * pricing.output_per_1m
|
||||
|
||||
return CostBreakdown(
|
||||
input_cost=round(input_cost, 8),
|
||||
output_cost=round(output_cost, 8),
|
||||
total=round(input_cost + output_cost, 8),
|
||||
currency=pricing.currency,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsageRecord:
|
||||
model_name: str
|
||||
provider: str
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
currency: str = "CNY"
|
||||
cost: float = 0.0
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
endpoint_name: str | None = None
|
||||
status: str = "success"
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class BillingTracker:
|
||||
"""记录用量并按模型计算成本。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._records: list[UsageRecord] = []
|
||||
|
||||
def record(
|
||||
self,
|
||||
model_name: str,
|
||||
provider: str,
|
||||
usage: TokenUsage | None,
|
||||
pricing: PricingInfo | None = None,
|
||||
endpoint_name: str | None = None,
|
||||
status: str = "success",
|
||||
error_message: str | None = None,
|
||||
) -> UsageRecord:
|
||||
prompt_tokens = usage.prompt_tokens if usage else 0
|
||||
completion_tokens = usage.completion_tokens if usage else 0
|
||||
total = usage.total_tokens if usage else 0
|
||||
cached = usage.prompt_tokens_cached if usage else 0
|
||||
|
||||
cost_info = CostCalculator.calculate(pricing, prompt_tokens, completion_tokens, cached)
|
||||
|
||||
record = UsageRecord(
|
||||
model_name=model_name,
|
||||
provider=provider,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total,
|
||||
currency=cost_info.currency,
|
||||
cost=cost_info.total,
|
||||
endpoint_name=endpoint_name,
|
||||
status=status,
|
||||
error_message=error_message,
|
||||
)
|
||||
self._records.append(record)
|
||||
return record
|
||||
|
||||
def get_records(
|
||||
self,
|
||||
since: datetime | None = None,
|
||||
model_name: str | None = None,
|
||||
) -> list[UsageRecord]:
|
||||
records = self._records
|
||||
if since:
|
||||
records = [r for r in records if r.timestamp >= since]
|
||||
if model_name:
|
||||
records = [r for r in records if r.model_name == model_name]
|
||||
return records
|
||||
|
||||
def total_cost(self, currency: str | None = None) -> float:
|
||||
if currency:
|
||||
return sum(r.cost for r in self._records if r.currency == currency)
|
||||
return sum(r.cost for r in self._records)
|
||||
|
||||
|
||||
class BudgetController:
|
||||
"""强制执行每日和每月预算限制。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
daily_budget_cny: float = 0.0,
|
||||
monthly_budget_cny: float = 0.0,
|
||||
) -> None:
|
||||
self.daily_budget_cny = daily_budget_cny
|
||||
self.monthly_budget_cny = monthly_budget_cny
|
||||
self._daily_cost_cny: float = 0.0
|
||||
self._monthly_cost_cny: float = 0.0
|
||||
self._current_date: date = date.today()
|
||||
|
||||
def add_cost_cny(self, cost: float) -> None:
|
||||
self._daily_cost_cny += cost
|
||||
self._monthly_cost_cny += cost
|
||||
|
||||
def allow_request(self) -> bool:
|
||||
return not (
|
||||
(self.daily_budget_cny > 0 and self._daily_cost_cny >= self.daily_budget_cny)
|
||||
or (self.monthly_budget_cny > 0 and self._monthly_cost_cny >= self.monthly_budget_cny)
|
||||
)
|
||||
|
||||
def reset_daily(self) -> None:
|
||||
self._daily_cost_cny = 0.0
|
||||
self._current_date = date.today()
|
||||
|
||||
def reset_monthly(self) -> None:
|
||||
self._monthly_cost_cny = 0.0
|
||||
|
||||
|
||||
class BillingFactory:
|
||||
"""计费组件工厂。"""
|
||||
|
||||
@staticmethod
|
||||
def create_tracker() -> BillingTracker:
|
||||
return BillingTracker()
|
||||
|
||||
@staticmethod
|
||||
def create_budget_controller(
|
||||
daily_budget_cny: float = 0.0,
|
||||
monthly_budget_cny: float = 0.0,
|
||||
) -> BudgetController:
|
||||
return BudgetController(
|
||||
daily_budget_cny=daily_budget_cny,
|
||||
monthly_budget_cny=monthly_budget_cny,
|
||||
)
|
||||
|
||||
|
||||
# ── 定价信息 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PricingInfo(BaseModel):
|
||||
"""模型定价信息。保持厂商原始单位,不转换不混合。
|
||||
|
||||
各厂商按各自币种/单位记录,展示时原生显示。
|
||||
"""
|
||||
|
||||
currency: str = "CNY"
|
||||
input_per_1m: float | None = None
|
||||
output_per_1m: float | None = None
|
||||
input_cache_read_per_1m: float | None = None
|
||||
|
||||
|
||||
class PricingRegistry:
|
||||
"""模型定价注册表。以模型逻辑名为键查找定价。
|
||||
|
||||
与 ModelRegistry 解耦 —— 改价不影响模型注册,无定价的模型
|
||||
也可以正常使用(不计费)。
|
||||
"""
|
||||
|
||||
def __init__(self, pricing_map: dict[str, PricingInfo] | None = None) -> None:
|
||||
self._pricing: dict[str, PricingInfo] = dict(pricing_map or {})
|
||||
|
||||
def get(self, model_name: str) -> PricingInfo | None:
|
||||
"""获取指定模型的定价信息。无定价时返回 None。"""
|
||||
return self._pricing.get(model_name)
|
||||
|
||||
def list_all(self) -> dict[str, PricingInfo]:
|
||||
"""返回全部定价数据的副本。"""
|
||||
return dict(self._pricing)
|
||||
|
||||
|
||||
class PricingFactory:
|
||||
"""定价注册表工厂。"""
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict[str, dict]) -> PricingRegistry:
|
||||
"""从 YAML 解析后的 dict 构建 PricingRegistry。
|
||||
|
||||
YAML 按厂商嵌套组织(自文档化),工厂将其摊平为按模型逻辑名索引:
|
||||
|
||||
```yaml
|
||||
pricing:
|
||||
deepseek:
|
||||
deepseek-v4-flash:
|
||||
currency: CNY
|
||||
input_per_1m: 1.0
|
||||
anthropic:
|
||||
claude-sonnet:
|
||||
currency: USD
|
||||
input_per_1m: 3.0
|
||||
```
|
||||
"""
|
||||
pricing_map: dict[str, PricingInfo] = {}
|
||||
for _provider_name, models_data in data.items():
|
||||
if not isinstance(models_data, dict):
|
||||
continue
|
||||
for model_name, raw in models_data.items():
|
||||
if raw is None:
|
||||
continue
|
||||
# 跳过所有字段均为 null 的条目(如 mimo-v2.5-asr)
|
||||
values = [v for v in raw.values() if not isinstance(v, bool)]
|
||||
if all(v is None for v in values):
|
||||
continue
|
||||
pricing_map[model_name] = PricingInfo(**raw)
|
||||
return PricingRegistry(pricing_map)
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig, ProviderConfig
|
||||
from app.ai_provider.core.billing import PricingFactory, PricingRegistry
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
|
||||
|
||||
class ModelRegistry:
|
||||
"""内存模型索引。支持逻辑名和别名查找。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
models: list[ModelConfig],
|
||||
aliases: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self._models: dict[str, ModelConfig] = {}
|
||||
self._aliases: dict[str, str] = dict(aliases or {})
|
||||
self._default: str | None = None
|
||||
self._fallback: str | None = None
|
||||
self._capability_index: dict[str, list[ModelConfig]] = {}
|
||||
|
||||
for m in models:
|
||||
self._models[m.name] = m
|
||||
if "default" in m.tags and self._default is None:
|
||||
self._default = m.name
|
||||
if "fallback" in m.tags and self._fallback is None:
|
||||
self._fallback = m.name
|
||||
caps = m.resolved_capabilities()
|
||||
for cap, supported in caps.items():
|
||||
if supported:
|
||||
self._capability_index.setdefault(cap, []).append(m)
|
||||
# 索引 tags(如 tts / asr / voice_clone)——这些不在
|
||||
# resolved_capabilities() 中,但 AudioService 等模块依赖它们查找模型。
|
||||
for tag in m.tags:
|
||||
self._capability_index.setdefault(tag, []).append(m)
|
||||
|
||||
if self._default is None and models:
|
||||
self._default = models[0].name
|
||||
|
||||
def get(self, name: str) -> ModelConfig:
|
||||
model = self._models.get(name)
|
||||
if model is None:
|
||||
raise AIProviderError(
|
||||
message=f"Model '{name}' not found in registry",
|
||||
code="MODEL_NOT_FOUND",
|
||||
status_code=404,
|
||||
)
|
||||
return model
|
||||
|
||||
def resolve(self, name_or_alias: str | None = None) -> ModelConfig:
|
||||
"""解析逻辑名或别名为 ModelConfig。
|
||||
|
||||
无参数时返回默认模型。
|
||||
"""
|
||||
if name_or_alias is None:
|
||||
return self._models[self.get_default_name()]
|
||||
if name_or_alias in self._models:
|
||||
return self._models[name_or_alias]
|
||||
if name_or_alias in self._aliases:
|
||||
logical = self._aliases[name_or_alias]
|
||||
return self._models[logical]
|
||||
raise AIProviderError(
|
||||
message=f"Model or alias '{name_or_alias}' not found",
|
||||
code="MODEL_NOT_FOUND",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
def resolve_name(self, name_or_alias: str) -> str:
|
||||
"""解析为规范逻辑名(不返回完整配置)。"""
|
||||
if name_or_alias in self._models:
|
||||
return name_or_alias
|
||||
if name_or_alias in self._aliases:
|
||||
return self._aliases[name_or_alias]
|
||||
raise AIProviderError(
|
||||
message=f"Model or alias '{name_or_alias}' not found",
|
||||
code="MODEL_NOT_FOUND",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
def get_default_name(self) -> str:
|
||||
if self._default is None:
|
||||
raise AIProviderError(
|
||||
message="No default model configured",
|
||||
code="NO_DEFAULT_MODEL",
|
||||
status_code=500,
|
||||
)
|
||||
return self._default
|
||||
|
||||
def get_fallback_name(self) -> str | None:
|
||||
return self._fallback
|
||||
|
||||
def contains(self, name: str) -> bool:
|
||||
return name in self._models
|
||||
|
||||
def list_all(self) -> list[ModelConfig]:
|
||||
return list(self._models.values())
|
||||
|
||||
def list_by_capability(self, capability: str) -> list[ModelConfig]:
|
||||
return list(self._capability_index.get(capability, []))
|
||||
|
||||
def resolve_by_capability(self, capability: str) -> str:
|
||||
candidates = self._capability_index.get(capability)
|
||||
if not candidates:
|
||||
raise AIProviderError(
|
||||
message=f"No active models with capability '{capability}'",
|
||||
code="CAPABILITY_NOT_FOUND",
|
||||
status_code=404,
|
||||
)
|
||||
weights = [max(c.weight, 1) for c in candidates]
|
||||
chosen = random.choices(candidates, weights=weights, k=1)[0]
|
||||
return chosen.name
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
"""内存提供商配置索引。"""
|
||||
|
||||
def __init__(self, providers: dict[str, ProviderConfig]) -> None:
|
||||
self._providers = dict(providers)
|
||||
|
||||
def get(self, name: str) -> ProviderConfig:
|
||||
prov = self._providers.get(name)
|
||||
if prov is None:
|
||||
raise AIProviderError(
|
||||
message=f"Provider '{name}' not configured",
|
||||
code="PROVIDER_NOT_FOUND",
|
||||
status_code=404,
|
||||
)
|
||||
return prov
|
||||
|
||||
def list_all(self) -> dict[str, ProviderConfig]:
|
||||
return dict(self._providers)
|
||||
|
||||
|
||||
class RegistryFactory:
|
||||
"""工厂:从配置列表创建注册表。"""
|
||||
|
||||
@staticmethod
|
||||
def create(
|
||||
models: list[ModelConfig],
|
||||
providers: dict[str, ProviderConfig],
|
||||
aliases: dict[str, str] | None = None,
|
||||
pricing_data: dict[str, dict] | None = None,
|
||||
) -> RegistryHolder:
|
||||
return RegistryHolder(
|
||||
model_registry=ModelRegistry(models, aliases=aliases),
|
||||
provider_registry=ProviderRegistry(providers),
|
||||
pricing_registry=PricingFactory.from_dict(pricing_data or {}),
|
||||
)
|
||||
|
||||
|
||||
class RegistryHolder:
|
||||
"""同时持有所有注册表。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_registry: ModelRegistry,
|
||||
provider_registry: ProviderRegistry,
|
||||
pricing_registry: PricingRegistry | None = None,
|
||||
) -> None:
|
||||
self.model_registry = model_registry
|
||||
self.provider_registry = provider_registry
|
||||
self.pricing_registry = pricing_registry or PricingRegistry()
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CircuitBreakerState(Enum):
|
||||
CLOSED = "closed"
|
||||
OPEN = "open"
|
||||
HALF_OPEN = "half_open"
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""标准断路器,冷却后自动半开。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
failure_threshold: int = 5,
|
||||
cooldown_seconds: float = 30.0,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.failure_threshold = failure_threshold
|
||||
self.cooldown_seconds = cooldown_seconds
|
||||
self._state = CircuitBreakerState.CLOSED
|
||||
self._failure_count = 0
|
||||
self._last_failure_time: float = 0.0
|
||||
|
||||
@property
|
||||
def state(self) -> CircuitBreakerState:
|
||||
if (
|
||||
self._state == CircuitBreakerState.OPEN
|
||||
and time.monotonic() - self._last_failure_time >= self.cooldown_seconds
|
||||
):
|
||||
self._state = CircuitBreakerState.HALF_OPEN
|
||||
return self._state
|
||||
|
||||
def record_success(self) -> None:
|
||||
self._failure_count = 0
|
||||
self._state = CircuitBreakerState.CLOSED
|
||||
|
||||
def record_failure(self) -> None:
|
||||
self._failure_count += 1
|
||||
self._last_failure_time = time.monotonic()
|
||||
if self._failure_count >= self.failure_threshold:
|
||||
self._state = CircuitBreakerState.OPEN
|
||||
|
||||
def allow_request(self) -> bool:
|
||||
return self.state != CircuitBreakerState.OPEN
|
||||
|
||||
|
||||
class RetryStrategy:
|
||||
"""指数退避重试。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 60.0,
|
||||
multiplier: float = 2.0,
|
||||
) -> None:
|
||||
self.max_retries = max_retries
|
||||
self.base_delay = base_delay
|
||||
self.max_delay = max_delay
|
||||
self.multiplier = multiplier
|
||||
|
||||
def should_retry(self, attempt_index: int) -> bool:
|
||||
return attempt_index < self.max_retries
|
||||
|
||||
def get_delay(self, attempt_index: int) -> float:
|
||||
delay = self.base_delay * (self.multiplier**attempt_index)
|
||||
return min(delay, self.max_delay)
|
||||
|
||||
|
||||
class DowngradeAction:
|
||||
"""降级链中的一个步骤。"""
|
||||
|
||||
def __init__(self, type: str, target: str) -> None: # noqa: A002
|
||||
self.type = type # "fallback_endpoint", "fallback_model", "cache", "degrade_quality"
|
||||
self.target = target
|
||||
|
||||
|
||||
class DowngradeChain:
|
||||
"""当模型/端点失败时的降级动作链。"""
|
||||
|
||||
def __init__(self, actions: list[DowngradeAction]) -> None:
|
||||
self._actions = actions
|
||||
self._index = 0
|
||||
|
||||
def next_action(self) -> tuple[str, str] | None:
|
||||
if self._index >= len(self._actions):
|
||||
return None
|
||||
action = self._actions[self._index]
|
||||
return (action.type, action.target)
|
||||
|
||||
def advance(self) -> None:
|
||||
self._index += 1
|
||||
|
||||
def is_exhausted(self) -> bool:
|
||||
return self._index >= len(self._actions)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._index = 0
|
||||
|
||||
|
||||
class ResilienceFactory:
|
||||
"""弹性组件工厂。"""
|
||||
|
||||
@staticmethod
|
||||
def create_circuit_breaker(
|
||||
name: str,
|
||||
failure_threshold: int = 5,
|
||||
cooldown_seconds: float = 30.0,
|
||||
) -> CircuitBreaker:
|
||||
return CircuitBreaker(
|
||||
name=name,
|
||||
failure_threshold=failure_threshold,
|
||||
cooldown_seconds=cooldown_seconds,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_retry_strategy(
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 60.0,
|
||||
multiplier: float = 2.0,
|
||||
) -> RetryStrategy:
|
||||
return RetryStrategy(
|
||||
max_retries=max_retries,
|
||||
base_delay=base_delay,
|
||||
max_delay=max_delay,
|
||||
multiplier=multiplier,
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""嵌入服务 — Embedding 适配器外观。
|
||||
|
||||
用法::
|
||||
|
||||
from app.ai_provider.embedding import EmbeddingService
|
||||
|
||||
service = EmbeddingService(manager)
|
||||
result = await service.embed("要嵌入的文本")
|
||||
results = await service.embed(["文本1", "文本2"])
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.ai_provider.embedding.base import (
|
||||
EmbeddingProtocol,
|
||||
EmbeddingRequest,
|
||||
EmbeddingResponse,
|
||||
EmbeddingResult,
|
||||
EmbeddingServiceError,
|
||||
)
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
|
||||
|
||||
class EmbeddingService:
|
||||
"""外观类,负责解析 Embedding 模型并委托给 ProviderManager。
|
||||
|
||||
接受 ProviderManager 或实现了以下接口的适配器:
|
||||
- ``.model_registry`` → 具有 ``list_by_capability(capability)`` 方法
|
||||
- ``.create_embedding(name)`` → 返回 EmbeddingProtocol 实例
|
||||
"""
|
||||
|
||||
def __init__(self, manager) -> None:
|
||||
self._manager = manager
|
||||
self._adapter: EmbeddingProtocol | None = None
|
||||
|
||||
# ── Registry 兼容适配 ─────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def _registry(self):
|
||||
mgr = self._manager
|
||||
if hasattr(mgr, "model_registry"):
|
||||
return mgr.model_registry
|
||||
return mgr
|
||||
|
||||
@property
|
||||
def _factory(self):
|
||||
mgr = self._manager
|
||||
if hasattr(mgr, "create_embedding"):
|
||||
return mgr
|
||||
return None
|
||||
|
||||
# ── Embedding ──────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def adapter(self) -> EmbeddingProtocol:
|
||||
"""解析并缓存 Embedding 适配器。"""
|
||||
if self._adapter is None:
|
||||
self._adapter = self._resolve()
|
||||
return self._adapter
|
||||
|
||||
async def embed(
|
||||
self,
|
||||
input: str | list[str],
|
||||
model_hint: str | None = None,
|
||||
dimensions: int | None = None,
|
||||
) -> EmbeddingResponse:
|
||||
"""快捷方法:构造请求并调用 Embedding 适配器。"""
|
||||
model_name = self._resolve_model_name(model_hint)
|
||||
# 优先从模型配置读 dimensions
|
||||
effective_dimensions = dimensions
|
||||
if effective_dimensions is None:
|
||||
effective_dimensions = self._resolve_model_dimensions(model_hint)
|
||||
request = EmbeddingRequest(
|
||||
input=input if isinstance(input, list) else [input],
|
||||
model=model_name,
|
||||
dimensions=effective_dimensions,
|
||||
)
|
||||
return await self.adapter.embed(request)
|
||||
|
||||
# ── 解析 ───────────────────────────────────────────────────────
|
||||
|
||||
def _resolve(self) -> EmbeddingProtocol:
|
||||
"""查找第一个活跃的 Embedding 模型并通过工厂创建。"""
|
||||
models = self._list_capability_models("embedding")
|
||||
if not models:
|
||||
raise AIProviderError(
|
||||
status_code=503,
|
||||
code="embedding_no_model",
|
||||
message="No embedding model configured in the model registry.",
|
||||
)
|
||||
factory = self._factory
|
||||
if factory is None:
|
||||
raise AIProviderError(
|
||||
status_code=501,
|
||||
code="embedding_no_factory",
|
||||
message="Embedding adapter factory not available (provider manager missing create_embedding).",
|
||||
)
|
||||
return factory.create_embedding(models[0].name)
|
||||
|
||||
def _resolve_model_name(self, model_hint: str | None) -> str:
|
||||
models = self._list_capability_models("embedding")
|
||||
if model_hint:
|
||||
return model_hint
|
||||
if models:
|
||||
return models[0].name
|
||||
return ""
|
||||
|
||||
def _resolve_model_dimensions(self, model_hint: str | None) -> int | None:
|
||||
"""从模型配置读取向量维度。"""
|
||||
models = self._list_capability_models("embedding")
|
||||
if model_hint:
|
||||
for m in models:
|
||||
if m.name == model_hint:
|
||||
return m.dimensions
|
||||
if models:
|
||||
return models[0].dimensions
|
||||
return None
|
||||
|
||||
def _list_capability_models(self, tag: str):
|
||||
reg = self._registry
|
||||
if hasattr(reg, "list_capability_models"):
|
||||
return reg.list_capability_models(tag)
|
||||
if hasattr(reg, "list_by_capability"):
|
||||
return reg.list_by_capability(tag)
|
||||
return []
|
||||
|
||||
# ── 生命周期 ────────────────────────────────────────────────────
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._adapter is not None:
|
||||
close = getattr(self._adapter, "close", None)
|
||||
if close is not None:
|
||||
await close()
|
||||
|
||||
async def __aenter__(self) -> EmbeddingService:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.close()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EmbeddingProtocol",
|
||||
"EmbeddingRequest",
|
||||
"EmbeddingResponse",
|
||||
"EmbeddingResult",
|
||||
"EmbeddingService",
|
||||
"EmbeddingServiceError",
|
||||
]
|
||||
@@ -0,0 +1,144 @@
|
||||
"""嵌入服务类型 — 请求/响应模型和协议定义。
|
||||
|
||||
Embedding 适配器通过 HTTP 调用嵌入端点(通常是 OpenAI 兼容的 ``POST /v1/embeddings``),
|
||||
经由 ``EmbeddingService`` 或直接从 ``ProviderManager`` 消费。
|
||||
|
||||
用法::
|
||||
|
||||
service = EmbeddingService(manager)
|
||||
result = await service.embed("要嵌入的文本")
|
||||
|
||||
# 或通过工厂直接调用:
|
||||
adapter = manager.create_embedding("text-embedding-3-small")
|
||||
result = await adapter.embed(EmbeddingRequest(input=["文本"]))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.ai_provider.config.models import ModelConfig, ModelEndpointConfig
|
||||
|
||||
|
||||
class EmbeddingServiceError(AIProviderError):
|
||||
"""Embedding API 调用失败的基础错误。"""
|
||||
|
||||
|
||||
# ── 请求 / 响应 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmbeddingRequest:
|
||||
"""将文本转换为向量的请求。
|
||||
|
||||
属性:
|
||||
input: 待嵌入的文本字符串或字符串列表。
|
||||
model: 模型标识符(如 ``"text-embedding-3-small"``)。
|
||||
encoding_format: 返回编码格式(``"float"`` 或 ``"base64"``)。
|
||||
dimensions: 输出向量的维度(仅部分模型支持)。
|
||||
user: 终端用户标识(用于用量追踪)。
|
||||
"""
|
||||
|
||||
input: str | list[str]
|
||||
model: str = ""
|
||||
encoding_format: str = "float"
|
||||
dimensions: int | None = None
|
||||
user: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmbeddingResult:
|
||||
"""单条文本的嵌入结果。"""
|
||||
|
||||
embedding: list[float]
|
||||
index: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmbeddingResponse:
|
||||
"""嵌入 API 的完整响应。"""
|
||||
|
||||
data: list[EmbeddingResult]
|
||||
model: str = ""
|
||||
usage: dict[str, int] | None = None
|
||||
|
||||
|
||||
# ── Protocol ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmbeddingProtocol(Protocol):
|
||||
"""Embedding 适配器的协议。"""
|
||||
|
||||
async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
|
||||
"""将文本转换为向量。
|
||||
|
||||
异常:
|
||||
EmbeddingServiceError: API 调用失败或响应异常时抛出。
|
||||
"""
|
||||
...
|
||||
|
||||
async def close(self) -> None:
|
||||
"""释放适配器持有的 HTTP 连接。"""
|
||||
...
|
||||
|
||||
|
||||
# ── 共享工厂基类 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EmbeddingAdapterBase:
|
||||
"""Embedding 适配器基类,提供共享的工厂方法。
|
||||
|
||||
子类应在 ``__init__`` 中接受 ``base_url``、``api_key``、``model``、
|
||||
``request_timeout_seconds`` 参数。
|
||||
"""
|
||||
|
||||
_adapter_type: str = "embedding"
|
||||
_default_timeout: int = 60
|
||||
|
||||
@classmethod
|
||||
def from_endpoint(
|
||||
cls,
|
||||
endpoint: ModelEndpointConfig,
|
||||
*,
|
||||
timeout_seconds: int | None = None,
|
||||
) -> Any:
|
||||
"""从已解析的端点配置构建适配器。"""
|
||||
base_url = endpoint.base_url
|
||||
api_key = endpoint.api_key
|
||||
if not base_url:
|
||||
raise EmbeddingServiceError(
|
||||
status_code=500,
|
||||
code="embedding_endpoint_invalid",
|
||||
message="Embedding endpoint has no resolved base_url.",
|
||||
details={"endpoint": endpoint.name},
|
||||
)
|
||||
return cls(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
model=endpoint.model,
|
||||
request_timeout_seconds=timeout_seconds
|
||||
or endpoint.request_timeout_seconds
|
||||
or cls._default_timeout,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_model_config(
|
||||
cls,
|
||||
config: ModelConfig,
|
||||
*,
|
||||
timeout_seconds: int | None = None,
|
||||
) -> Any:
|
||||
"""从逻辑模型配置构建适配器(使用第一个活跃端点)。"""
|
||||
for ep in config.endpoints:
|
||||
if ep.status == "active":
|
||||
return cls.from_endpoint(ep, timeout_seconds=timeout_seconds)
|
||||
raise EmbeddingServiceError(
|
||||
status_code=500,
|
||||
code="embedding_no_endpoint",
|
||||
message=f"Embedding model '{config.name}' has no active endpoint.",
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""OpenAI 兼容格式的 Embedding 适配器 — ``POST /v1/embeddings``。
|
||||
|
||||
覆盖 OpenAI、DeepSeek、以及任何兼容 OpenAI 嵌入端点的提供商。
|
||||
|
||||
请求::
|
||||
|
||||
POST {base_url}/v1/embeddings
|
||||
Authorization: Bearer {api_key}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "text-embedding-3-small",
|
||||
"input": ["要嵌入的文本"],
|
||||
"encoding_format": "float"
|
||||
}
|
||||
|
||||
响应::
|
||||
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"object": "embedding", "index": 0, "embedding": [0.xxx, ...]}
|
||||
],
|
||||
"model": "text-embedding-3-small",
|
||||
"usage": {"prompt_tokens": 5, "total_tokens": 5}
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.ai_provider.embedding.base import (
|
||||
EmbeddingAdapterBase,
|
||||
EmbeddingProtocol,
|
||||
EmbeddingRequest,
|
||||
EmbeddingResponse,
|
||||
EmbeddingResult,
|
||||
EmbeddingServiceError,
|
||||
)
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
class OpenAIFormatEmbeddingAdapter(EmbeddingAdapterBase, EmbeddingProtocol):
|
||||
"""OpenAI 兼容嵌入适配器,覆盖 95%+ 的嵌入服务提供商。"""
|
||||
|
||||
_adapter_type = "embedding"
|
||||
_default_timeout = 60
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
model: str = "text-embedding-3-small",
|
||||
request_timeout_seconds: int = 60,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._model = model
|
||||
self._timeout = request_timeout_seconds
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
# ── 客户端 ───────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def _http(self) -> httpx.AsyncClient:
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
|
||||
# ── EmbeddingProtocol ────────────────────────────────────────────────
|
||||
|
||||
async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
|
||||
"""通过 ``POST /v1/embeddings`` 将文本转换为向量。"""
|
||||
url = f"{self._base_url}/v1/embeddings"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": request.model or self._model,
|
||||
"input": request.input,
|
||||
"encoding_format": request.encoding_format,
|
||||
}
|
||||
if request.dimensions is not None:
|
||||
payload["dimensions"] = request.dimensions
|
||||
if request.user is not None:
|
||||
payload["user"] = request.user
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if self._api_key:
|
||||
headers["Authorization"] = f"Bearer {self._api_key}"
|
||||
|
||||
logger.info(
|
||||
"embedding.request",
|
||||
model=payload["model"],
|
||||
input_count=len(request.input) if isinstance(request.input, list) else 1,
|
||||
dimensions=request.dimensions,
|
||||
url=url,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._http.post(url, json=payload, headers=headers)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise EmbeddingServiceError(
|
||||
status_code=504,
|
||||
code="embedding_timeout",
|
||||
message="Embedding request timed out.",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise EmbeddingServiceError(
|
||||
status_code=503,
|
||||
code="embedding_connection_error",
|
||||
message=f"Embedding connection error: {exc}",
|
||||
) from exc
|
||||
|
||||
if response.status_code != 200:
|
||||
body = response.text[:500]
|
||||
raise EmbeddingServiceError(
|
||||
status_code=response.status_code,
|
||||
code="embedding_api_error",
|
||||
message=f"Embedding API returned {response.status_code}: {body}",
|
||||
)
|
||||
|
||||
result = self._parse_response(response.json())
|
||||
|
||||
logger.info(
|
||||
"embedding.success",
|
||||
model=payload["model"],
|
||||
total_vectors=len(result.data),
|
||||
usage=result.usage,
|
||||
)
|
||||
return result
|
||||
|
||||
# ── 辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _parse_response(body: dict) -> EmbeddingResponse:
|
||||
"""从 OpenAI 格式的嵌入响应中提取结果。"""
|
||||
try:
|
||||
raw_data = body.get("data", [])
|
||||
results = []
|
||||
for item in raw_data:
|
||||
results.append(
|
||||
EmbeddingResult(
|
||||
embedding=item.get("embedding", []),
|
||||
index=item.get("index", 0),
|
||||
)
|
||||
)
|
||||
# 按 index 排序确保顺序
|
||||
results.sort(key=lambda r: r.index)
|
||||
|
||||
return EmbeddingResponse(
|
||||
data=results,
|
||||
model=body.get("model", ""),
|
||||
usage=body.get("usage"),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise EmbeddingServiceError(
|
||||
status_code=500,
|
||||
code="embedding_response_parse_error",
|
||||
message=f"Failed to parse embedding response: {exc}",
|
||||
) from exc
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.ai_provider.llm.anthropic_format import (
|
||||
AnthropicFormatAdapter,
|
||||
AnthropicFormatAdapterFactory,
|
||||
)
|
||||
from app.ai_provider.llm.openai_format import OpenAIFormatAdapter, OpenAIFormatAdapterFactory
|
||||
from app.ai_provider.llm.stream import StreamFactory, StreamHandler, ToolCallAccumulator
|
||||
|
||||
__all__ = [
|
||||
"AnthropicFormatAdapter",
|
||||
"AnthropicFormatAdapterFactory",
|
||||
"OpenAIFormatAdapter",
|
||||
"OpenAIFormatAdapterFactory",
|
||||
"StreamFactory",
|
||||
"StreamHandler",
|
||||
"ToolCallAccumulator",
|
||||
]
|
||||
@@ -0,0 +1,433 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig, ProviderConfig
|
||||
from app.ai_provider.pipeline import ParameterPipeline, PipelineContext, PipelineFactory
|
||||
from app.ai_provider.types.chat import ChatChunk, ChatResult, TokenUsage
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
from app.ai_provider.types.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
class AnthropicFormatAdapter:
|
||||
"""兼容 Anthropic Messages API 格式的适配器。
|
||||
|
||||
覆盖通过 Anthropic API 访问的 Claude 模型,以及任何镜像
|
||||
Anthropic 消息格式的提供商。
|
||||
"""
|
||||
|
||||
API_VERSION = "2023-06-01"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: ProviderConfig,
|
||||
pipeline: ParameterPipeline | None = None,
|
||||
) -> None:
|
||||
self._provider = provider_config
|
||||
self._pipeline = pipeline or PipelineFactory.create(provider_config)
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
@property
|
||||
def _http(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._provider.base_url or "",
|
||||
timeout=self._provider.timeout_seconds,
|
||||
)
|
||||
return self._client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:消息转换
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _convert_message(self, msg: BaseMessage) -> dict[str, Any]:
|
||||
"""将单条消息转换为 Anthropic 消息格式。
|
||||
|
||||
Anthropic 使用 content blocks 数组而非纯字符串。
|
||||
System 消息**不在此处处理** —— 它们由 :meth:`build_request`
|
||||
提取到顶层的 ``system`` 字段中。
|
||||
"""
|
||||
if isinstance(msg, ToolMessage):
|
||||
return {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": msg.tool_call_id,
|
||||
"content": msg.content,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
if isinstance(msg, AIMessage):
|
||||
if msg.raw_content_blocks:
|
||||
return {"role": "assistant", "content": msg.raw_content_blocks}
|
||||
blocks: list[dict[str, Any]] = []
|
||||
if msg.content:
|
||||
blocks.append({"type": "text", "text": msg.content})
|
||||
for tc in msg.tool_calls:
|
||||
blocks.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tc.get("id", f"tu_{tc['name']}"),
|
||||
"name": tc["name"],
|
||||
"input": tc.get("args", {}),
|
||||
}
|
||||
)
|
||||
return {"role": "assistant", "content": blocks}
|
||||
|
||||
# HumanMessage
|
||||
return {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": msg.content}],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""将 OpenAI 兼容格式的工具转换为 Anthropic 格式。
|
||||
|
||||
OpenAI 格式::
|
||||
|
||||
{"type": "function", "function": {"name": ..., "parameters": ...}}
|
||||
|
||||
Anthropic 格式::
|
||||
|
||||
{"name": ..., "input_schema": ...}
|
||||
"""
|
||||
result: list[dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
fn = tool.get("function", tool)
|
||||
result.append(
|
||||
{
|
||||
"name": fn.get("name", ""),
|
||||
"description": fn.get("description", ""),
|
||||
"input_schema": fn.get("parameters", {"type": "object"}),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 请求构建
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build_request(
|
||||
self,
|
||||
model: ModelConfig,
|
||||
messages: list[BaseMessage],
|
||||
params: dict[str, Any] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
stream: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
ctx = PipelineContext(
|
||||
params=params or {},
|
||||
model_config=model,
|
||||
provider_config=self._provider,
|
||||
)
|
||||
pipe_result = self._pipeline.run(ctx)
|
||||
|
||||
# Extract system messages — Anthropic uses a top-level "system" field
|
||||
system_parts: list[str] = []
|
||||
non_system: list[BaseMessage] = []
|
||||
for m in messages:
|
||||
if isinstance(m, SystemMessage):
|
||||
system_parts.append(m.content)
|
||||
else:
|
||||
non_system.append(m)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": model.model,
|
||||
"messages": [self._convert_message(m) for m in non_system],
|
||||
"stream": stream,
|
||||
}
|
||||
|
||||
if system_parts:
|
||||
body["system"] = "\n".join(system_parts)
|
||||
|
||||
body.update(pipe_result.params)
|
||||
|
||||
if tools:
|
||||
body["tools"] = self._convert_tools(tools)
|
||||
if tool_choice:
|
||||
body["tool_choice"] = tool_choice
|
||||
|
||||
# Anthropic requires explicit max_tokens
|
||||
if "max_tokens" not in body:
|
||||
body["max_tokens"] = model.max_output_tokens or 4096
|
||||
|
||||
return body
|
||||
|
||||
def bind_tools(
|
||||
self,
|
||||
request_body: dict[str, Any],
|
||||
tools: list[dict[str, Any]],
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
request_body["tools"] = self._convert_tools(tools)
|
||||
if tool_choice:
|
||||
request_body["tool_choice"] = tool_choice
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 认证头
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _headers(self, api_key: str) -> dict[str, str]:
|
||||
return {
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": self.API_VERSION,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 响应解析 —— 非流式
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_content_blocks(raw: dict[str, Any]) -> tuple[str, list[dict[str, Any]], str]:
|
||||
"""解析 Anthropic content blocks 为 (text, tool_calls, reasoning)。"""
|
||||
content_blocks = raw.get("content", [])
|
||||
text_parts: list[str] = []
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
reasoning = ""
|
||||
|
||||
for block in content_blocks:
|
||||
block_type = block.get("type", "")
|
||||
if block_type == "text":
|
||||
text_parts.append(block.get("text", ""))
|
||||
elif block_type == "tool_use":
|
||||
tool_calls.append(
|
||||
{
|
||||
"name": block.get("name", ""),
|
||||
"args": block.get("input", {}),
|
||||
"id": block.get("id", ""),
|
||||
}
|
||||
)
|
||||
elif block_type == "thinking":
|
||||
reasoning += block.get("thinking", "")
|
||||
# signature 块是 thinking 的加密签名,不是 reasoning 内容,忽略
|
||||
|
||||
return "".join(text_parts), tool_calls, reasoning
|
||||
|
||||
def parse_nonstreaming_response(
|
||||
self,
|
||||
raw: dict[str, Any],
|
||||
model_name: str,
|
||||
) -> ChatResult:
|
||||
content, tool_calls, reasoning = self._parse_content_blocks(raw)
|
||||
|
||||
result = ChatResult(
|
||||
content=content,
|
||||
model_name=model_name,
|
||||
)
|
||||
if tool_calls:
|
||||
result.tool_calls = tool_calls
|
||||
if reasoning:
|
||||
result.reasoning = reasoning
|
||||
|
||||
usage = raw.get("usage")
|
||||
if usage:
|
||||
result.usage = TokenUsage(
|
||||
prompt_tokens=usage.get("input_tokens", 0),
|
||||
prompt_tokens_cached=usage.get("cache_read_input_tokens", 0),
|
||||
completion_tokens=usage.get("output_tokens", 0),
|
||||
total_tokens=usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 响应解析 —— 流式(SSE 事件行协议)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_stream_event(event_type: str, data: dict[str, Any]) -> ChatChunk:
|
||||
"""解析单条 SSE 流事件为 ChatChunk。"""
|
||||
chunk = ChatChunk()
|
||||
|
||||
if event_type == "message_start":
|
||||
message = data.get("message", {})
|
||||
usage = message.get("usage")
|
||||
if usage:
|
||||
chunk.usage = TokenUsage(
|
||||
prompt_tokens=usage.get("input_tokens", 0),
|
||||
prompt_tokens_cached=usage.get("cache_read_input_tokens", 0),
|
||||
completion_tokens=usage.get("output_tokens", 0),
|
||||
)
|
||||
|
||||
elif event_type == "content_block_start":
|
||||
block = data.get("content_block", {})
|
||||
chunk.content_block_index = data.get("index", 0)
|
||||
chunk.content_block_start = block
|
||||
block_type = block.get("type", "")
|
||||
if block_type == "text":
|
||||
chunk.delta_content = block.get("text", "")
|
||||
elif block_type == "thinking":
|
||||
chunk.delta_reasoning = block.get("thinking", "")
|
||||
elif block_type == "tool_use":
|
||||
chunk.tool_call_chunks.append(
|
||||
{
|
||||
"index": data.get("index", 0),
|
||||
"id": block.get("id"),
|
||||
"name": block.get("name"),
|
||||
"args": "",
|
||||
}
|
||||
)
|
||||
|
||||
elif event_type == "content_block_delta":
|
||||
delta = data.get("delta", {})
|
||||
chunk.content_block_index = data.get("index", 0)
|
||||
chunk.content_block_delta = delta
|
||||
delta_type = delta.get("type", "")
|
||||
if delta_type == "text_delta":
|
||||
chunk.delta_content = delta.get("text", "")
|
||||
elif delta_type == "thinking_delta":
|
||||
chunk.delta_reasoning = delta.get("thinking", "")
|
||||
elif delta_type == "signature_delta":
|
||||
# signature 不展示,但由 Agent Runtime 原样保留供下一轮回放。
|
||||
pass
|
||||
elif delta_type == "input_json_delta":
|
||||
chunk.tool_call_chunks.append(
|
||||
{
|
||||
"index": data.get("index", 0),
|
||||
"id": None,
|
||||
"name": None,
|
||||
"args": delta.get("partial_json", ""),
|
||||
}
|
||||
)
|
||||
|
||||
elif event_type == "message_delta":
|
||||
delta = data.get("delta", {})
|
||||
finish_reason_map = {
|
||||
"end_turn": "stop",
|
||||
"max_tokens": "length",
|
||||
"stop_sequence": "stop",
|
||||
"tool_use": "tool_calls",
|
||||
}
|
||||
stop_reason = delta.get("stop_reason")
|
||||
if stop_reason:
|
||||
chunk.finish_reason = finish_reason_map.get(stop_reason, stop_reason)
|
||||
|
||||
usage = data.get("usage")
|
||||
if usage:
|
||||
chunk.usage = TokenUsage(
|
||||
prompt_tokens=0,
|
||||
completion_tokens=usage.get("output_tokens", 0),
|
||||
)
|
||||
|
||||
# content_block_stop, message_stop, ping — no content to extract
|
||||
|
||||
return chunk
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公开 API:chat
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
model: ModelConfig,
|
||||
messages: list[BaseMessage],
|
||||
api_key: str,
|
||||
base_url: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> ChatResult:
|
||||
url = f"{base_url or self._provider.base_url}/v1/messages"
|
||||
body = self.build_request(
|
||||
model,
|
||||
messages,
|
||||
params=params,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._http.post(url, json=body, headers=self._headers(api_key))
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return self.parse_nonstreaming_response(data, model.name)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise AIProviderError.from_provider(
|
||||
message=f"Anthropic API error: {exc.response.status_code} {exc.response.text[:200]}",
|
||||
code="PROVIDER_ERROR",
|
||||
provider=self._provider.name,
|
||||
original=exc,
|
||||
) from exc
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
model: ModelConfig,
|
||||
messages: list[BaseMessage],
|
||||
api_key: str,
|
||||
base_url: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> AsyncIterator[ChatChunk]:
|
||||
url = f"{base_url or self._provider.base_url}/v1/messages"
|
||||
body = self.build_request(
|
||||
model,
|
||||
messages,
|
||||
params=params,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
async with self._http.stream(
|
||||
"POST",
|
||||
url,
|
||||
json=body,
|
||||
headers=self._headers(api_key),
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
error_text = await response.aread()
|
||||
raise AIProviderError.from_provider(
|
||||
message=f"Anthropic stream error: {response.status_code} {error_text[:200]}",
|
||||
code="PROVIDER_ERROR",
|
||||
provider=self._provider.name,
|
||||
)
|
||||
|
||||
current_event = ""
|
||||
async for line in response.aiter_lines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("event: "):
|
||||
current_event = line[7:]
|
||||
elif line.startswith("data: "):
|
||||
data_str = line[6:]
|
||||
if data_str and current_event:
|
||||
try:
|
||||
raw = json.loads(data_str)
|
||||
yield self._parse_stream_event(current_event, raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"anthropic_stream_parse_error",
|
||||
event=current_event,
|
||||
raw=data_str[:100],
|
||||
)
|
||||
current_event = ""
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
|
||||
class AnthropicFormatAdapterFactory:
|
||||
"""工厂:创建 Anthropic 格式适配器。"""
|
||||
|
||||
@staticmethod
|
||||
def create(provider_config: ProviderConfig) -> AnthropicFormatAdapter:
|
||||
return AnthropicFormatAdapter(provider_config=provider_config)
|
||||
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig, ProviderConfig
|
||||
from app.ai_provider.pipeline import ParameterPipeline, PipelineContext, PipelineFactory
|
||||
from app.ai_provider.types.chat import ChatChunk, ChatResult, TokenUsage
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
from app.ai_provider.types.messages import AIMessage, BaseMessage, ToolMessage
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
class OpenAIFormatAdapter:
|
||||
"""兼容 OpenAI API 格式的适配器(覆盖 deepseek、xiaomi 等)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: ProviderConfig,
|
||||
pipeline: ParameterPipeline | None = None,
|
||||
) -> None:
|
||||
self._provider = provider_config
|
||||
self._pipeline = pipeline or PipelineFactory.create(provider_config)
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
@property
|
||||
def _http(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._provider.base_url or "",
|
||||
timeout=self._provider.timeout_seconds,
|
||||
)
|
||||
return self._client
|
||||
|
||||
def _convert_message(self, msg: BaseMessage) -> dict[str, Any]:
|
||||
if isinstance(msg, ToolMessage):
|
||||
return {
|
||||
"role": "tool",
|
||||
"content": msg.content,
|
||||
"tool_call_id": msg.tool_call_id,
|
||||
}
|
||||
if isinstance(msg, AIMessage):
|
||||
d: dict[str, Any] = {"role": "assistant", "content": msg.content}
|
||||
if msg.tool_calls:
|
||||
d["tool_calls"] = [
|
||||
{
|
||||
"id": tc.get("id", f"call_{i}"),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc["name"],
|
||||
"arguments": json.dumps(tc.get("args", {})),
|
||||
},
|
||||
}
|
||||
for i, tc in enumerate(msg.tool_calls)
|
||||
]
|
||||
return d
|
||||
return {"role": msg.role, "content": msg.content}
|
||||
|
||||
def build_request(
|
||||
self,
|
||||
model: ModelConfig,
|
||||
messages: list[BaseMessage],
|
||||
params: dict[str, Any] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
stream: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
ctx = PipelineContext(
|
||||
params=params or {},
|
||||
model_config=model,
|
||||
provider_config=self._provider,
|
||||
)
|
||||
pipe_result = self._pipeline.run(ctx)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": model.model,
|
||||
"messages": [self._convert_message(m) for m in messages],
|
||||
"stream": stream,
|
||||
}
|
||||
body.update(pipe_result.params)
|
||||
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
if tool_choice:
|
||||
body["tool_choice"] = tool_choice
|
||||
|
||||
return body
|
||||
|
||||
def bind_tools(
|
||||
self,
|
||||
request_body: dict[str, Any],
|
||||
tools: list[dict[str, Any]],
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
request_body["tools"] = tools
|
||||
if tool_choice:
|
||||
request_body["tool_choice"] = tool_choice
|
||||
|
||||
def parse_chunk(self, raw: dict[str, Any]) -> ChatChunk:
|
||||
chunk = ChatChunk()
|
||||
choices = raw.get("choices", [])
|
||||
if choices:
|
||||
delta = choices[0].get("delta", {})
|
||||
chunk.delta_content = delta.get("content") or ""
|
||||
chunk.delta_reasoning = delta.get("reasoning_content") or ""
|
||||
chunk.finish_reason = choices[0].get("finish_reason")
|
||||
|
||||
if delta.get("tool_calls"):
|
||||
for tc in delta["tool_calls"]:
|
||||
fn = tc.get("function", {})
|
||||
chunk.tool_call_chunks.append(
|
||||
{
|
||||
"index": tc.get("index", 0),
|
||||
"id": tc.get("id"),
|
||||
"name": fn.get("name"),
|
||||
"args": fn.get("arguments", ""),
|
||||
}
|
||||
)
|
||||
usage = raw.get("usage")
|
||||
if usage:
|
||||
chunk.usage = TokenUsage(
|
||||
prompt_tokens=usage.get("prompt_tokens", 0),
|
||||
completion_tokens=usage.get("completion_tokens", 0),
|
||||
total_tokens=usage.get("total_tokens", 0),
|
||||
prompt_tokens_cached=usage.get("prompt_tokens_cached", 0),
|
||||
)
|
||||
return chunk
|
||||
|
||||
def parse_nonstreaming_response(
|
||||
self,
|
||||
raw: dict[str, Any],
|
||||
model_name: str,
|
||||
) -> ChatResult:
|
||||
choice = raw.get("choices", [{}])[0]
|
||||
message = choice.get("message", {})
|
||||
content = message.get("content", "")
|
||||
tool_calls_raw = message.get("tool_calls", [])
|
||||
|
||||
result = ChatResult(
|
||||
content=content,
|
||||
reasoning=message.get("reasoning_content", ""),
|
||||
model_name=model_name,
|
||||
)
|
||||
if tool_calls_raw:
|
||||
result.tool_calls = [
|
||||
{
|
||||
"name": tc["function"]["name"],
|
||||
"args": json.loads(tc["function"].get("arguments", "{}")),
|
||||
"id": tc["id"],
|
||||
}
|
||||
for tc in tool_calls_raw
|
||||
]
|
||||
|
||||
usage = raw.get("usage")
|
||||
if usage:
|
||||
result.usage = TokenUsage(
|
||||
prompt_tokens=usage.get("prompt_tokens", 0),
|
||||
completion_tokens=usage.get("completion_tokens", 0),
|
||||
total_tokens=usage.get("total_tokens", 0),
|
||||
prompt_tokens_cached=usage.get("prompt_tokens_cached", 0),
|
||||
)
|
||||
return result
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
model: ModelConfig,
|
||||
messages: list[BaseMessage],
|
||||
api_key: str,
|
||||
base_url: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> ChatResult:
|
||||
url = f"{base_url or self._provider.base_url}/chat/completions"
|
||||
body = self.build_request(
|
||||
model, messages, params=params, tools=tools, tool_choice=tool_choice
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._http.post(url, json=body, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return self.parse_nonstreaming_response(data, model.name)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise AIProviderError.from_provider(
|
||||
message=f"OpenAI API error: {exc.response.status_code} {exc.response.text[:200]}",
|
||||
code="PROVIDER_ERROR",
|
||||
provider=self._provider.name,
|
||||
original=exc,
|
||||
) from exc
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
model: ModelConfig,
|
||||
messages: list[BaseMessage],
|
||||
api_key: str,
|
||||
base_url: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> AsyncIterator[ChatChunk]:
|
||||
url = f"{base_url or self._provider.base_url}/chat/completions"
|
||||
body = self.build_request(
|
||||
model, messages, params=params, tools=tools, tool_choice=tool_choice, stream=True
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async with self._http.stream("POST", url, json=body, headers=headers) as response:
|
||||
if response.status_code != 200:
|
||||
error_text = await response.aread()
|
||||
raise AIProviderError.from_provider(
|
||||
message=f"OpenAI stream error: {response.status_code} {error_text[:200]}",
|
||||
code="PROVIDER_ERROR",
|
||||
provider=self._provider.name,
|
||||
)
|
||||
async for line in response.aiter_lines():
|
||||
if line.startswith("data: "):
|
||||
data_str = line[6:].strip()
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
if data_str:
|
||||
try:
|
||||
raw = json.loads(data_str)
|
||||
yield self.parse_chunk(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("openai_stream_parse_error", raw=data_str[:100])
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
|
||||
class OpenAIFormatAdapterFactory:
|
||||
"""工厂:创建 OpenAI 格式适配器。"""
|
||||
|
||||
@staticmethod
|
||||
def create(provider_config: ProviderConfig) -> OpenAIFormatAdapter:
|
||||
return OpenAIFormatAdapter(provider_config=provider_config)
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
@dataclass
|
||||
class PoolContext:
|
||||
request_id: str = ""
|
||||
excluded_endpoints: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
class SchedulingStrategy(Protocol):
|
||||
def select(self, excluded: set[str]) -> dict[str, Any] | None: ...
|
||||
|
||||
|
||||
class WeightedRoundRobinStrategy:
|
||||
"""策略 1:在所有活跃端点间加权轮询。"""
|
||||
|
||||
def __init__(self, endpoints: list[dict[str, Any]]) -> None:
|
||||
self._endpoints = endpoints
|
||||
self._index = 0
|
||||
|
||||
def select(self, excluded: set[str]) -> dict[str, Any] | None:
|
||||
available = [
|
||||
e
|
||||
for e in self._endpoints
|
||||
if e.get("name") not in excluded and e.get("status", "active") == "active"
|
||||
]
|
||||
if not available:
|
||||
return None
|
||||
weights = [e.get("weight", 100) for e in available]
|
||||
self._index = (self._index + 1) % len(available)
|
||||
return random.choices(available, weights=weights, k=1)[0]
|
||||
|
||||
|
||||
class PriorityFailoverStrategy:
|
||||
"""策略 2:选择最高优先级;当排除时顺延。"""
|
||||
|
||||
def __init__(self, endpoints: list[dict[str, Any]]) -> None:
|
||||
self._endpoints = sorted(endpoints, key=lambda e: e.get("priority", 100))
|
||||
|
||||
def select(self, excluded: set[str]) -> dict[str, Any] | None:
|
||||
for ep in self._endpoints:
|
||||
if ep.get("name") not in excluded and ep.get("status", "active") == "active":
|
||||
return ep
|
||||
return None
|
||||
|
||||
|
||||
class LowestLatencyStrategy:
|
||||
"""策略 3:选择最近延迟最低的端点。"""
|
||||
|
||||
def __init__(self, endpoints: list[dict[str, Any]]) -> None:
|
||||
self._endpoints = endpoints
|
||||
|
||||
def select(self, excluded: set[str]) -> dict[str, Any] | None:
|
||||
available = [
|
||||
e
|
||||
for e in self._endpoints
|
||||
if e.get("name") not in excluded and e.get("status", "active") == "active"
|
||||
]
|
||||
if not available:
|
||||
return None
|
||||
return min(available, key=lambda e: e.get("latency_ms", 1000))
|
||||
|
||||
|
||||
class LeastConnectionsStrategy:
|
||||
"""策略 4:选择活跃连接数最少的端点。"""
|
||||
|
||||
def __init__(self, endpoints: list[dict[str, Any]]) -> None:
|
||||
self._endpoints = endpoints
|
||||
|
||||
def select(self, excluded: set[str]) -> dict[str, Any] | None:
|
||||
available = [
|
||||
e
|
||||
for e in self._endpoints
|
||||
if e.get("name") not in excluded and e.get("status", "active") == "active"
|
||||
]
|
||||
if not available:
|
||||
return None
|
||||
return min(available, key=lambda e: e.get("connections", 0))
|
||||
|
||||
|
||||
class CostAwareStrategy:
|
||||
"""策略 5:按每 token 成本选择最便宜的端点。"""
|
||||
|
||||
def __init__(
|
||||
self, endpoints: list[dict[str, Any]], get_cost: Callable[[str], float] | None = None
|
||||
) -> None:
|
||||
self._endpoints = endpoints
|
||||
self._get_cost = get_cost or (lambda _: 0.0)
|
||||
|
||||
def select(self, excluded: set[str]) -> dict[str, Any] | None:
|
||||
available = [
|
||||
e
|
||||
for e in self._endpoints
|
||||
if e.get("name") not in excluded and e.get("status", "active") == "active"
|
||||
]
|
||||
if not available:
|
||||
return None
|
||||
return min(available, key=lambda e: self._get_cost(e.get("name", "")))
|
||||
|
||||
|
||||
_STRATEGIES = {
|
||||
"weighted_round_robin": WeightedRoundRobinStrategy,
|
||||
"priority_failover": PriorityFailoverStrategy,
|
||||
"lowest_latency": LowestLatencyStrategy,
|
||||
"least_connections": LeastConnectionsStrategy,
|
||||
"cost_aware": CostAwareStrategy,
|
||||
}
|
||||
|
||||
|
||||
class EndpointPool:
|
||||
"""模型池,带可插拔的调度策略。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
logical_model_name: str,
|
||||
endpoints_configs: list[dict[str, Any]],
|
||||
strategy: str = "weighted_round_robin",
|
||||
) -> None:
|
||||
self.logical_model_name = logical_model_name
|
||||
self._endpoints = endpoints_configs
|
||||
strategy_cls = _STRATEGIES.get(strategy, WeightedRoundRobinStrategy)
|
||||
self.strategy = (
|
||||
strategy_cls(endpoints_configs)
|
||||
if strategy == "priority_failover"
|
||||
else strategy_cls(endpoints_configs)
|
||||
)
|
||||
|
||||
def select(self, excluded: set[str]) -> dict[str, Any] | None:
|
||||
"""使用配置的策略选择一个端点。"""
|
||||
return self.strategy.select(excluded)
|
||||
|
||||
def list_endpoints(self) -> list[dict[str, Any]]:
|
||||
return list(self._endpoints)
|
||||
|
||||
|
||||
class PoolFactory:
|
||||
"""工厂:创建端点池。"""
|
||||
|
||||
@staticmethod
|
||||
def create_pool(
|
||||
logical_model_name: str,
|
||||
endpoints: list[dict[str, Any]],
|
||||
strategy_name: str = "weighted_round_robin",
|
||||
) -> EndpointPool:
|
||||
return EndpointPool(
|
||||
logical_model_name=logical_model_name,
|
||||
endpoints_configs=endpoints,
|
||||
strategy=strategy_name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_strategy(
|
||||
name: str,
|
||||
endpoints: list[dict[str, Any]],
|
||||
) -> SchedulingStrategy:
|
||||
strategy_cls = _STRATEGIES.get(name)
|
||||
if strategy_cls is None:
|
||||
raise ValueError(f"Unknown scheduling strategy: {name}")
|
||||
return strategy_cls(endpoints)
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from app.ai_provider.types.chat import ChatChunk, ChatResult, TokenUsage
|
||||
|
||||
|
||||
class ToolCallAccumulator:
|
||||
"""将流式 tool call 分片累积为完整的 tool call。
|
||||
|
||||
处理流式场景中常见的多分片 tool call 参数,
|
||||
按索引拼接部分 JSON 字符串。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.content: str = ""
|
||||
self.reasoning: str = ""
|
||||
self._tool_call_slots: dict[int, dict[str, Any]] = defaultdict(
|
||||
lambda: {"index": 0, "id": "", "name": "", "args": ""}
|
||||
)
|
||||
self.usage: TokenUsage | None = None
|
||||
|
||||
def add_chunk(self, chunk: ChatChunk) -> None:
|
||||
"""处理一个 ChatChunk,累积 content/reasoning/tool-calls/usage。"""
|
||||
if chunk.delta_content:
|
||||
self.content += chunk.delta_content
|
||||
if chunk.delta_reasoning:
|
||||
self.reasoning += chunk.delta_reasoning
|
||||
if chunk.usage is not None:
|
||||
self.usage = chunk.usage
|
||||
for tc in chunk.tool_call_chunks:
|
||||
idx = tc.get("index", 0)
|
||||
if idx not in self._tool_call_slots:
|
||||
self._tool_call_slots[idx] = {"index": idx, "id": "", "name": "", "args": ""}
|
||||
slot = self._tool_call_slots[idx]
|
||||
# The first chunk for a given index carries id/name; subsequent
|
||||
# arg-only chunks have them as None and should NOT overwrite.
|
||||
if tc.get("id"):
|
||||
slot["id"] = tc["id"]
|
||||
if tc.get("name"):
|
||||
slot["name"] = tc["name"]
|
||||
slot["args"] += tc.get("args", "")
|
||||
|
||||
def drain(self) -> list[dict[str, Any]]:
|
||||
"""返回所有已累积的 tool call 并重置内部槽位。"""
|
||||
result: list[dict[str, Any]] = []
|
||||
for idx in sorted(self._tool_call_slots.keys()):
|
||||
slot = self._tool_call_slots[idx]
|
||||
result.append(
|
||||
{
|
||||
"index": slot["index"],
|
||||
"id": slot["id"] or f"call_{idx}",
|
||||
"name": slot["name"],
|
||||
"args": slot["args"],
|
||||
}
|
||||
)
|
||||
self._tool_call_slots.clear()
|
||||
return result
|
||||
|
||||
|
||||
class StreamHandler:
|
||||
"""收集流式分片并生成最终的 ChatResult。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._accumulator = ToolCallAccumulator()
|
||||
self._chunks: list[ChatChunk] = []
|
||||
|
||||
def add_chunk(self, chunk: ChatChunk) -> None:
|
||||
"""收集一个流式分片。"""
|
||||
self._chunks.append(chunk)
|
||||
self._accumulator.add_chunk(chunk)
|
||||
|
||||
@property
|
||||
def chunks(self) -> list[ChatChunk]:
|
||||
"""返回所有已收集分片的副本。"""
|
||||
return list(self._chunks)
|
||||
|
||||
def to_result(self, model_name: str = "") -> ChatResult:
|
||||
"""从所有累积分片构建最终的 ChatResult。"""
|
||||
raw_tool_calls = self._accumulator.drain()
|
||||
parsed_calls: list[dict[str, Any]] = []
|
||||
for tc in raw_tool_calls:
|
||||
args_raw = tc.get("args", "{}")
|
||||
try:
|
||||
parsed_args = json.loads(args_raw)
|
||||
except json.JSONDecodeError:
|
||||
parsed_args = {"_raw": args_raw}
|
||||
parsed_calls.append(
|
||||
{
|
||||
"name": tc["name"],
|
||||
"args": parsed_args,
|
||||
"id": tc["id"],
|
||||
}
|
||||
)
|
||||
return ChatResult(
|
||||
content=self._accumulator.content,
|
||||
reasoning=self._accumulator.reasoning,
|
||||
tool_calls=parsed_calls,
|
||||
usage=self._accumulator.usage,
|
||||
model_name=model_name,
|
||||
)
|
||||
|
||||
|
||||
class StreamFactory:
|
||||
"""流处理器和累积器的工厂。"""
|
||||
|
||||
@staticmethod
|
||||
def create_handler() -> StreamHandler:
|
||||
return StreamHandler()
|
||||
|
||||
@staticmethod
|
||||
def create_accumulator() -> ToolCallAccumulator:
|
||||
return ToolCallAccumulator()
|
||||
@@ -0,0 +1,463 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.ai_provider.config.loader import (
|
||||
load_models_config,
|
||||
load_pricing_config,
|
||||
load_providers_config,
|
||||
)
|
||||
from app.ai_provider.config.models import ModelConfig, ProviderConfig
|
||||
from app.ai_provider.core.billing import (
|
||||
BillingFactory,
|
||||
BillingTracker,
|
||||
BudgetController,
|
||||
PricingInfo,
|
||||
PricingRegistry,
|
||||
)
|
||||
from app.ai_provider.core.registry import ModelRegistry, ProviderRegistry
|
||||
from app.ai_provider.core.resilience import CircuitBreaker
|
||||
from app.ai_provider.llm.anthropic_format import (
|
||||
AnthropicFormatAdapter,
|
||||
AnthropicFormatAdapterFactory,
|
||||
)
|
||||
from app.ai_provider.llm.openai_format import OpenAIFormatAdapter, OpenAIFormatAdapterFactory
|
||||
from app.ai_provider.llm.stream import StreamHandler
|
||||
from app.ai_provider.routing import (
|
||||
CapabilityPermissionController,
|
||||
ModelSelector,
|
||||
MultiModalRouter,
|
||||
)
|
||||
from app.ai_provider.types.chat import ChatResult, TokenUsage
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
from app.ai_provider.types.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
)
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ResolvedEndpoint:
|
||||
"""端点到适配器和凭证的解析结果。"""
|
||||
|
||||
adapter: OpenAIFormatAdapter | AnthropicFormatAdapter
|
||||
model: ModelConfig
|
||||
provider: ProviderConfig
|
||||
api_key: str
|
||||
base_url: str | None
|
||||
breaker: CircuitBreaker | None = None
|
||||
|
||||
|
||||
class ProviderManager:
|
||||
"""所有 AI 提供商操作的统一入口。
|
||||
|
||||
取代旧的 ModelRouter + get_model_router() 模式。
|
||||
组合了注册表、适配器、池、计费、弹性和路由。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_registry: ModelRegistry,
|
||||
provider_registry: ProviderRegistry,
|
||||
billing_tracker: BillingTracker | None = None,
|
||||
budget_controller: BudgetController | None = None,
|
||||
pricing_registry: PricingRegistry | None = None,
|
||||
) -> None:
|
||||
self.model_registry = model_registry
|
||||
self.provider_registry = provider_registry
|
||||
self._billing = billing_tracker or BillingFactory.create_tracker()
|
||||
self._budget = budget_controller
|
||||
self._pricing_registry = pricing_registry or PricingRegistry()
|
||||
self._adapter_cache: dict[str, OpenAIFormatAdapter | AnthropicFormatAdapter] = {}
|
||||
self._model_selector = ModelSelector(model_registry)
|
||||
self._multi_modal_router = MultiModalRouter()
|
||||
self._permission_controller = CapabilityPermissionController()
|
||||
|
||||
# ── 模型解析 ──────────────────────────────────────────────
|
||||
|
||||
def resolve_model(self, model_hint: str | None = None) -> str:
|
||||
if model_hint is None:
|
||||
return self.model_registry.get_default_name()
|
||||
return self.model_registry.resolve_name(model_hint)
|
||||
|
||||
def get_model_config(self, name: str) -> ModelConfig:
|
||||
return self.model_registry.get(name)
|
||||
|
||||
def get_provider_config(self, name: str) -> ProviderConfig:
|
||||
return self.provider_registry.get(name)
|
||||
|
||||
# ── 适配器解析 ────────────────────────────────────────────
|
||||
|
||||
def get_adapter(self, model_name: str) -> OpenAIFormatAdapter | AnthropicFormatAdapter:
|
||||
if model_name in self._adapter_cache:
|
||||
return self._adapter_cache[model_name]
|
||||
|
||||
model = self.model_registry.get(model_name)
|
||||
provider = self.provider_registry.get(model.provider)
|
||||
|
||||
if model.format.value == "openai":
|
||||
adapter = OpenAIFormatAdapterFactory.create(provider)
|
||||
else:
|
||||
adapter = AnthropicFormatAdapterFactory.create(provider)
|
||||
|
||||
self._adapter_cache[model_name] = adapter
|
||||
return adapter
|
||||
|
||||
# ── 消息辅助 ───────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def to_message_list(
|
||||
system_messages: list[str],
|
||||
history: list[tuple[str, str]],
|
||||
) -> list[BaseMessage]:
|
||||
messages: list[BaseMessage] = []
|
||||
for msg in system_messages:
|
||||
if msg:
|
||||
messages.append(SystemMessage(content=msg))
|
||||
for role, content in history:
|
||||
if role == "system":
|
||||
messages.append(SystemMessage(content=content))
|
||||
elif role == "user":
|
||||
messages.append(HumanMessage(content=content))
|
||||
elif role == "assistant":
|
||||
messages.append(AIMessage(content=content))
|
||||
return messages
|
||||
|
||||
# ── Embedding ──────────────────────────────────────────────────────
|
||||
|
||||
def create_embedding(self, model_name: str) -> Any:
|
||||
"""按模型名创建 Embedding 适配器。
|
||||
|
||||
优先读取 model endpoint 级 base_url/api_key 覆盖;无 endpoint 时
|
||||
回退 provider 级凭证。
|
||||
"""
|
||||
from app.ai_provider.embedding.openai_format import OpenAIFormatEmbeddingAdapter
|
||||
|
||||
model = self.model_registry.get(model_name)
|
||||
provider = self.provider_registry.get(model.provider)
|
||||
|
||||
# 尝试从 model endpoint 读取 base_url / api_key 覆盖
|
||||
base_url: str | None = None
|
||||
api_key: str = ""
|
||||
if model.endpoints and len(model.endpoints) > 0:
|
||||
ep = model.endpoints[0] # 取第一个活跃 endpoint
|
||||
if ep.base_url:
|
||||
base_url = ep.base_url
|
||||
elif ep.base_url_env and hasattr(settings, ep.base_url_env.lower()):
|
||||
base_url = getattr(settings, ep.base_url_env.lower())
|
||||
if ep.api_key:
|
||||
api_key = ep.api_key
|
||||
elif ep.api_key_env and hasattr(settings, ep.api_key_env.lower()):
|
||||
api_key = getattr(settings, ep.api_key_env.lower(), "")
|
||||
|
||||
if not base_url:
|
||||
_, base_url = self._resolve_credentials(provider)
|
||||
if not base_url:
|
||||
base_url = provider.base_url
|
||||
if not api_key:
|
||||
api_key, _ = self._resolve_credentials(provider)
|
||||
|
||||
return OpenAIFormatEmbeddingAdapter(
|
||||
base_url=base_url or "",
|
||||
api_key=api_key or "",
|
||||
model=model.model,
|
||||
request_timeout_seconds=model.timeout_seconds,
|
||||
)
|
||||
|
||||
# ── Reranker ───────────────────────────────────────────────────────
|
||||
|
||||
def create_reranker(self, model_name: str) -> Any:
|
||||
"""按模型名创建 Reranker 适配器。"""
|
||||
from app.ai_provider.reranker.cohere_format import CohereFormatRerankerAdapter
|
||||
|
||||
model = self.model_registry.get(model_name)
|
||||
provider = self.provider_registry.get(model.provider)
|
||||
api_key, base_url = self._resolve_credentials(provider)
|
||||
if not base_url:
|
||||
base_url = provider.base_url
|
||||
return CohereFormatRerankerAdapter(
|
||||
base_url=base_url or "",
|
||||
api_key=api_key,
|
||||
model=model.model,
|
||||
request_timeout_seconds=model.timeout_seconds,
|
||||
)
|
||||
|
||||
# ── 对话 ──────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_endpoint(self, model_name: str) -> _ResolvedEndpoint:
|
||||
"""解析模型名→(适配器, 凭证, 熔断器)。
|
||||
|
||||
处理池选择、熔断初始化、凭证回退。
|
||||
"""
|
||||
model = self.model_registry.get(model_name)
|
||||
provider = self.provider_registry.get(model.provider)
|
||||
adapter = self.get_adapter(model_name)
|
||||
|
||||
breaker: CircuitBreaker | None = None
|
||||
|
||||
if model.endpoint_pool_enabled and model.endpoints:
|
||||
from app.ai_provider.llm.pool import PoolFactory
|
||||
|
||||
pool = PoolFactory.create_pool(
|
||||
logical_model_name=model_name,
|
||||
endpoints=[ep.model_dump(exclude_none=True) for ep in model.endpoints],
|
||||
strategy_name=model.routing_strategy,
|
||||
)
|
||||
selected = pool.select(set())
|
||||
if selected is not None:
|
||||
api_key = selected.get("api_key") or (
|
||||
settings.__getattribute__(provider.api_key_env.lower())
|
||||
if hasattr(settings, provider.api_key_env.lower())
|
||||
else ""
|
||||
)
|
||||
base_url_env = selected.get("base_url_env") or provider.base_url_env
|
||||
base_url = selected.get("base_url") or (
|
||||
settings.__getattribute__(base_url_env.lower())
|
||||
if hasattr(settings, base_url_env.lower())
|
||||
else None
|
||||
)
|
||||
# 熔断器
|
||||
from app.ai_provider.core.resilience import ResilienceFactory
|
||||
|
||||
breaker = ResilienceFactory.create_circuit_breaker(
|
||||
name=f"{model_name}@{selected.get('name', 'default')}",
|
||||
failure_threshold=3,
|
||||
cooldown_seconds=30,
|
||||
)
|
||||
if not breaker.allow_request():
|
||||
raise AIProviderError(
|
||||
message=f"Circuit breaker open for {selected.get('name', 'unknown')}",
|
||||
code="CIRCUIT_BREAKER_OPEN",
|
||||
status_code=503,
|
||||
)
|
||||
return _ResolvedEndpoint(
|
||||
adapter=adapter,
|
||||
model=model,
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
breaker=breaker,
|
||||
)
|
||||
# 无活跃 endpoint,回退 provider 凭证(无熔断)
|
||||
api_key, base_url = self._resolve_credentials(provider)
|
||||
return _ResolvedEndpoint(
|
||||
adapter=adapter,
|
||||
model=model,
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
# 无池,直接走 provider 凭证
|
||||
api_key, base_url = self._resolve_credentials(provider)
|
||||
return _ResolvedEndpoint(
|
||||
adapter=adapter,
|
||||
model=model,
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
model_hint: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> ChatResult:
|
||||
model_name = self.resolve_model(model_hint)
|
||||
if self._budget and not self._budget.allow_request():
|
||||
raise AIProviderError(
|
||||
message="Budget limit exceeded", code="BUDGET_EXCEEDED", status_code=429
|
||||
)
|
||||
|
||||
ep = self._resolve_endpoint(model_name)
|
||||
|
||||
try:
|
||||
result = await ep.adapter.chat(
|
||||
model=ep.model,
|
||||
messages=messages,
|
||||
api_key=ep.api_key,
|
||||
base_url=ep.base_url,
|
||||
params=params,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
)
|
||||
if ep.breaker:
|
||||
ep.breaker.record_success()
|
||||
except Exception:
|
||||
if ep.breaker:
|
||||
ep.breaker.record_failure()
|
||||
raise
|
||||
|
||||
self._record_usage(
|
||||
model_name, ep.provider.name, result.usage, self._pricing_registry.get(model_name)
|
||||
)
|
||||
return result
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
model_hint: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
):
|
||||
model_name = self.resolve_model(model_hint)
|
||||
if self._budget and not self._budget.allow_request():
|
||||
raise AIProviderError(
|
||||
message="Budget limit exceeded", code="BUDGET_EXCEEDED", status_code=429
|
||||
)
|
||||
|
||||
ep = self._resolve_endpoint(model_name)
|
||||
|
||||
try:
|
||||
stream = ep.adapter.chat_stream(
|
||||
model=ep.model,
|
||||
messages=messages,
|
||||
api_key=ep.api_key,
|
||||
base_url=ep.base_url,
|
||||
params=params,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
)
|
||||
handler = StreamHandler()
|
||||
async for chunk in stream:
|
||||
handler.add_chunk(chunk)
|
||||
yield chunk
|
||||
|
||||
if ep.breaker:
|
||||
ep.breaker.record_success()
|
||||
except Exception:
|
||||
if ep.breaker:
|
||||
ep.breaker.record_failure()
|
||||
raise
|
||||
|
||||
result = handler.to_result(model_name=model_name)
|
||||
self._record_usage(
|
||||
model_name, ep.provider.name, result.usage, self._pricing_registry.get(model_name)
|
||||
)
|
||||
|
||||
def _resolve_credentials(self, provider: ProviderConfig) -> tuple[str, str | None]:
|
||||
"""从 settings 解析 provider 级别的 API key 和 base URL。"""
|
||||
api_key = (
|
||||
settings.__getattribute__(provider.api_key_env.lower())
|
||||
if hasattr(settings, provider.api_key_env.lower())
|
||||
else ""
|
||||
)
|
||||
base_url = (
|
||||
settings.__getattribute__(provider.base_url_env.lower())
|
||||
if hasattr(settings, provider.base_url_env.lower())
|
||||
else None
|
||||
)
|
||||
return api_key, base_url
|
||||
|
||||
# ── 路由辅助 ───────────────────────────────────────────────
|
||||
|
||||
def select_model_for_context(
|
||||
self,
|
||||
needs_vision: bool = False,
|
||||
needs_thinking: bool = False,
|
||||
) -> str:
|
||||
capabilities: set[str] = set()
|
||||
if needs_vision:
|
||||
capabilities.add("vision")
|
||||
if needs_thinking:
|
||||
capabilities.add("thinking")
|
||||
model = self._model_selector.select({"capabilities": capabilities})
|
||||
return model.name
|
||||
|
||||
def get_adapter_for_tool_binding(
|
||||
self,
|
||||
model_name: str,
|
||||
tools: list[dict[str, Any]],
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
):
|
||||
"""返回 (adapter, request_body_base) 用于工具绑定。
|
||||
|
||||
为 Agent 运行时保留独立的 bind_tools 步骤。
|
||||
"""
|
||||
adapter = self.get_adapter(model_name)
|
||||
body = {"model": self.model_registry.get(model_name).model}
|
||||
adapter.bind_tools(body, tools, tool_choice=tool_choice)
|
||||
return adapter, body
|
||||
|
||||
# ── 用量与计费 ───────────────────────────────────────────────
|
||||
|
||||
def _record_usage(
|
||||
self,
|
||||
model_name: str,
|
||||
provider: str,
|
||||
usage: TokenUsage | None,
|
||||
pricing: PricingInfo | None = None,
|
||||
) -> None:
|
||||
record = self._billing.record(
|
||||
model_name=model_name,
|
||||
provider=provider,
|
||||
usage=usage,
|
||||
pricing=pricing,
|
||||
)
|
||||
if self._budget and record.cost > 0:
|
||||
self._budget.add_cost_cny(
|
||||
record.cost if record.currency == "CNY" else record.cost * 7.3
|
||||
)
|
||||
|
||||
def get_usage_records(self, **kwargs) -> list:
|
||||
return self._billing.get_records(**kwargs)
|
||||
|
||||
def get_budget_status(self) -> dict[str, bool]:
|
||||
if self._budget:
|
||||
return {"within_daily": self._budget.allow_request()}
|
||||
return {"within_daily": True}
|
||||
|
||||
# ── 生命周期 ─────────────────────────────────────────────────────
|
||||
|
||||
async def close(self) -> None:
|
||||
for adapter in self._adapter_cache.values():
|
||||
await adapter.close()
|
||||
|
||||
async def __aenter__(self) -> ProviderManager:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.close()
|
||||
|
||||
|
||||
class ProviderManagerFactory:
|
||||
"""工厂:从配置文件创建 ProviderManager。"""
|
||||
|
||||
@staticmethod
|
||||
def create(
|
||||
models_path: str | None = None,
|
||||
providers_path: str | None = None,
|
||||
aliases: dict[str, str] | None = None,
|
||||
daily_budget_cny: float = 0.0,
|
||||
) -> ProviderManager:
|
||||
from pathlib import Path
|
||||
|
||||
models = load_models_config(Path(models_path) if models_path else None)
|
||||
providers = load_providers_config(Path(providers_path) if providers_path else None)
|
||||
pricing_data = load_pricing_config()
|
||||
from app.ai_provider.core.registry import RegistryFactory
|
||||
|
||||
registries = RegistryFactory.create(
|
||||
models, providers, aliases=aliases, pricing_data=pricing_data
|
||||
)
|
||||
billing = BillingFactory.create_tracker()
|
||||
budget = (
|
||||
BillingFactory.create_budget_controller(daily_budget_cny=daily_budget_cny)
|
||||
if daily_budget_cny > 0
|
||||
else None
|
||||
)
|
||||
return ProviderManager(
|
||||
model_registry=registries.model_registry,
|
||||
provider_registry=registries.provider_registry,
|
||||
billing_tracker=billing,
|
||||
budget_controller=budget,
|
||||
pricing_registry=registries.pricing_registry,
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""配置驱动的 AI 提供商五阶段参数管道。
|
||||
|
||||
根据 ProviderConfig.parameter_mappings 在分发前对参数进行
|
||||
转换、归一化、过滤和注入。第五阶段 HandlerStep 处理跨参数
|
||||
依赖等复杂场景。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig, ProviderConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineContext:
|
||||
"""管道的输入上下文。"""
|
||||
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
model_config: ModelConfig | None = None
|
||||
provider_config: ProviderConfig | None = None
|
||||
messages: list[dict[str, Any]] | None = None
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineResult:
|
||||
"""经过管道处理后的结果。"""
|
||||
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
filtered_params: dict[str, Any] = field(default_factory=dict)
|
||||
messages: list[dict[str, Any]] | None = None
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class PipelineStep:
|
||||
"""管道步骤的基类。"""
|
||||
|
||||
def apply(self, ctx: PipelineContext, result: PipelineResult) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class NormalizeStep(PipelineStep):
|
||||
"""步骤 1:归一化参数范围(夹紧到 min/max)。"""
|
||||
|
||||
def apply(self, ctx: PipelineContext, result: PipelineResult) -> None:
|
||||
mappings = ctx.provider_config.parameter_mappings if ctx.provider_config else {}
|
||||
for key, value in ctx.params.items():
|
||||
mapping = mappings.get(key)
|
||||
if mapping:
|
||||
if mapping.min is not None:
|
||||
value = max(mapping.min, value)
|
||||
if mapping.max is not None:
|
||||
value = min(mapping.max, value)
|
||||
result.params[key] = value
|
||||
else:
|
||||
result.params[key] = value
|
||||
|
||||
|
||||
class FilterStep(PipelineStep):
|
||||
"""步骤 2:过滤模型/提供商不支持的参数。"""
|
||||
|
||||
def apply(self, ctx: PipelineContext, result: PipelineResult) -> None:
|
||||
allowed = (
|
||||
set(ctx.provider_config.parameter_mappings.keys())
|
||||
if ctx.provider_config
|
||||
else set(result.params.keys())
|
||||
)
|
||||
filtered = {}
|
||||
for key in list(result.params.keys()):
|
||||
if key not in allowed:
|
||||
filtered[key] = result.params.pop(key)
|
||||
result.filtered_params = filtered
|
||||
|
||||
|
||||
class TransformStep(PipelineStep):
|
||||
"""步骤 3:转换参数名称和值为提供商所需格式。"""
|
||||
|
||||
def apply(self, ctx: PipelineContext, result: PipelineResult) -> None:
|
||||
mappings = ctx.provider_config.parameter_mappings if ctx.provider_config else {}
|
||||
remapped: dict[str, Any] = {}
|
||||
for key, value in result.params.items():
|
||||
mapping = mappings.get(key)
|
||||
if mapping:
|
||||
provider_key = mapping.provider_param
|
||||
if mapping.transform == "int":
|
||||
value = int(value)
|
||||
elif mapping.transform == "str":
|
||||
value = str(value)
|
||||
elif mapping.transform == "multiply_1000":
|
||||
value = int(value * 1000)
|
||||
remapped[provider_key] = value
|
||||
else:
|
||||
remapped[key] = value
|
||||
result.params = remapped
|
||||
|
||||
|
||||
class InjectStep(PipelineStep):
|
||||
"""步骤 4:为缺失参数注入提供商默认值。"""
|
||||
|
||||
def apply(self, ctx: PipelineContext, result: PipelineResult) -> None:
|
||||
mappings = ctx.provider_config.parameter_mappings if ctx.provider_config else {}
|
||||
for mapping in mappings.values():
|
||||
provider_key = mapping.provider_param
|
||||
if provider_key not in result.params and mapping.default is not None:
|
||||
result.params[provider_key] = mapping.default
|
||||
|
||||
|
||||
class HandlerStep(PipelineStep):
|
||||
"""步骤 5:执行已注册的参数处理器。
|
||||
|
||||
处理 YAML 映射规则无法表达的跨参数依赖和复杂变换:
|
||||
- Anthropic thinking 参数要求 max_tokens > budget_tokens
|
||||
- 根据模型能力过滤不支持的参数
|
||||
- 结构性参数重组
|
||||
"""
|
||||
|
||||
def apply(self, ctx: PipelineContext, result: PipelineResult) -> None:
|
||||
if not ctx.provider_config:
|
||||
return
|
||||
from app.ai_provider.pipeline_handlers import ParameterHandlerRegistry
|
||||
|
||||
registry = ParameterHandlerRegistry
|
||||
|
||||
# 阶段 A:逐参数处理器(来自 ParameterMapping.handler)
|
||||
for key, mapping in ctx.provider_config.parameter_mappings.items():
|
||||
if mapping.handler:
|
||||
handler = registry.get(mapping.handler)
|
||||
handler(ctx, result, key) # key is the param key
|
||||
|
||||
# 阶段 B:全局处理器(来自 ProviderConfig.parameter_handlers)
|
||||
for handler_name in ctx.provider_config.parameter_handlers:
|
||||
handler = registry.get(handler_name)
|
||||
handler(ctx, result, None) # global handler: no param_key
|
||||
|
||||
|
||||
class ParameterPipeline:
|
||||
"""配置驱动的五阶段参数管道。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
steps: list[PipelineStep],
|
||||
provider_config: ProviderConfig | None = None,
|
||||
) -> None:
|
||||
self._steps = steps
|
||||
self._provider_config = provider_config
|
||||
|
||||
def run(self, ctx: PipelineContext) -> PipelineResult:
|
||||
if ctx.provider_config is None and self._provider_config is not None:
|
||||
ctx.provider_config = self._provider_config
|
||||
result = PipelineResult(params=dict(ctx.params))
|
||||
for step in self._steps:
|
||||
step.apply(ctx, result)
|
||||
return result
|
||||
|
||||
|
||||
class PipelineFactory:
|
||||
"""工厂:从提供商配置构建管道。"""
|
||||
|
||||
@staticmethod
|
||||
def create(provider_config: ProviderConfig) -> ParameterPipeline:
|
||||
return ParameterPipeline(
|
||||
provider_config=provider_config,
|
||||
steps=[
|
||||
NormalizeStep(),
|
||||
FilterStep(),
|
||||
TransformStep(),
|
||||
InjectStep(),
|
||||
HandlerStep(),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""参数处理器注册中心与内置 handlers。
|
||||
|
||||
处理跨参数依赖、结构性转换等复杂场景——这些无法通过
|
||||
YAML ParameterMapping 的 min/max/transform/default 表达。
|
||||
|
||||
使用方式(在 providers.yaml 中):
|
||||
```yaml
|
||||
parameter_mappings:
|
||||
thinking: { provider_param: thinking, handler: anthropic_thinking }
|
||||
|
||||
parameter_handlers:
|
||||
- strip_unsupported_capability_params
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.ai_provider.pipeline import PipelineContext, PipelineResult
|
||||
|
||||
# Type alias for a parameter handler function.
|
||||
# param_key is passed for per-param handlers (from ParameterMapping.handler);
|
||||
# for global handlers (from ProviderConfig.parameter_handlers), param_key is None.
|
||||
ParameterHandler = Callable[[PipelineContext, PipelineResult, str | None], None]
|
||||
|
||||
|
||||
class ParameterHandlerRegistry:
|
||||
"""全局参数处理器注册中心。
|
||||
|
||||
使用 register() 装饰器注册 handler,名称字符串即可在 YAML 中引用。
|
||||
"""
|
||||
|
||||
_handlers: dict[str, ParameterHandler] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, name: str) -> Callable[[ParameterHandler], ParameterHandler]:
|
||||
"""装饰器:将函数注册为命名 handler。"""
|
||||
|
||||
def decorator(handler: ParameterHandler) -> ParameterHandler:
|
||||
cls._handlers[name] = handler
|
||||
return handler
|
||||
|
||||
return decorator
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str) -> ParameterHandler:
|
||||
"""按名称获取 handler。未找到时抛出 KeyError。"""
|
||||
if name not in cls._handlers:
|
||||
msg = f"Unknown parameter handler: '{name}'. "
|
||||
msg += f"Available: {', '.join(sorted(cls._handlers))}"
|
||||
raise KeyError(msg)
|
||||
return cls._handlers[name]
|
||||
|
||||
@classmethod
|
||||
def list(cls) -> list[str]:
|
||||
"""返回所有已注册的 handler 名称。"""
|
||||
return list(cls._handlers.keys())
|
||||
|
||||
|
||||
# ── 内建 Handlers ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@ParameterHandlerRegistry.register("anthropic_thinking")
|
||||
def handle_anthropic_thinking(
|
||||
_ctx: PipelineContext,
|
||||
result: PipelineResult,
|
||||
_param_key: str | None = None,
|
||||
) -> None:
|
||||
"""处理 Anthropic thinking 参数的跨参数约束。
|
||||
|
||||
Anthropic API 要求:
|
||||
- max_tokens 必须大于 thinking.budget_tokens
|
||||
- 开启 thinking 时 temperature 必须为 1(不应传此参数)
|
||||
- thinking 参数自身需转换为 {type, budget_tokens} 结构
|
||||
"""
|
||||
thinking = result.params.get("thinking")
|
||||
if thinking is None:
|
||||
return
|
||||
|
||||
# 将标量 thinking=True 转换为启用 extended thinking
|
||||
if thinking is True:
|
||||
result.params["thinking"] = {"type": "enabled"}
|
||||
|
||||
# 提取 budget_tokens
|
||||
if isinstance(thinking, dict):
|
||||
budget = thinking.get("budget_tokens", 0)
|
||||
# 确保 max_tokens > budget_tokens
|
||||
max_tokens = result.params.get("max_tokens", 0)
|
||||
if budget > 0 and max_tokens <= budget:
|
||||
result.params["max_tokens"] = budget + 1
|
||||
|
||||
# 开启 thinking 时移除 temperature 参数(Anthropic 静默忽略,
|
||||
# 但显式移除更安全干净)
|
||||
result.params.pop("temperature", None)
|
||||
|
||||
|
||||
@ParameterHandlerRegistry.register("validate_max_tokens")
|
||||
def validate_max_tokens(
|
||||
ctx: PipelineContext,
|
||||
result: PipelineResult,
|
||||
_param_key: str | None = None,
|
||||
) -> None:
|
||||
"""校验 max_tokens 不超过模型上限。"""
|
||||
model_config = ctx.model_config
|
||||
if model_config is None:
|
||||
return
|
||||
max_tokens = result.params.get("max_tokens")
|
||||
if max_tokens is None:
|
||||
return
|
||||
limit = model_config.max_output_tokens
|
||||
if limit and max_tokens > limit:
|
||||
result.params["max_tokens"] = limit
|
||||
|
||||
|
||||
@ParameterHandlerRegistry.register("validate_temperature")
|
||||
def validate_temperature(
|
||||
_ctx: PipelineContext,
|
||||
_result: PipelineResult,
|
||||
_param_key: str | None = None,
|
||||
) -> None:
|
||||
"""温度参数跨提供商规范校验。
|
||||
|
||||
非 reasoning/thinking 模式下强制温度 1.0 不是必需的规则,
|
||||
OpenAI/DeepSeek 无此要求,此 handler 留作扩展点。
|
||||
"""
|
||||
# 预留:可为特定 provider 添加温度校验逻辑
|
||||
pass
|
||||
|
||||
|
||||
@ParameterHandlerRegistry.register("strip_unsupported_capability_params")
|
||||
def strip_unsupported_capability_params(
|
||||
ctx: PipelineContext,
|
||||
result: PipelineResult,
|
||||
_param_key: str | None = None,
|
||||
) -> None:
|
||||
"""根据模型能力过滤不支持的参数。
|
||||
|
||||
当模型不支持 vision 时,移除 image_url 等视觉参数。
|
||||
当模型不支持 tool_use 时,移除 tools 相关参数。
|
||||
"""
|
||||
model_config = ctx.model_config
|
||||
if model_config is None:
|
||||
return
|
||||
|
||||
caps = model_config.resolved_capabilities()
|
||||
|
||||
if not caps.get("vision", False):
|
||||
result.params.pop("image_url", None)
|
||||
result.params.pop("image", None)
|
||||
|
||||
if not caps.get("thinking", False):
|
||||
result.params.pop("thinking", None)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""重排序服务 — Reranker 适配器外观。
|
||||
|
||||
用法::
|
||||
|
||||
from app.ai_provider.reranker import RerankerService
|
||||
|
||||
service = RerankerService(manager)
|
||||
result = await service.rerank("查询", ["文档1", "文档2"])
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.ai_provider.reranker.base import (
|
||||
RerankerProtocol,
|
||||
RerankerServiceError,
|
||||
RerankRequest,
|
||||
RerankResponse,
|
||||
RerankResult,
|
||||
)
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
|
||||
|
||||
class RerankerService:
|
||||
"""外观类,负责解析 Reranker 模型并委托给 ProviderManager。
|
||||
|
||||
接受 ProviderManager 或实现了以下接口的适配器:
|
||||
- ``.model_registry`` → 具有 ``list_by_capability(capability)`` 方法
|
||||
- ``.create_reranker(name)`` → 返回 RerankerProtocol 实例
|
||||
"""
|
||||
|
||||
def __init__(self, manager) -> None:
|
||||
self._manager = manager
|
||||
self._adapter: RerankerProtocol | None = None
|
||||
|
||||
# ── Registry 兼容适配 ─────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def _registry(self):
|
||||
mgr = self._manager
|
||||
if hasattr(mgr, "model_registry"):
|
||||
return mgr.model_registry
|
||||
return mgr
|
||||
|
||||
@property
|
||||
def _factory(self):
|
||||
mgr = self._manager
|
||||
if hasattr(mgr, "create_reranker"):
|
||||
return mgr
|
||||
return None
|
||||
|
||||
# ── Reranker ───────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def adapter(self) -> RerankerProtocol:
|
||||
"""解析并缓存 Reranker 适配器。"""
|
||||
if self._adapter is None:
|
||||
self._adapter = self._resolve()
|
||||
return self._adapter
|
||||
|
||||
async def rerank(
|
||||
self,
|
||||
query: str,
|
||||
documents: list[str],
|
||||
model_hint: str | None = None,
|
||||
top_n: int | None = None,
|
||||
) -> RerankResponse:
|
||||
"""快捷方法:构造请求并调用 Reranker 适配器。"""
|
||||
model = self._resolve_model_name(model_hint)
|
||||
request = RerankRequest(
|
||||
query=query,
|
||||
documents=documents,
|
||||
model=model,
|
||||
top_n=top_n,
|
||||
)
|
||||
return await self.adapter.rerank(request)
|
||||
|
||||
# ── 解析 ───────────────────────────────────────────────────────
|
||||
|
||||
def _resolve(self) -> RerankerProtocol:
|
||||
"""查找第一个活跃的 Reranker 模型并通过工厂创建。"""
|
||||
models = self._list_capability_models("reranker")
|
||||
if not models:
|
||||
raise AIProviderError(
|
||||
status_code=503,
|
||||
code="reranker_no_model",
|
||||
message="No reranker model configured in the model registry.",
|
||||
)
|
||||
factory = self._factory
|
||||
if factory is None:
|
||||
raise AIProviderError(
|
||||
status_code=501,
|
||||
code="reranker_no_factory",
|
||||
message="Reranker adapter factory not available (provider manager missing create_reranker).",
|
||||
)
|
||||
return factory.create_reranker(models[0].name)
|
||||
|
||||
def _resolve_model_name(self, model_hint: str | None) -> str:
|
||||
models = self._list_capability_models("reranker")
|
||||
if model_hint:
|
||||
return model_hint
|
||||
if models:
|
||||
return models[0].name
|
||||
return ""
|
||||
|
||||
def _list_capability_models(self, tag: str):
|
||||
reg = self._registry
|
||||
if hasattr(reg, "list_capability_models"):
|
||||
return reg.list_capability_models(tag)
|
||||
if hasattr(reg, "list_by_capability"):
|
||||
return reg.list_by_capability(tag)
|
||||
return []
|
||||
|
||||
# ── 生命周期 ────────────────────────────────────────────────────
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._adapter is not None:
|
||||
close = getattr(self._adapter, "close", None)
|
||||
if close is not None:
|
||||
await close()
|
||||
|
||||
async def __aenter__(self) -> RerankerService:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.close()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RerankerProtocol",
|
||||
"RerankRequest",
|
||||
"RerankResponse",
|
||||
"RerankResult",
|
||||
"RerankerService",
|
||||
"RerankerServiceError",
|
||||
]
|
||||
@@ -0,0 +1,147 @@
|
||||
"""重排序服务类型 — 请求/响应模型和协议定义。
|
||||
|
||||
Reranker 适配器通过 HTTP 调用重排序端点,经由 ``RerankerService``
|
||||
或直接从 ``ProviderManager`` 消费。
|
||||
|
||||
用法::
|
||||
|
||||
service = RerankerService(manager)
|
||||
result = await service.rerank("查询", ["文档1", "文档2"])
|
||||
|
||||
# 或通过工厂直接调用:
|
||||
adapter = manager.create_reranker("rerank-model")
|
||||
result = await adapter.rerank(RerankRequest(query="...", documents=["..."]))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.ai_provider.config.models import ModelConfig, ModelEndpointConfig
|
||||
|
||||
|
||||
class RerankerServiceError(AIProviderError):
|
||||
"""Reranker API 调用失败的基础错误。"""
|
||||
|
||||
|
||||
# ── 请求 / 响应 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class RerankRequest:
|
||||
"""重排序请求。
|
||||
|
||||
属性:
|
||||
query: 查询文本。
|
||||
documents: 待重排序的文档列表。
|
||||
model: 模型标识符。
|
||||
top_n: 返回的最相关文档数量(nil 表示全部)。
|
||||
max_chunks_per_doc: 每个文档最多切分为多少块。
|
||||
return_documents: 返回结果中是否包含文档文本。
|
||||
"""
|
||||
|
||||
query: str
|
||||
documents: list[str]
|
||||
model: str = ""
|
||||
top_n: int | None = None
|
||||
max_chunks_per_doc: int | None = None
|
||||
return_documents: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class RerankResult:
|
||||
"""单条重排序结果。"""
|
||||
|
||||
index: int
|
||||
relevance_score: float
|
||||
text: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RerankResponse:
|
||||
"""重排序 API 的完整响应。"""
|
||||
|
||||
results: list[RerankResult]
|
||||
model: str = ""
|
||||
usage: dict[str, int] | None = None
|
||||
|
||||
|
||||
# ── Protocol ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RerankerProtocol(Protocol):
|
||||
"""Reranker 适配器的协议。"""
|
||||
|
||||
async def rerank(self, request: RerankRequest) -> RerankResponse:
|
||||
"""对文档进行重排序。
|
||||
|
||||
异常:
|
||||
RerankerServiceError: API 调用失败或响应异常时抛出。
|
||||
"""
|
||||
...
|
||||
|
||||
async def close(self) -> None:
|
||||
"""释放适配器持有的 HTTP 连接。"""
|
||||
...
|
||||
|
||||
|
||||
# ── 共享工厂基类 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RerankerAdapterBase:
|
||||
"""Reranker 适配器基类,提供共享的工厂方法。
|
||||
|
||||
子类应在 ``__init__`` 中接受 ``base_url``、``api_key``、``model``、
|
||||
``request_timeout_seconds`` 参数。
|
||||
"""
|
||||
|
||||
_adapter_type: str = "reranker"
|
||||
_default_timeout: int = 60
|
||||
|
||||
@classmethod
|
||||
def from_endpoint(
|
||||
cls,
|
||||
endpoint: ModelEndpointConfig,
|
||||
*,
|
||||
timeout_seconds: int | None = None,
|
||||
) -> Any:
|
||||
"""从已解析的端点配置构建适配器。"""
|
||||
base_url = endpoint.base_url
|
||||
api_key = endpoint.api_key
|
||||
if not base_url:
|
||||
raise RerankerServiceError(
|
||||
status_code=500,
|
||||
code="reranker_endpoint_invalid",
|
||||
message="Reranker endpoint has no resolved base_url.",
|
||||
details={"endpoint": endpoint.name},
|
||||
)
|
||||
return cls(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
model=endpoint.model,
|
||||
request_timeout_seconds=timeout_seconds
|
||||
or endpoint.request_timeout_seconds
|
||||
or cls._default_timeout,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_model_config(
|
||||
cls,
|
||||
config: ModelConfig,
|
||||
*,
|
||||
timeout_seconds: int | None = None,
|
||||
) -> Any:
|
||||
"""从逻辑模型配置构建适配器(使用第一个活跃端点)。"""
|
||||
for ep in config.endpoints:
|
||||
if ep.status == "active":
|
||||
return cls.from_endpoint(ep, timeout_seconds=timeout_seconds)
|
||||
raise RerankerServiceError(
|
||||
status_code=500,
|
||||
code="reranker_no_endpoint",
|
||||
message=f"Reranker model '{config.name}' has no active endpoint.",
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Cohere 兼容格式的 Reranker 适配器 — ``POST /v1/rerank``。
|
||||
|
||||
覆盖 Cohere、mixedbread、Jina AI 等采用 Cohere 兼容重排序端点的提供商。
|
||||
|
||||
请求::
|
||||
|
||||
POST {base_url}/v1/rerank
|
||||
Authorization: Bearer {api_key}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "rerank-model",
|
||||
"query": "查询文本",
|
||||
"documents": ["文档1", "文档2"],
|
||||
"top_n": 3,
|
||||
"return_documents": false
|
||||
}
|
||||
|
||||
响应::
|
||||
|
||||
{
|
||||
"results": [
|
||||
{"index": 0, "relevance_score": 0.99, "document": {"text": "文档..."}},
|
||||
{"index": 1, "relevance_score": 0.85, "document": {"text": "文档..."}}
|
||||
],
|
||||
"meta": {"billed_units": {"search_units": 2}}
|
||||
}
|
||||
|
||||
另见:
|
||||
|
||||
- Cohere Rerank API: https://docs.cohere.com/reference/rerank
|
||||
- Jina Reranker: https://api.jina.ai/redoc#tag/rerank
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.ai_provider.reranker.base import (
|
||||
RerankerAdapterBase,
|
||||
RerankerProtocol,
|
||||
RerankerServiceError,
|
||||
RerankRequest,
|
||||
RerankResponse,
|
||||
RerankResult,
|
||||
)
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
class CohereFormatRerankerAdapter(RerankerAdapterBase, RerankerProtocol):
|
||||
"""Cohere 兼容重排序适配器,覆盖多数重排序服务提供商。"""
|
||||
|
||||
_adapter_type = "reranker"
|
||||
_default_timeout = 60
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
model: str = "rerank-model",
|
||||
request_timeout_seconds: int = 60,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._model = model
|
||||
self._timeout = request_timeout_seconds
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
# ── 客户端 ───────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def _http(self) -> httpx.AsyncClient:
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
|
||||
# ── RerankerProtocol ────────────────────────────────────────────────
|
||||
|
||||
async def rerank(self, request: RerankRequest) -> RerankResponse:
|
||||
"""通过 ``POST /v1/rerank`` 对文档进行重排序。"""
|
||||
url = f"{self._base_url}/v1/rerank"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": request.model or self._model,
|
||||
"query": request.query,
|
||||
"documents": request.documents,
|
||||
"return_documents": request.return_documents,
|
||||
}
|
||||
if request.top_n is not None:
|
||||
payload["top_n"] = request.top_n
|
||||
if request.max_chunks_per_doc is not None:
|
||||
payload["max_chunks_per_doc"] = request.max_chunks_per_doc
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"reranker.request",
|
||||
model=payload["model"],
|
||||
query_length=len(request.query),
|
||||
document_count=len(request.documents),
|
||||
top_n=request.top_n,
|
||||
url=url,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._http.post(url, json=payload, headers=headers)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise RerankerServiceError(
|
||||
status_code=504,
|
||||
code="reranker_timeout",
|
||||
message="Reranker request timed out.",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise RerankerServiceError(
|
||||
status_code=503,
|
||||
code="reranker_connection_error",
|
||||
message=f"Reranker connection error: {exc}",
|
||||
) from exc
|
||||
|
||||
if response.status_code != 200:
|
||||
body = response.text[:500]
|
||||
raise RerankerServiceError(
|
||||
status_code=response.status_code,
|
||||
code="reranker_api_error",
|
||||
message=f"Reranker API returned {response.status_code}: {body}",
|
||||
)
|
||||
|
||||
result = self._parse_response(response.json())
|
||||
|
||||
logger.info(
|
||||
"reranker.success",
|
||||
model=payload["model"],
|
||||
total_results=len(result.results),
|
||||
)
|
||||
return result
|
||||
|
||||
# ── 辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _parse_response(body: dict) -> RerankResponse:
|
||||
"""从 Cohere 格式的重排序响应中提取结果。"""
|
||||
try:
|
||||
raw_results = body.get("results", [])
|
||||
results = []
|
||||
for item in raw_results:
|
||||
results.append(
|
||||
RerankResult(
|
||||
index=item.get("index", 0),
|
||||
relevance_score=float(item.get("relevance_score", 0.0)),
|
||||
text=item.get("document", {}).get("text")
|
||||
if isinstance(item.get("document"), dict)
|
||||
else None,
|
||||
)
|
||||
)
|
||||
# 按相关性分数降序排列
|
||||
results.sort(key=lambda r: r.relevance_score, reverse=True)
|
||||
|
||||
# 提取用量信息(不同提供商格式各异)
|
||||
usage: dict[str, int] | None = None
|
||||
meta = body.get("meta")
|
||||
if isinstance(meta, dict):
|
||||
billed = meta.get("billed_units")
|
||||
if isinstance(billed, dict):
|
||||
usage = {k: int(v) for k, v in billed.items() if isinstance(v, (int, float))}
|
||||
|
||||
return RerankResponse(
|
||||
results=results,
|
||||
model=body.get("model") or body.get("id", ""),
|
||||
usage=usage,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RerankerServiceError(
|
||||
status_code=500,
|
||||
code="reranker_response_parse_error",
|
||||
message=f"Failed to parse reranker response: {exc}",
|
||||
) from exc
|
||||
@@ -0,0 +1,14 @@
|
||||
from app.ai_provider.routing.multimodal import MultiModalDecision, MultiModalRouter
|
||||
from app.ai_provider.routing.permissions import (
|
||||
CapabilityPermissionController,
|
||||
PermissionRule,
|
||||
)
|
||||
from app.ai_provider.routing.selector import ModelSelector
|
||||
|
||||
__all__ = [
|
||||
"MultiModalDecision",
|
||||
"MultiModalRouter",
|
||||
"ModelSelector",
|
||||
"PermissionRule",
|
||||
"CapabilityPermissionController",
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class MultiModalDecision(StrEnum):
|
||||
"""基于消息内容分析的选路决策。
|
||||
|
||||
每个值代表所选模型必须满足的一项能力需求。
|
||||
PLAIN_TEXT 表示不需要特殊能力。
|
||||
"""
|
||||
|
||||
PLAIN_TEXT = "plain_text"
|
||||
VISION = "vision"
|
||||
THINKING = "thinking"
|
||||
TOOL_USE = "tool_use"
|
||||
STRUCTURED_OUTPUT = "structured_output"
|
||||
|
||||
|
||||
class MultiModalRouter:
|
||||
"""分析消息列表以确定所需的模型能力。
|
||||
|
||||
用法::
|
||||
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
decisions = MultiModalRouter.decide_from_messages(messages)
|
||||
# -> {MultiModalDecision.PLAIN_TEXT}
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's in this image?"},
|
||||
{"type": "image_url", "image_url": {"url": "..."}},
|
||||
],
|
||||
},
|
||||
]
|
||||
decisions = MultiModalRouter.decide_from_messages(messages)
|
||||
# -> {MultiModalDecision.VISION}
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def decide_from_messages(messages: list[dict[str, Any]]) -> set[MultiModalDecision]:
|
||||
"""分析消息并返回所需能力的集合。
|
||||
|
||||
参数:
|
||||
messages: 消息字典列表,每个字典至少包含 ``role`` 和 ``content`` 键(兼容 OpenAI 格式)。
|
||||
|
||||
返回:
|
||||
:class:`MultiModalDecision` 值的集合。如果未检测到特殊能力,
|
||||
返回 ``{PLAIN_TEXT}``。
|
||||
"""
|
||||
if not messages:
|
||||
return {MultiModalDecision.PLAIN_TEXT}
|
||||
|
||||
decisions: set[MultiModalDecision] = set()
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
|
||||
# --- VISION: content is a list of blocks with image type ---
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
block_type = block.get("type", "")
|
||||
if block_type in ("image_url", "image"):
|
||||
decisions.add(MultiModalDecision.VISION)
|
||||
|
||||
# --- TOOL_USE: assistant sent tool_calls, or message is tool result ---
|
||||
if msg.get("tool_calls"):
|
||||
decisions.add(MultiModalDecision.TOOL_USE)
|
||||
if role == "tool":
|
||||
decisions.add(MultiModalDecision.TOOL_USE)
|
||||
|
||||
# --- THINKING: assistant included reasoning ---
|
||||
if msg.get("reasoning"):
|
||||
decisions.add(MultiModalDecision.THINKING)
|
||||
|
||||
return decisions if decisions else {MultiModalDecision.PLAIN_TEXT}
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
from typing import Any
|
||||
|
||||
|
||||
class PermissionRule:
|
||||
"""模型能力访问控制的单条权限规则。
|
||||
|
||||
规则使用 ``fnmatch`` 风格的通配符模式匹配模型名称。
|
||||
|
||||
参数:
|
||||
model_pattern: ``fnmatch`` 模式,用于匹配模型名(如 ``"deepseek-*"``)。
|
||||
capability: 此规则管控的能力(如 ``"vision"``)。
|
||||
allowed: 能力是否允许(``True`` 允许,``False`` 拒绝)。
|
||||
|
||||
示例::
|
||||
|
||||
# 拒绝 "basic-*" 模型上的 vision 能力
|
||||
PermissionRule(model_pattern="basic-*", capability="vision", allowed=False)
|
||||
|
||||
# 允许所有模型的 thinking 能力
|
||||
PermissionRule(model_pattern="*", capability="thinking", allowed=True)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_pattern: str,
|
||||
capability: str,
|
||||
allowed: bool = True,
|
||||
) -> None:
|
||||
self.model_pattern = model_pattern
|
||||
self.capability = capability
|
||||
self.allowed = allowed
|
||||
|
||||
def match(self, model_name: str) -> bool:
|
||||
"""检查此规则是否适用于给定的模型名。"""
|
||||
return fnmatch.fnmatch(model_name, self.model_pattern)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
verb = "ALLOW" if self.allowed else "DENY"
|
||||
return f"PermissionRule({verb} {self.capability} on {self.model_pattern!r})"
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if not isinstance(other, PermissionRule):
|
||||
return NotImplemented
|
||||
return (
|
||||
self.model_pattern == other.model_pattern
|
||||
and self.capability == other.capability
|
||||
and self.allowed == other.allowed
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.model_pattern, self.capability, self.allowed))
|
||||
|
||||
|
||||
class CapabilityPermissionController:
|
||||
"""控制哪些模型允许哪些能力。
|
||||
|
||||
按顺序评估 :class:`PermissionRule` 列表。
|
||||
**最后匹配的规则** 胜出(最具体的规则应放在较宽泛的规则之后)。
|
||||
如果没有规则匹配某个模型/能力对,该能力**默认允许**。
|
||||
|
||||
用法::
|
||||
|
||||
controller = CapabilityPermissionController([
|
||||
PermissionRule(model_pattern="*", capability="vision", allowed=False),
|
||||
PermissionRule(model_pattern="vision-*", capability="vision", allowed=True),
|
||||
])
|
||||
|
||||
controller.check("fast-model", "vision") # False
|
||||
controller.check("vision-model", "vision") # True
|
||||
"""
|
||||
|
||||
def __init__(self, rules: list[PermissionRule] | None = None) -> None:
|
||||
self._rules = list(rules) if rules else []
|
||||
|
||||
def check(self, model_name: str, capability: str) -> bool:
|
||||
"""检查 *capability* 是否在 *model_name* 上被允许。
|
||||
|
||||
返回 ``True`` 表示允许,``False`` 表示拒绝。
|
||||
"""
|
||||
result: bool | None = None
|
||||
for rule in self._rules:
|
||||
if rule.capability == capability and rule.match(model_name):
|
||||
result = rule.allowed
|
||||
# Default: allow if no rule matched
|
||||
return result if result is not None else True
|
||||
|
||||
def is_allowed(self, model_name: str, capability: str) -> bool:
|
||||
""":meth:`check` 的别名。"""
|
||||
return self.check(model_name, capability)
|
||||
|
||||
def filter_capabilities(
|
||||
self,
|
||||
model_name: str,
|
||||
capabilities: set[str],
|
||||
) -> set[str]:
|
||||
"""返回 *capabilities* 中在 *model_name* 上允许的子集。"""
|
||||
return {c for c in capabilities if self.check(model_name, c)}
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig
|
||||
from app.ai_provider.core.registry import ModelRegistry
|
||||
from app.ai_provider.routing.multimodal import MultiModalDecision
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
|
||||
_SELECTOR_CAPABILITY_MAP: dict[MultiModalDecision, str] = {
|
||||
MultiModalDecision.VISION: "vision",
|
||||
MultiModalDecision.THINKING: "thinking",
|
||||
MultiModalDecision.TOOL_USE: "tool_use",
|
||||
MultiModalDecision.STRUCTURED_OUTPUT: "structured_output",
|
||||
MultiModalDecision.PLAIN_TEXT: "chat",
|
||||
}
|
||||
|
||||
|
||||
class ModelSelector:
|
||||
"""为给定上下文选择最佳模型。
|
||||
|
||||
用法::
|
||||
|
||||
selector = ModelSelector(registry)
|
||||
model = selector.select({"capabilities": {"vision", "thinking"}})
|
||||
model = selector.select({"preferred": "fast-model"})
|
||||
model = selector.select_from_decisions({MultiModalDecision.VISION})
|
||||
"""
|
||||
|
||||
def __init__(self, registry: ModelRegistry) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def select(self, context: dict[str, Any]) -> ModelConfig:
|
||||
"""根据给定的上下文选择模型。
|
||||
|
||||
上下文键:
|
||||
|
||||
* ``capabilities`` (``set[str]``, 可选) — 必需的能力。
|
||||
* ``preferred`` (``str``, 可选) — 偏好的模型名或别名。
|
||||
* ``avoid`` (``set[str]``, 可选) — 要排除的模型名。
|
||||
|
||||
如果给了偏好模型但不能满足所有必需的能力,此方法会回退到
|
||||
能满足要求的模型。
|
||||
"""
|
||||
capabilities: set[str] = set(context.get("capabilities", set()))
|
||||
preferred: str | None = context.get("preferred")
|
||||
avoid: set[str] = set(context.get("avoid", set()))
|
||||
|
||||
# If preferred is given, resolve it and check capabilities
|
||||
if preferred is not None:
|
||||
model = self._registry.resolve(preferred)
|
||||
if (
|
||||
not capabilities or self._model_satisfies_all(model, capabilities)
|
||||
) and model.name not in avoid:
|
||||
return model
|
||||
|
||||
# Find models that satisfy all required capabilities
|
||||
if capabilities:
|
||||
return self._select_by_capabilities(capabilities, avoid)
|
||||
|
||||
# Fall back to default
|
||||
return self._registry.resolve()
|
||||
|
||||
def select_from_decisions(
|
||||
self,
|
||||
decisions: set[MultiModalDecision],
|
||||
) -> ModelConfig:
|
||||
"""基于 :class:`MultiModalDecision` 值选择模型。
|
||||
|
||||
将决策翻译为能力字符串并委托给 :meth:`select`。
|
||||
"""
|
||||
capabilities: set[str] = set()
|
||||
for d in decisions:
|
||||
cap = _SELECTOR_CAPABILITY_MAP.get(d)
|
||||
if cap and cap != "chat":
|
||||
capabilities.add(cap)
|
||||
return self.select({"capabilities": capabilities})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _select_by_capabilities(
|
||||
self,
|
||||
capabilities: set[str],
|
||||
avoid: set[str],
|
||||
) -> ModelConfig:
|
||||
"""找到支持所有必需能力的模型。"""
|
||||
# Narrow down by each capability (ModelConfig is unhashable, use lists)
|
||||
candidates: list[ModelConfig] = list(self._registry.list_all())
|
||||
|
||||
for cap in capabilities:
|
||||
cap_models = list(self._registry.list_by_capability(cap))
|
||||
cap_names = {m.name for m in cap_models}
|
||||
candidates = [m for m in candidates if m.name in cap_names]
|
||||
|
||||
# Remove avoided models
|
||||
candidates = [m for m in candidates if m.name not in avoid]
|
||||
|
||||
if not candidates:
|
||||
raise AIProviderError(
|
||||
message=f"No active model supports all capabilities: {capabilities}",
|
||||
code="CAPABILITY_NOT_FOUND",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Prefer higher-weight models (higher = better)
|
||||
return max(candidates, key=lambda m: m.weight)
|
||||
|
||||
@staticmethod
|
||||
def _model_satisfies_all(model: ModelConfig, capabilities: set[str]) -> bool:
|
||||
caps = model.resolved_capabilities()
|
||||
return all(caps.get(cap, False) for cap in capabilities)
|
||||
@@ -0,0 +1,21 @@
|
||||
from app.ai_provider.types.chat import ChatChunk, ChatResult, TokenUsage
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
from app.ai_provider.types.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AIMessage",
|
||||
"BaseMessage",
|
||||
"HumanMessage",
|
||||
"SystemMessage",
|
||||
"ToolMessage",
|
||||
"ChatChunk",
|
||||
"ChatResult",
|
||||
"TokenUsage",
|
||||
"AIProviderError",
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenUsage:
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
prompt_tokens_cached: int = 0 # 缓存命中的 prompt token 数(按缓存费率计费)
|
||||
|
||||
def __add__(self, other: TokenUsage) -> TokenUsage:
|
||||
return TokenUsage(
|
||||
prompt_tokens=self.prompt_tokens + other.prompt_tokens,
|
||||
completion_tokens=self.completion_tokens + other.completion_tokens,
|
||||
total_tokens=self.total_tokens + other.total_tokens,
|
||||
prompt_tokens_cached=self.prompt_tokens_cached + other.prompt_tokens_cached,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatChunk:
|
||||
delta_content: str = ""
|
||||
delta_reasoning: str = ""
|
||||
tool_call_chunks: list[dict[str, Any]] = field(default_factory=list)
|
||||
usage: TokenUsage | None = None
|
||||
finish_reason: str | None = None
|
||||
content_block_index: int | None = None
|
||||
content_block_start: dict[str, Any] | None = None
|
||||
content_block_delta: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatResult:
|
||||
content: str = ""
|
||||
reasoning: str = ""
|
||||
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
||||
usage: TokenUsage | None = None
|
||||
model_name: str = ""
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AIProviderError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: str = "UNKNOWN",
|
||||
status_code: int = 500,
|
||||
provider: str | None = None,
|
||||
original: Exception | None = None,
|
||||
) -> None:
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
self.provider = provider
|
||||
self.original = original
|
||||
super().__init__(message)
|
||||
|
||||
@classmethod
|
||||
def from_provider(
|
||||
cls,
|
||||
message: str,
|
||||
code: str = "PROVIDER_ERROR",
|
||||
provider: str | None = None,
|
||||
original: Exception | None = None,
|
||||
) -> AIProviderError:
|
||||
return cls(
|
||||
message=message, code=code, status_code=502, provider=provider, original=original
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
Role = Literal["system", "user", "assistant", "tool"]
|
||||
|
||||
|
||||
class BaseMessage:
|
||||
role: Role
|
||||
content: str
|
||||
|
||||
def __init__(self, *, content: str, role: Role) -> None:
|
||||
self.content = content
|
||||
self.role = role
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"role": self.role, "content": self.content}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> BaseMessage:
|
||||
role = d["role"]
|
||||
content = d.get("content", "")
|
||||
if role == "system":
|
||||
return SystemMessage(content=content)
|
||||
if role == "user":
|
||||
return HumanMessage(content=content)
|
||||
if role == "assistant":
|
||||
return AIMessage(
|
||||
content=content,
|
||||
tool_calls=d.get("tool_calls"),
|
||||
reasoning=d.get("reasoning"),
|
||||
raw_content_blocks=d.get("raw_content_blocks"),
|
||||
)
|
||||
if role == "tool":
|
||||
return ToolMessage(content=content, tool_call_id=d.get("tool_call_id", ""))
|
||||
raise ValueError(f"Unknown role: {role}")
|
||||
|
||||
|
||||
class SystemMessage(BaseMessage):
|
||||
def __init__(self, content: str) -> None:
|
||||
super().__init__(content=content, role="system")
|
||||
|
||||
|
||||
class HumanMessage(BaseMessage):
|
||||
def __init__(self, content: str) -> None:
|
||||
super().__init__(content=content, role="user")
|
||||
|
||||
|
||||
class AIMessage(BaseMessage):
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
tool_calls: list[dict[str, Any]] | None = None,
|
||||
reasoning: str | None = None,
|
||||
raw_content_blocks: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
super().__init__(content=content, role="assistant")
|
||||
self.tool_calls = tool_calls or []
|
||||
self.reasoning = reasoning or ""
|
||||
self.raw_content_blocks = raw_content_blocks or []
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
d = super().to_dict()
|
||||
if self.tool_calls:
|
||||
d["tool_calls"] = self.tool_calls
|
||||
if self.reasoning:
|
||||
d["reasoning"] = self.reasoning
|
||||
if self.raw_content_blocks:
|
||||
d["raw_content_blocks"] = self.raw_content_blocks
|
||||
return d
|
||||
|
||||
|
||||
class ToolMessage(BaseMessage):
|
||||
def __init__(self, content: str, tool_call_id: str) -> None:
|
||||
super().__init__(content=content, role="tool")
|
||||
self.tool_call_id = tool_call_id
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
d = super().to_dict()
|
||||
d["tool_call_id"] = self.tool_call_id
|
||||
return d
|
||||
@@ -0,0 +1 @@
|
||||
"""API 路由包。"""
|
||||
@@ -0,0 +1,60 @@
|
||||
"""API 路由聚合。"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import (
|
||||
admin,
|
||||
audio,
|
||||
auth,
|
||||
avatar,
|
||||
character_import,
|
||||
characters,
|
||||
chat,
|
||||
drive,
|
||||
events,
|
||||
evolution,
|
||||
extraction,
|
||||
goals,
|
||||
health,
|
||||
imports,
|
||||
lorebooks,
|
||||
mcp,
|
||||
memory,
|
||||
personality,
|
||||
runs,
|
||||
safety,
|
||||
sandbox_files,
|
||||
settings,
|
||||
skills,
|
||||
time,
|
||||
tools,
|
||||
world,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router)
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(characters.router)
|
||||
api_router.include_router(character_import.router)
|
||||
api_router.include_router(chat.router)
|
||||
api_router.include_router(runs.router)
|
||||
api_router.include_router(memory.router)
|
||||
api_router.include_router(lorebooks.router)
|
||||
api_router.include_router(personality.router)
|
||||
api_router.include_router(tools.router)
|
||||
api_router.include_router(skills.router)
|
||||
api_router.include_router(mcp.router)
|
||||
api_router.include_router(safety.router)
|
||||
api_router.include_router(sandbox_files.router)
|
||||
api_router.include_router(settings.router)
|
||||
api_router.include_router(world.router)
|
||||
api_router.include_router(imports.router)
|
||||
api_router.include_router(evolution.router)
|
||||
api_router.include_router(drive.router)
|
||||
api_router.include_router(events.router)
|
||||
api_router.include_router(extraction.router)
|
||||
api_router.include_router(goals.router)
|
||||
api_router.include_router(admin.router)
|
||||
api_router.include_router(audio.router)
|
||||
api_router.include_router(avatar.router)
|
||||
api_router.include_router(time.router)
|
||||
@@ -0,0 +1 @@
|
||||
"""v1 API 路由。"""
|
||||
@@ -0,0 +1,186 @@
|
||||
"""API 层依赖注入。
|
||||
|
||||
从 ``app.state`` 取 lifespan 装配的共享运行时(ToolRuntime / ConfirmationStore / EventStore)。
|
||||
CRUD 类路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不在此注入 session。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.agent.confirmation import ConfirmationRegistry
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.modules.runtime.events import EventStore
|
||||
from app.modules.safety.confirmation import ConfirmationStore
|
||||
from app.schemas.auth import CurrentUser as CurrentUserSchema
|
||||
from app.services.auth import decode_token, get_user_by_id, get_user_permissions
|
||||
|
||||
|
||||
def _unavailable(message: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=503,
|
||||
detail={"error": {"code": "service_unavailable", "message": message}},
|
||||
)
|
||||
|
||||
|
||||
def get_tool_runtime(request: Request) -> ToolRuntime:
|
||||
runtime: ToolRuntime | None = getattr(request.app.state, "tool_runtime", None)
|
||||
if runtime is None:
|
||||
raise _unavailable("工具运行时未就绪(沙箱未配置或未启动)")
|
||||
return runtime
|
||||
|
||||
|
||||
def get_optional_tool_runtime(request: Request) -> ToolRuntime | None:
|
||||
"""宽松版:tool_runtime 未就绪时返回 None(不抛 503),供聚合视图降级呈现。"""
|
||||
return getattr(request.app.state, "tool_runtime", None)
|
||||
|
||||
|
||||
def get_confirmation_store(request: Request) -> ConfirmationStore:
|
||||
store = getattr(request.app.state, "confirmation_store", None)
|
||||
if store is None:
|
||||
raise _unavailable("确认存储未就绪")
|
||||
return store
|
||||
|
||||
|
||||
def get_confirmation_registry(request: Request) -> ConfirmationRegistry:
|
||||
registry: ConfirmationRegistry = getattr(request.app.state, "confirmation_registry", None)
|
||||
if registry is None:
|
||||
raise _unavailable("确认注册表未就绪")
|
||||
return registry
|
||||
|
||||
|
||||
def get_event_store(request: Request) -> EventStore:
|
||||
store = getattr(request.app.state, "event_store", None)
|
||||
if store is None:
|
||||
raise _unavailable("事件存储未就绪")
|
||||
return store
|
||||
|
||||
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""管理端点鉴权:JWT + is_admin 优先,ADMIN_TOKEN fallback。
|
||||
|
||||
JWT 模式下:
|
||||
- 有效 JWT 且 is_admin=True → 放行
|
||||
- 有效 JWT 且 is_admin=False → 403
|
||||
- 无效 JWT → 回退 ADMIN_TOKEN 校验
|
||||
ADMIN_TOKEN 模式下(原 v1 逻辑):
|
||||
- 同现有逻辑,常数时间比较
|
||||
- ADMIN_TOKEN 未配置且无 JWT → 503 fail-closed
|
||||
"""
|
||||
# 0) fail-closed:两种鉴权均未配置时直接拒绝
|
||||
if not settings.jwt_secret and not settings.admin_token:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={"error": {"code": "admin_not_configured", "message": "管理端点未启用。"}},
|
||||
)
|
||||
|
||||
credential = request.headers.get("Authorization", "")
|
||||
if not credential.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "缺少管理员凭证。"}},
|
||||
)
|
||||
presented = credential[len("Bearer ") :]
|
||||
|
||||
# 1) 尝试 JWT 模式(如果 jwt_secret 已配置)
|
||||
if settings.jwt_secret:
|
||||
try:
|
||||
payload = decode_token(presented)
|
||||
if payload.get("type") == "access":
|
||||
user_id = payload.get("sub")
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {"code": "unauthorized", "message": "用户不存在或已禁用。"}
|
||||
},
|
||||
)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": {"code": "forbidden", "message": "仅管理员可访问此接口。"}
|
||||
},
|
||||
)
|
||||
return # JWT 模式鉴权通过
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass # JWT 无效,回退 ADMIN_TOKEN 校验
|
||||
|
||||
# 2) Fallback: ADMIN_TOKEN 校验(原 v1 逻辑)
|
||||
token = settings.admin_token
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={"error": {"code": "admin_not_configured", "message": "管理端点未启用。"}},
|
||||
)
|
||||
if not hmac.compare_digest(presented, token):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "管理员凭证无效。"}},
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> CurrentUserSchema:
|
||||
"""从 Authorization: Bearer <access_token> 解析当前用户。
|
||||
|
||||
JWT 未配置(jwt_secret 为空)时 503 fail-closed。
|
||||
"""
|
||||
if not settings.jwt_secret:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "service_unavailable",
|
||||
"message": "认证服务未配置(JWT_SECRET 为空)。",
|
||||
}
|
||||
},
|
||||
)
|
||||
credential = request.headers.get("Authorization", "")
|
||||
if not credential.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "缺少认证凭证。"}},
|
||||
)
|
||||
token = credential[len("Bearer ") :]
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "Token 无效或已过期。"}},
|
||||
) from None
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "Token 类型不匹配。"}},
|
||||
)
|
||||
user_id = payload.get("sub")
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "用户不存在或已禁用。"}},
|
||||
)
|
||||
perms = await get_user_permissions(db, user.id)
|
||||
return CurrentUserSchema(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
owner_scope=user.id,
|
||||
is_admin=user.is_admin,
|
||||
permissions=perms,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""v1 API 路由端点。
|
||||
|
||||
路由实现统一放在 ``endpoints/`` 子目录,``deps.py`` 留在父级 ``api/v1/`` 提供依赖注入。
|
||||
每个端点模块只做 参数校验 → 调用 service/usecase → 转响应,不直接持有 ``AsyncSession``。
|
||||
"""
|
||||
|
||||
from app.api.v1.endpoints import auth
|
||||
|
||||
__all__ = ["auth"]
|
||||
@@ -0,0 +1,534 @@
|
||||
"""司命台管理 API(api-spec §19)。
|
||||
|
||||
聚合九枢治理视图:九枢健康度 / 朱批待办 / 起居注审计(§19.1–§19.3)
|
||||
+ 五组治理对象 CRUD(§19.4)。治理写操作由 service 层记审计并入起居注。
|
||||
|
||||
路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不持有 session;
|
||||
``ToolRuntime`` / ``ConfirmationStore`` 等运行时对象经 ``deps`` 从 ``app.state`` 注入。
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.v1.deps import (
|
||||
get_confirmation_store,
|
||||
get_current_user,
|
||||
get_optional_tool_runtime,
|
||||
require_admin,
|
||||
)
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.models.runtime import Event
|
||||
from app.modules.safety.confirmation import ConfirmationStore
|
||||
from app.schemas.admin import (
|
||||
AdminCreateUserRequest,
|
||||
AdminUpdateUserRequest,
|
||||
AdminUserResponse,
|
||||
AgentRosterResponse,
|
||||
AuditEntry,
|
||||
CreateAgentRequest,
|
||||
CreateGoalRequest,
|
||||
CreatePerceptionChannelRequest,
|
||||
CreatePlanRequest,
|
||||
CreateWorldEntityRequest,
|
||||
GoalNode,
|
||||
PendingApproval,
|
||||
PerceptionChannelResponse,
|
||||
PivotHealthReport,
|
||||
PlanSummaryResponse,
|
||||
TimeEventListResponse,
|
||||
TimeEventResponse,
|
||||
TraceDetail,
|
||||
TraceListResponse,
|
||||
UpdateAgentRequest,
|
||||
UpdateGoalRequest,
|
||||
UpdatePerceptionChannelRequest,
|
||||
UpdatePlanRequest,
|
||||
UpdateWorldEntityRequest,
|
||||
WorldEntityResponse,
|
||||
)
|
||||
from app.schemas.admin_character import (
|
||||
AdminCreateTemplateRequest,
|
||||
AdminTemplateSummaryResponse,
|
||||
AdminUpdateTemplateRequest,
|
||||
)
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.common import Page
|
||||
from app.services import governance as governance_service
|
||||
from app.services.admin import (
|
||||
get_audit_timeline,
|
||||
get_pending_approvals,
|
||||
get_pivots_health,
|
||||
get_trace,
|
||||
list_time_events,
|
||||
list_traces,
|
||||
)
|
||||
from app.services.admin_characters import (
|
||||
admin_create_template,
|
||||
admin_delete_template,
|
||||
admin_get_template,
|
||||
admin_list_templates,
|
||||
admin_update_template,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
# —— §19.1 九枢健康度聚合 ——
|
||||
|
||||
|
||||
@router.get("/pivots/health", response_model=PivotHealthReport)
|
||||
async def pivots_health(
|
||||
confirmation_store: ConfirmationStore = Depends(get_confirmation_store),
|
||||
tool_runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PivotHealthReport:
|
||||
return await get_pivots_health(
|
||||
confirmation_store=confirmation_store,
|
||||
tool_runtime=tool_runtime,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
# —— §19.2 朱批待办 ——
|
||||
|
||||
|
||||
@router.get("/safety/pending", response_model=Page[PendingApproval])
|
||||
async def safety_pending(
|
||||
confirmation_store: ConfirmationStore = Depends(get_confirmation_store),
|
||||
tool_runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> Page[PendingApproval]:
|
||||
items = await get_pending_approvals(
|
||||
confirmation_store=confirmation_store, tool_runtime=tool_runtime
|
||||
)
|
||||
return Page(items=items, page=1, page_size=len(items), total=len(items))
|
||||
|
||||
|
||||
# —— §19.3 起居注审计 ——
|
||||
|
||||
|
||||
@router.get("/audit/timeline", response_model=Page[AuditEntry])
|
||||
async def audit_timeline(
|
||||
pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
) -> Page[AuditEntry]:
|
||||
return await get_audit_timeline(pivot=pivot, page=page, page_size=page_size)
|
||||
|
||||
|
||||
# —— §19.4 太一枢 · 目标 ——
|
||||
|
||||
|
||||
@router.get("/goals", response_model=list[GoalNode])
|
||||
async def list_goals(
|
||||
character_id: str | None = Query(default=None, description="按人物过滤。"),
|
||||
) -> list[GoalNode]:
|
||||
if character_id:
|
||||
return await governance_service.list_goals_by_character(character_id)
|
||||
return await governance_service.list_goals()
|
||||
|
||||
|
||||
@router.post("/goals", response_model=GoalNode)
|
||||
async def create_goal(payload: CreateGoalRequest) -> GoalNode:
|
||||
return await governance_service.create_goal(payload)
|
||||
|
||||
|
||||
@router.patch("/goals/{goal_id}", response_model=GoalNode)
|
||||
async def update_goal(
|
||||
goal_id: str,
|
||||
payload: UpdateGoalRequest,
|
||||
) -> GoalNode:
|
||||
return await governance_service.update_goal(goal_id, payload)
|
||||
|
||||
|
||||
@router.delete("/goals/{goal_id}", status_code=204)
|
||||
async def delete_goal(goal_id: str) -> None:
|
||||
await governance_service.delete_goal(goal_id)
|
||||
|
||||
|
||||
# —— §19.4 烛照枢 · 感知通道 ——
|
||||
|
||||
|
||||
@router.get("/perception/channels", response_model=list[PerceptionChannelResponse])
|
||||
async def list_perception_channels() -> list[PerceptionChannelResponse]:
|
||||
return await governance_service.list_perception_channels()
|
||||
|
||||
|
||||
@router.post("/perception/channels", response_model=PerceptionChannelResponse)
|
||||
async def create_perception_channel(
|
||||
payload: CreatePerceptionChannelRequest,
|
||||
) -> PerceptionChannelResponse:
|
||||
return await governance_service.create_perception_channel(payload)
|
||||
|
||||
|
||||
@router.patch("/perception/channels/{channel}", response_model=PerceptionChannelResponse)
|
||||
async def update_perception_channel(
|
||||
channel: str,
|
||||
payload: UpdatePerceptionChannelRequest,
|
||||
) -> PerceptionChannelResponse:
|
||||
return await governance_service.update_perception_channel(channel, payload)
|
||||
|
||||
|
||||
@router.delete("/perception/channels/{channel}", status_code=204)
|
||||
async def delete_perception_channel(channel: str) -> None:
|
||||
await governance_service.delete_perception_channel(channel)
|
||||
|
||||
|
||||
# —— §19.4 河图枢 · 世界实体 ——
|
||||
|
||||
|
||||
@router.get("/world/entities", response_model=list[WorldEntityResponse])
|
||||
async def list_world_entities() -> list[WorldEntityResponse]:
|
||||
return await governance_service.list_world_entities()
|
||||
|
||||
|
||||
@router.post("/world/entities", response_model=WorldEntityResponse)
|
||||
async def create_world_entity(
|
||||
payload: CreateWorldEntityRequest,
|
||||
) -> WorldEntityResponse:
|
||||
return await governance_service.create_world_entity(payload)
|
||||
|
||||
|
||||
@router.patch("/world/entities/{entity_id}", response_model=WorldEntityResponse)
|
||||
async def update_world_entity(
|
||||
entity_id: str,
|
||||
payload: UpdateWorldEntityRequest,
|
||||
) -> WorldEntityResponse:
|
||||
return await governance_service.update_world_entity(entity_id, payload)
|
||||
|
||||
|
||||
@router.delete("/world/entities/{entity_id}", status_code=204)
|
||||
async def delete_world_entity(entity_id: str) -> None:
|
||||
await governance_service.delete_world_entity(entity_id)
|
||||
|
||||
|
||||
# —— §19.4 洛书枢 · 计划 ——
|
||||
|
||||
|
||||
@router.get("/plans", response_model=list[PlanSummaryResponse])
|
||||
async def list_plans() -> list[PlanSummaryResponse]:
|
||||
return await governance_service.list_plans()
|
||||
|
||||
|
||||
@router.post("/plans", response_model=PlanSummaryResponse)
|
||||
async def create_plan(payload: CreatePlanRequest) -> PlanSummaryResponse:
|
||||
return await governance_service.create_plan(payload)
|
||||
|
||||
|
||||
@router.patch("/plans/{plan_id}", response_model=PlanSummaryResponse)
|
||||
async def update_plan(
|
||||
plan_id: str,
|
||||
payload: UpdatePlanRequest,
|
||||
) -> PlanSummaryResponse:
|
||||
return await governance_service.update_plan(plan_id, payload)
|
||||
|
||||
|
||||
@router.delete("/plans/{plan_id}", status_code=204)
|
||||
async def delete_plan(plan_id: str) -> None:
|
||||
await governance_service.delete_plan(plan_id)
|
||||
|
||||
|
||||
# —— §19.4 天工阁枢 · 角色在岗 ——
|
||||
|
||||
|
||||
@router.get("/agents/roster", response_model=list[AgentRosterResponse])
|
||||
async def list_agent_roster() -> list[AgentRosterResponse]:
|
||||
return await governance_service.list_agent_roster()
|
||||
|
||||
|
||||
@router.post("/agents/roster", response_model=AgentRosterResponse)
|
||||
async def create_agent(payload: CreateAgentRequest) -> AgentRosterResponse:
|
||||
return await governance_service.create_agent(payload)
|
||||
|
||||
|
||||
@router.patch("/agents/roster/{role}", response_model=AgentRosterResponse)
|
||||
async def update_agent(
|
||||
role: str,
|
||||
payload: UpdateAgentRequest,
|
||||
) -> AgentRosterResponse:
|
||||
return await governance_service.update_agent(role, payload)
|
||||
|
||||
|
||||
@router.delete("/agents/roster/{role}", status_code=204)
|
||||
async def delete_agent(role: str) -> None:
|
||||
await governance_service.delete_agent(role)
|
||||
|
||||
|
||||
# —— §19.5 链路追踪 ——
|
||||
|
||||
|
||||
@router.get("/traces", response_model=TraceListResponse)
|
||||
async def trace_list(
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
) -> TraceListResponse:
|
||||
"""列出最近活动的 trace_id 概览(跨运行事件 / 安全审计 / 请求幂等聚合)。"""
|
||||
return await list_traces(limit=limit)
|
||||
|
||||
|
||||
@router.get("/traces/{trace_id}", response_model=TraceDetail)
|
||||
async def trace_detail(trace_id: str) -> TraceDetail:
|
||||
"""按 trace_id 聚合运行事件 / 安全审计 / 请求幂等三表为升序时间线。"""
|
||||
return await get_trace(trace_id)
|
||||
|
||||
|
||||
# —— §8 司命台 · 起居注时间河(辰正 time.* 事件)——
|
||||
|
||||
|
||||
@router.get("/time-events", response_model=TimeEventListResponse)
|
||||
async def time_event_list(
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> TimeEventListResponse:
|
||||
"""起居注时间河:辰正 time.* 事件(§8 司命台接入,source='time_sense')。"""
|
||||
items, total = await list_time_events(limit=limit, offset=offset)
|
||||
return TimeEventListResponse(
|
||||
items=[_time_event_to_response(item) for item in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
def _time_event_to_response(event: Event) -> TimeEventResponse:
|
||||
"""复用既有 payload 解析模式(json.loads + 异常回退到空 dict,与 _event_to_item 一致)。"""
|
||||
try:
|
||||
payload = json.loads(event.payload_json) if event.payload_json else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
payload = {}
|
||||
return TimeEventResponse(
|
||||
id=event.id,
|
||||
type=event.type,
|
||||
payload=payload,
|
||||
run_id=event.run_id,
|
||||
character_id=event.character_id,
|
||||
sensitivity=event.sensitivity,
|
||||
created_at=event.created_at.isoformat() if event.created_at else "",
|
||||
)
|
||||
|
||||
|
||||
# —— §19.6 用户管理 ——
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserResponse])
|
||||
async def admin_list_users() -> list[AdminUserResponse]:
|
||||
"""列出所有用户(管理员用)。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.user import User
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(User).order_by(User.created_at))
|
||||
users = result.scalars().all()
|
||||
return [AdminUserResponse.model_validate(u) for u in users]
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserResponse)
|
||||
async def admin_create_user(payload: AdminCreateUserRequest) -> AdminUserResponse:
|
||||
"""创建用户(管理员用)。"""
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.services.auth import create_user
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
user = await create_user(
|
||||
session,
|
||||
username=payload.username,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
is_admin=payload.is_admin,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return AdminUserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=AdminUserResponse)
|
||||
async def admin_update_user(user_id: str, payload: AdminUpdateUserRequest) -> AdminUserResponse:
|
||||
"""更新用户(启用/禁用、管理员角色)。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.user import User
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
from app.core.errors import AppError
|
||||
|
||||
raise AppError("not_found", "用户不存在。")
|
||||
if payload.is_active is not None:
|
||||
user.is_active = payload.is_active
|
||||
if payload.is_admin is not None:
|
||||
user.is_admin = payload.is_admin
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return AdminUserResponse.model_validate(user)
|
||||
|
||||
|
||||
# —— §19.7 多租户管理视图 ——
|
||||
|
||||
|
||||
class _TenantUserOverview(BaseModel):
|
||||
"""租户概览(管理员跨租户视角)。"""
|
||||
|
||||
user_id: str
|
||||
username: str
|
||||
email: str
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
character_count: int
|
||||
memory_count: int
|
||||
|
||||
|
||||
@router.get("/tenants/overview", response_model=list[_TenantUserOverview])
|
||||
async def admin_tenants_overview() -> list[_TenantUserOverview]:
|
||||
"""跨租户概览:每用户的人物与记忆计数(管理员用)。"""
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.models.memory import Memory
|
||||
from app.models.user import User
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(User).order_by(User.created_at))
|
||||
users = result.scalars().all()
|
||||
overview: list[_TenantUserOverview] = []
|
||||
for user in users:
|
||||
char_count = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(Character)
|
||||
.where(
|
||||
Character.owner_scope == user.id,
|
||||
Character.is_system_template.is_(False),
|
||||
)
|
||||
)
|
||||
mem_count = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(Memory)
|
||||
.join(Character, Memory.character_id == Character.id)
|
||||
.where(
|
||||
Character.owner_scope == user.id,
|
||||
Character.is_system_template.is_(False),
|
||||
)
|
||||
)
|
||||
overview.append(
|
||||
_TenantUserOverview(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_active=user.is_active,
|
||||
is_admin=user.is_admin,
|
||||
character_count=char_count or 0,
|
||||
memory_count=mem_count or 0,
|
||||
)
|
||||
)
|
||||
return overview
|
||||
|
||||
|
||||
@router.get("/tenants/{user_id}/characters")
|
||||
async def admin_tenant_characters(user_id: str) -> list[dict]:
|
||||
"""查看指定用户的所有人物(跨租户管理员用)。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.services.serializers import character_to_response
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Character)
|
||||
.where(
|
||||
Character.owner_scope == user_id,
|
||||
Character.is_system_template.is_(False),
|
||||
)
|
||||
.order_by(Character.created_at)
|
||||
)
|
||||
return [character_to_response(c).model_dump(mode="json") for c in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/tenants/{user_id}/memories")
|
||||
async def admin_tenant_memories(user_id: str) -> list[dict]:
|
||||
"""查看指定用户的所有记忆(跨租户管理员用)。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.models.memory import Memory
|
||||
from app.services.serializers import memory_to_response
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Memory)
|
||||
.join(Character, Memory.character_id == Character.id)
|
||||
.where(
|
||||
Character.owner_scope == user_id,
|
||||
Character.is_system_template.is_(False),
|
||||
)
|
||||
.order_by(Memory.created_at.desc())
|
||||
)
|
||||
return [memory_to_response(m).model_dump(mode="json") for m in result.scalars().all()]
|
||||
|
||||
|
||||
# —— §19.8 人物模板管理(管理员专用 CRUD) ——
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/templates",
|
||||
response_model=list[AdminTemplateSummaryResponse],
|
||||
)
|
||||
async def list_templates() -> list[AdminTemplateSummaryResponse]:
|
||||
"""列出所有系统模板(管理端完整视图,含 profile / model_policy 全文)。"""
|
||||
return await admin_list_templates()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/templates/{key}",
|
||||
response_model=AdminTemplateSummaryResponse,
|
||||
)
|
||||
async def get_template(key: str) -> AdminTemplateSummaryResponse:
|
||||
"""获取单个系统模板详情。"""
|
||||
return await admin_get_template(key)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/characters/templates",
|
||||
response_model=AdminTemplateSummaryResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_template(
|
||||
payload: AdminCreateTemplateRequest,
|
||||
) -> AdminTemplateSummaryResponse:
|
||||
"""创建新的系统模板。
|
||||
|
||||
如 key 与 YAML 中已存在的 key 相同,下次重启时会被 YAML 配置覆盖。
|
||||
"""
|
||||
return await admin_create_template(payload)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/characters/templates/{key}",
|
||||
response_model=AdminTemplateSummaryResponse,
|
||||
)
|
||||
async def update_template(
|
||||
key: str,
|
||||
payload: AdminUpdateTemplateRequest,
|
||||
) -> AdminTemplateSummaryResponse:
|
||||
"""更新系统模板(部分更新)。
|
||||
|
||||
仅提供非 None 的字段会被更新。YAML 定义的模板字段会在下次重启时被 YAML 覆盖。
|
||||
"""
|
||||
return await admin_update_template(key, payload)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/characters/templates/{key}",
|
||||
status_code=204,
|
||||
)
|
||||
async def delete_template(key: str) -> None:
|
||||
"""删除系统模板。
|
||||
|
||||
仅允许删除未被任何用户人物引用的模板。
|
||||
被引用时返回 409 conflict。
|
||||
"""
|
||||
await admin_delete_template(key)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""音频相关 API 端点 — TTS(文本转语音)、ASR(语音转文本)等。
|
||||
|
||||
音频端点使用神识的模型注册表查找可用的音频模型,并调用对应音频适配器合成 /
|
||||
转写。ASR 与 TTS 按 ``tags`` 中的 ``asr`` / ``tts`` 能力区分,ASR 内部再按
|
||||
``provider`` 区分协议:``yuanqi`` 走 multipart ``/v1/audio/transcriptions``,
|
||||
其他走 MiMo 风格的 ``/chat/completions`` + ``input_audio``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Response, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.ai_provider.audio import AudioService, AudioServiceError
|
||||
from app.ai_provider.manager import ProviderManagerFactory
|
||||
from app.core.logging import logger
|
||||
|
||||
router = APIRouter(tags=["audio"])
|
||||
|
||||
_MIME_MAP = {
|
||||
"wav": "audio/wav",
|
||||
"mp3": "audio/mpeg",
|
||||
"opus": "audio/opus",
|
||||
"flac": "audio/flac",
|
||||
}
|
||||
|
||||
|
||||
class _AudioModelFactory:
|
||||
"""Adapter: wraps ProviderManager to provide ModelFactory interface for AudioService.
|
||||
|
||||
``create_audio(name)`` 按 ``tags`` 判断 ``asr`` / ``tts`` 能力:
|
||||
- ``tts`` → ``HunjingTTSOpenAI``(``/chat/completions`` + ``audio``)
|
||||
- ``asr`` → 按 ``provider`` 分派:
|
||||
- ``yuanqi`` → ``YuanqiASRWhisper``(multipart ``/v1/audio/transcriptions``)
|
||||
- 其他 → ``HunjingASROpenAI``(``/chat/completions`` + ``input_audio``)
|
||||
"""
|
||||
|
||||
def __init__(self, manager) -> None:
|
||||
self._manager = manager
|
||||
|
||||
@property
|
||||
def registry(self):
|
||||
return self._manager.model_registry
|
||||
|
||||
def create_audio(self, name: str):
|
||||
model = self._manager.get_model_config(name)
|
||||
is_asr = "asr" in model.tags
|
||||
|
||||
if is_asr:
|
||||
return self._create_asr(model)
|
||||
return self._create_tts(model)
|
||||
|
||||
def _provider_or_none(self, model):
|
||||
"""安全获取 provider 配置(provider 未配置时返回 None,走纯端点解析)。"""
|
||||
try:
|
||||
return self._manager.get_provider_config(model.provider)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ── ASR ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _create_asr(self, model):
|
||||
"""按 provider 选择 ASR 协议适配器。"""
|
||||
# yuanqi 本地引擎走 multipart /v1/audio/transcriptions
|
||||
if model.provider == "yuanqi":
|
||||
from app.ai_provider.audio.yuanqi_asr import YuanqiASRWhisper
|
||||
|
||||
if model.endpoints:
|
||||
return YuanqiASRWhisper.from_model_config(
|
||||
model, provider=self._provider_or_none(model)
|
||||
)
|
||||
|
||||
provider = self._manager.get_provider_config(model.provider)
|
||||
api_key, base_url = self._resolve_credentials(provider)
|
||||
if not base_url:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_asr_no_base_url",
|
||||
message=f"Provider '{model.provider}' has no base_url configured.",
|
||||
)
|
||||
return YuanqiASRWhisper(
|
||||
base_url=base_url,
|
||||
api_key=api_key or "",
|
||||
model=model.model,
|
||||
request_timeout_seconds=model.timeout_seconds or 300,
|
||||
)
|
||||
|
||||
# MiMo 等走 /chat/completions + input_audio
|
||||
from app.ai_provider.audio.asr import HunjingASROpenAI
|
||||
|
||||
if model.endpoints:
|
||||
return HunjingASROpenAI.from_model_config(
|
||||
model, provider=self._provider_or_none(model)
|
||||
)
|
||||
|
||||
provider = self._manager.get_provider_config(model.provider)
|
||||
api_key, base_url = self._resolve_credentials(provider)
|
||||
if not base_url:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_asr_no_base_url",
|
||||
message=f"Provider '{model.provider}' has no base_url configured.",
|
||||
)
|
||||
return HunjingASROpenAI(
|
||||
base_url=base_url,
|
||||
api_key=api_key or "",
|
||||
model=model.model,
|
||||
request_timeout_seconds=model.timeout_seconds or 300,
|
||||
)
|
||||
|
||||
# ── TTS ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _create_tts(self, model):
|
||||
from app.ai_provider.audio.tts import HunjingTTSOpenAI
|
||||
|
||||
# 有 endpoints 时交给 from_model_config(按端点的 status/model/超时)
|
||||
if model.endpoints:
|
||||
return HunjingTTSOpenAI.from_model_config(
|
||||
model, provider=self._provider_or_none(model)
|
||||
)
|
||||
|
||||
# 无 endpoint 时回退到 provider 级别凭证
|
||||
provider = self._manager.get_provider_config(model.provider)
|
||||
api_key, base_url = self._resolve_credentials(provider)
|
||||
if not base_url:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_tts_no_base_url",
|
||||
message=f"Provider '{model.provider}' has no base_url configured.",
|
||||
)
|
||||
return HunjingTTSOpenAI(
|
||||
base_url=base_url,
|
||||
api_key=api_key or "",
|
||||
model=model.model,
|
||||
request_timeout_seconds=model.timeout_seconds or 120,
|
||||
)
|
||||
|
||||
def _resolve_credentials(self, provider):
|
||||
"""从 settings 解析 provider 级别的 API key 和 base URL。"""
|
||||
from app.core.config import settings
|
||||
|
||||
api_key = (
|
||||
settings.__getattribute__(provider.api_key_env.lower())
|
||||
if hasattr(settings, provider.api_key_env.lower())
|
||||
else ""
|
||||
)
|
||||
base_url = (
|
||||
settings.__getattribute__(provider.base_url_env.lower())
|
||||
if hasattr(settings, provider.base_url_env.lower())
|
||||
else None
|
||||
)
|
||||
return api_key, base_url
|
||||
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
"""TTS 合成请求。"""
|
||||
|
||||
text: str = Field(..., min_length=1, max_length=4096, description="要合成语音的文本。")
|
||||
voice: str = Field("default", description="语音角色标识(取决于模型,如 default 或声纹 ID)。")
|
||||
format: str = Field("wav", description="音频格式:wav / mp3 / opus / flac。")
|
||||
|
||||
|
||||
@router.post("/audio/tts")
|
||||
async def synthesize_speech(body: TTSRequest) -> Response:
|
||||
"""合成文本为语音,返回音频二进制流。
|
||||
|
||||
使用神识注册表中第一个活跃的 TTS 模型(capability: tts)。
|
||||
未配置 TTS 模型时返回 503。
|
||||
"""
|
||||
if not body.text.strip():
|
||||
raise HTTPException(status_code=400, detail="text 不能为空。")
|
||||
|
||||
mime = _MIME_MAP.get(body.format, "audio/wav")
|
||||
|
||||
try:
|
||||
manager = ProviderManagerFactory.create()
|
||||
service = AudioService(_AudioModelFactory(manager))
|
||||
async with service:
|
||||
result = await service.synthesize(
|
||||
text=body.text,
|
||||
voice=body.voice,
|
||||
response_format=body.format,
|
||||
)
|
||||
except AudioServiceError as exc:
|
||||
logger.error(
|
||||
"audio.tts.api_error",
|
||||
voice=body.voice,
|
||||
format=body.format,
|
||||
error_code=exc.code,
|
||||
error_message=exc.message,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=exc.status_code or 502,
|
||||
detail=exc.message,
|
||||
) from exc
|
||||
|
||||
logger.info(
|
||||
"audio.tts.success",
|
||||
voice=body.voice,
|
||||
format=body.format,
|
||||
audio_bytes=len(result.audio_data),
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=result.audio_data,
|
||||
media_type=mime,
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="speech.{body.format}"',
|
||||
"X-Audio-Format": body.format,
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── ASR(语音转文本)───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TranscriptionResponse(BaseModel):
|
||||
"""OpenAI Whisper 兼容的转写响应。"""
|
||||
|
||||
text: str = Field(description="转写后的文本")
|
||||
|
||||
|
||||
@router.post("/audio/transcriptions", response_model=TranscriptionResponse)
|
||||
async def transcribe_audio(
|
||||
file: UploadFile = File(..., description="音频文件(wav/mp3/m4a/flac/ogg/opus/webm 等)"),
|
||||
language: str | None = Form(default=None, description="语言提示(ISO-639-1:zh/en/ja/ko 等),省略则自动检测"),
|
||||
model: str | None = Form(default=None, description="可选,指定使用的 ASR 模型逻辑名(如 funasr-sensevoice / mimo-v2.5-asr),省略则用注册表中第一个"),
|
||||
) -> TranscriptionResponse:
|
||||
"""转写音频为文本,返回 OpenAI Whisper 兼容的 JSON。
|
||||
|
||||
不指定 ``model`` 时用神识注册表中第一个活跃的 ASR 模型(capability: asr),
|
||||
按其 ``provider`` 自动选择协议适配器:``yuanqi`` 走本地 multipart 端点,
|
||||
其他走 MiMo 风格的 chat completions。未配置 ASR 模型时返回 503。
|
||||
"""
|
||||
audio_bytes = await file.read()
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=400, detail="音频文件为空。")
|
||||
|
||||
fmt = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower() if "." in (file.filename or "") else "wav"
|
||||
|
||||
try:
|
||||
manager = ProviderManagerFactory.create()
|
||||
service = AudioService(_AudioModelFactory(manager))
|
||||
async with service:
|
||||
result = await service.transcribe(
|
||||
audio_data=audio_bytes,
|
||||
format=fmt,
|
||||
language=language,
|
||||
model_name=model,
|
||||
)
|
||||
except AudioServiceError as exc:
|
||||
logger.error(
|
||||
"audio.asr.api_error",
|
||||
filename=file.filename,
|
||||
format=fmt,
|
||||
language=language,
|
||||
error_code=exc.code,
|
||||
error_message=exc.message,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=exc.status_code or 502,
|
||||
detail=exc.message,
|
||||
) from exc
|
||||
|
||||
logger.info(
|
||||
"audio.asr.success",
|
||||
filename=file.filename,
|
||||
format=fmt,
|
||||
language=language,
|
||||
text_length=len(result.text),
|
||||
text=result.text,
|
||||
)
|
||||
|
||||
return TranscriptionResponse(text=result.text)
|
||||
@@ -0,0 +1,207 @@
|
||||
"""认证 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.models.rbac import Role, UserRole
|
||||
from app.schemas.auth import (
|
||||
ChangePasswordRequest,
|
||||
CurrentUser,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RefreshRequest,
|
||||
RegisterRequest,
|
||||
UpdateProfileRequest,
|
||||
UserResponse,
|
||||
)
|
||||
from app.services import auth as auth_service
|
||||
from app.services.auth import get_user_permissions
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _jwt_enabled() -> None:
|
||||
if not settings.jwt_secret:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "service_unavailable",
|
||||
"message": "认证服务未配置(JWT_SECRET 为空)。",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
async def register(
|
||||
payload: RegisterRequest,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> UserResponse:
|
||||
_jwt_enabled()
|
||||
user = await auth_service.create_user(
|
||||
db,
|
||||
username=payload.username,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
)
|
||||
# 为新用户创建默认角色和默认人物
|
||||
from app.services.characters import ensure_default_character_for_owner
|
||||
|
||||
await ensure_default_character_for_owner(db, user.id)
|
||||
# 分配普通用户角色
|
||||
role_result = await db.execute(select(Role).where(Role.name == "user"))
|
||||
user_role = role_result.scalar_one_or_none()
|
||||
if user_role:
|
||||
db.add(UserRole(user_id=user.id, role_id=user_role.id))
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_admin=user.is_admin,
|
||||
created_at=user.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> LoginResponse:
|
||||
_jwt_enabled()
|
||||
user = await auth_service.authenticate_user(db, payload.username, payload.password)
|
||||
access_token = auth_service.create_access_token(
|
||||
subject=user.id,
|
||||
extra={"owner_scope": user.id},
|
||||
)
|
||||
refresh_token = auth_service.create_refresh_token(subject=user.id)
|
||||
perms = await get_user_permissions(db, user.id)
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.access_token_ttl,
|
||||
user=UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_admin=user.is_admin,
|
||||
permissions=perms,
|
||||
created_at=user.created_at.isoformat(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=LoginResponse)
|
||||
async def refresh(
|
||||
payload: RefreshRequest,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> LoginResponse:
|
||||
_jwt_enabled()
|
||||
data = auth_service.decode_token(payload.refresh_token)
|
||||
if data.get("type") != "refresh":
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "Token 类型不匹配。"}},
|
||||
)
|
||||
user = await auth_service.get_user_by_id(db, data["sub"])
|
||||
if user is None or not user.is_active:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "用户不存在或已禁用。"}},
|
||||
)
|
||||
new_access = auth_service.create_access_token(
|
||||
subject=user.id,
|
||||
extra={"owner_scope": user.id},
|
||||
)
|
||||
new_refresh = auth_service.create_refresh_token(subject=user.id)
|
||||
perms = await get_user_permissions(db, user.id)
|
||||
return LoginResponse(
|
||||
access_token=new_access,
|
||||
refresh_token=new_refresh,
|
||||
token_type="bearer",
|
||||
expires_in=settings.access_token_ttl,
|
||||
user=UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_admin=user.is_admin,
|
||||
permissions=perms,
|
||||
created_at=user.created_at.isoformat(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> UserResponse:
|
||||
perms = await get_user_permissions(db, current_user.user_id)
|
||||
return UserResponse(
|
||||
id=current_user.user_id,
|
||||
username=current_user.username,
|
||||
email=current_user.email,
|
||||
is_admin=current_user.is_admin,
|
||||
permissions=perms,
|
||||
created_at="",
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserResponse)
|
||||
async def update_profile(
|
||||
payload: UpdateProfileRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> UserResponse:
|
||||
"""更新个人资料(当前仅支持邮箱)。"""
|
||||
if payload.email is not None:
|
||||
from sqlalchemy import select as _select
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(_select(User).where(User.email == payload.email))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is not None and existing.id != current_user.user_id:
|
||||
from app.core.errors import AppError
|
||||
|
||||
raise AppError("conflict", "该邮箱已被其他用户使用。")
|
||||
user = await db.get(User, current_user.user_id)
|
||||
if user is not None:
|
||||
user.email = payload.email
|
||||
await db.commit()
|
||||
return await get_me(current_user, db)
|
||||
|
||||
|
||||
@router.post("/me/change-password", status_code=204)
|
||||
async def change_password(
|
||||
payload: ChangePasswordRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""修改当前用户密码。"""
|
||||
from app.models.user import User
|
||||
|
||||
user = await db.get(User, current_user.user_id)
|
||||
if user is None:
|
||||
from app.core.errors import AppError
|
||||
|
||||
raise AppError("not_found", "用户不存在。")
|
||||
if not auth_service.verify_password(payload.old_password, user.password_hash):
|
||||
from app.core.errors import AppError
|
||||
|
||||
raise AppError("unauthorized", "原密码错误。")
|
||||
user.password_hash = auth_service.hash_password(payload.new_password)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""角色头像 API。
|
||||
|
||||
上传与获取角色头像图片文件。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.services.avatar import (
|
||||
get_avatar_response,
|
||||
upload_avatar,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/characters", tags=["characters"])
|
||||
|
||||
|
||||
@router.post("/{character_id}/avatar")
|
||||
async def upload_character_avatar(
|
||||
character_id: str,
|
||||
file: UploadFile = File(..., description="头像图片,支持 JPG/PNG/WebP,最大 5MB。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""上传角色头像。
|
||||
|
||||
支持的格式:JPEG、PNG、WebP。最大 5MB。
|
||||
新头像会替换旧头像(同角色只保留一张)。
|
||||
"""
|
||||
content = await file.read()
|
||||
avatar_url = await upload_avatar(
|
||||
character_id=character_id,
|
||||
content=content,
|
||||
content_type=file.content_type or "image/jpeg",
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
return {"avatar_url": avatar_url}
|
||||
|
||||
|
||||
@router.get("/{character_id}/avatar")
|
||||
async def get_character_avatar(
|
||||
character_id: str,
|
||||
) -> object:
|
||||
"""获取角色头像图片。
|
||||
|
||||
返回原始图片文件。无头像时返回 404。
|
||||
"""
|
||||
return await get_avatar_response(character_id)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""角色卡导入 / 导出 API。"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Response, UploadFile
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.character import ImportCardResponse
|
||||
from app.services.card_import import export_character_card, import_character_card_response
|
||||
|
||||
router = APIRouter(prefix="/characters", tags=["characters"])
|
||||
|
||||
|
||||
@router.post("/import-card", response_model=ImportCardResponse)
|
||||
async def import_character_card_api(
|
||||
file: UploadFile = File(..., description="角色卡文件,支持 .png / .json / .charx。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportCardResponse:
|
||||
"""导入酒馆角色卡(V1/V2/V3),建立人物 + 世谱(character_book)。
|
||||
|
||||
``personality`` 写入道体 ``personality_summary`` 作初始值;
|
||||
``character_book`` 转为该角色的世谱书 + 条目。
|
||||
"""
|
||||
raw = await file.read()
|
||||
return await import_character_card_response(raw, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/{character_id}/export-card")
|
||||
async def export_character_card_api(
|
||||
character_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Response:
|
||||
"""导出人物为 ST V3 卡静态快照(.json,丢弃演化维度)。"""
|
||||
card = await export_character_card(character_id, owner_scope=current_user.owner_scope)
|
||||
return Response(
|
||||
content=json.dumps(card, ensure_ascii=False, indent=2),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": 'attachment; filename="character_card.json"'},
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""人物 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.core.errors import AppError
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.character import (
|
||||
ActivateCharacterResponse,
|
||||
CharacterResponse,
|
||||
CharacterSummaryResponse,
|
||||
CharacterTemplateSummaryResponse,
|
||||
CreateCharacterRequest,
|
||||
UpdateModelPolicyRequest,
|
||||
UpdateProfileRequest,
|
||||
)
|
||||
from app.schemas.common import Page
|
||||
from app.services.characters import (
|
||||
activate_character,
|
||||
archive_character,
|
||||
create_character,
|
||||
get_active_character,
|
||||
get_character_by_id,
|
||||
list_characters_page,
|
||||
list_system_templates_page,
|
||||
pause_character,
|
||||
resume_character,
|
||||
update_character_model_policy,
|
||||
update_character_profile,
|
||||
)
|
||||
from app.services.idempotency import run_idempotent
|
||||
|
||||
router = APIRouter(prefix="/characters", tags=["characters"])
|
||||
|
||||
|
||||
@router.get("/active", response_model=CharacterResponse)
|
||||
async def active_character(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await get_active_character(owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=list[CharacterTemplateSummaryResponse])
|
||||
async def list_templates(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[CharacterTemplateSummaryResponse]:
|
||||
"""列出所有系统模板,仅管理员可见(管理端完整 CRUD 见 ``/admin/characters/templates``)。"""
|
||||
if not current_user.is_admin:
|
||||
raise AppError("permission_denied", "仅管理员可查看系统模板。")
|
||||
return await list_system_templates_page()
|
||||
|
||||
|
||||
@router.get("", response_model=Page[CharacterSummaryResponse])
|
||||
async def list_characters(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
status: str | None = Query(default=None, description="按人物状态过滤。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[CharacterSummaryResponse]:
|
||||
return await list_characters_page(
|
||||
owner_scope=current_user.owner_scope, status=status, page=page, page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=CharacterResponse)
|
||||
async def create_character_api(
|
||||
payload: CreateCharacterRequest,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
"""创建人物。
|
||||
|
||||
- 传 ``template_key`` 时从系统模板复制(仅管理员可用)。
|
||||
- 不传 ``template_key`` 时创建空白人物(任何用户可用,status=wuji)。
|
||||
"""
|
||||
if payload.template_key and not current_user.is_admin:
|
||||
raise AppError("permission_denied", "仅管理员可从系统模板创建人物。")
|
||||
return await run_idempotent(
|
||||
key=idempotency_key,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
payload=payload,
|
||||
response_type=CharacterResponse,
|
||||
resource_type="character",
|
||||
handler=lambda session: create_character(
|
||||
session, payload, owner_scope=current_user.owner_scope
|
||||
),
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{character_id}", response_model=CharacterResponse)
|
||||
async def get_character(
|
||||
character_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await get_character_by_id(character_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.patch("/{character_id}/profile", response_model=CharacterResponse)
|
||||
async def update_profile(
|
||||
character_id: str,
|
||||
payload: UpdateProfileRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await update_character_profile(
|
||||
character_id, payload, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{character_id}/model-policy", response_model=CharacterResponse)
|
||||
async def update_model_policy(
|
||||
character_id: str,
|
||||
payload: UpdateModelPolicyRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await update_character_model_policy(
|
||||
character_id, payload, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{character_id}/activate", response_model=ActivateCharacterResponse)
|
||||
async def activate(
|
||||
character_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ActivateCharacterResponse:
|
||||
return await activate_character(character_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post("/{character_id}/pause", response_model=CharacterResponse)
|
||||
async def pause(
|
||||
character_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await pause_character(character_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post("/{character_id}/resume", response_model=CharacterResponse)
|
||||
async def resume(
|
||||
character_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await resume_character(character_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post("/{character_id}/archive", response_model=CharacterResponse)
|
||||
async def archive(
|
||||
character_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await archive_character(character_id, owner_scope=current_user.owner_scope)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""聊天 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request, Response, status
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.chat import (
|
||||
ChatMessageListResponse,
|
||||
ChatMessageResponse,
|
||||
ChatSessionListResponse,
|
||||
ChatSessionResponse,
|
||||
CreateSessionRequest,
|
||||
SelectVersionRequest,
|
||||
SendMessageRequest,
|
||||
SendMessageResponse,
|
||||
UpdateSessionRequest,
|
||||
)
|
||||
from app.services.chat import (
|
||||
create_session,
|
||||
delete_session,
|
||||
get_session_response,
|
||||
list_message_responses,
|
||||
list_session_responses,
|
||||
select_message_version,
|
||||
send_message,
|
||||
update_session_response,
|
||||
)
|
||||
from app.services.idempotency import run_idempotent
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=ChatSessionResponse)
|
||||
async def create_chat_session(
|
||||
payload: CreateSessionRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ChatSessionResponse:
|
||||
return await create_session(payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=ChatSessionListResponse)
|
||||
async def list_chat_sessions(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
status: str | None = Query(default=None, description="按状态过滤:active / archived"),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ChatSessionListResponse:
|
||||
return await list_session_responses(
|
||||
owner_scope=current_user.owner_scope, status=status, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", response_model=ChatSessionResponse)
|
||||
async def get_chat_session(
|
||||
session_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ChatSessionResponse:
|
||||
return await get_session_response(session_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.patch("/sessions/{session_id}", response_model=ChatSessionResponse)
|
||||
async def update_chat_session(
|
||||
session_id: str,
|
||||
payload: UpdateSessionRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ChatSessionResponse:
|
||||
return await update_session_response(session_id, payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_chat_session(
|
||||
session_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Response:
|
||||
await delete_session(session_id, owner_scope=current_user.owner_scope)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/messages", response_model=SendMessageResponse)
|
||||
async def send_chat_message(
|
||||
session_id: str,
|
||||
payload: SendMessageRequest,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> SendMessageResponse:
|
||||
return await run_idempotent(
|
||||
key=idempotency_key,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
payload=payload,
|
||||
response_type=SendMessageResponse,
|
||||
resource_type="run",
|
||||
handler=lambda session: send_message(
|
||||
session,
|
||||
session_id,
|
||||
payload,
|
||||
owner_scope=current_user.owner_scope,
|
||||
tool_runtime=getattr(request.app.state, "tool_runtime", None),
|
||||
confirmation_registry=getattr(request.app.state, "confirmation_registry", None),
|
||||
confirmation_store=getattr(request.app.state, "confirmation_store", None),
|
||||
event_store=getattr(request.app.state, "event_store", None),
|
||||
agent_run_registry=getattr(request.app.state, "agent_run_registry", None),
|
||||
python_skill_catalog=getattr(request.app.state, "python_skill_catalog", None),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/messages", response_model=ChatMessageListResponse)
|
||||
async def list_chat_messages(
|
||||
session_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ChatMessageListResponse:
|
||||
return await list_message_responses(
|
||||
session_id, owner_scope=current_user.owner_scope, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/sessions/{session_id}/messages/{message_id}/version",
|
||||
response_model=ChatMessageResponse,
|
||||
)
|
||||
async def select_message_version_endpoint(
|
||||
session_id: str,
|
||||
message_id: str,
|
||||
payload: SelectVersionRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ChatMessageResponse:
|
||||
"""选择/激活消息的某个历史版本。"""
|
||||
return await select_message_version(
|
||||
session_id,
|
||||
message_id,
|
||||
payload,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""内驱力 /drive API。"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.drive import CuriosityResponse, SubgoalResponse
|
||||
from app.services.drive import (
|
||||
approve_subgoal,
|
||||
defer_subgoal,
|
||||
list_curiosities,
|
||||
list_subgoals,
|
||||
reject_subgoal,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["drive"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/curiosities",
|
||||
response_model=Page[CuriosityResponse],
|
||||
)
|
||||
async def list_curiosities_endpoint(
|
||||
character_id: str,
|
||||
source: str | None = Query(default=None, description="按来源过滤(契约字典内驱力 source)"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[CuriosityResponse]:
|
||||
"""列出某人物的好奇问题(大周天内驱力生成)。"""
|
||||
return await list_curiosities(
|
||||
character_id=character_id,
|
||||
source=source,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/subgoals",
|
||||
response_model=Page[SubgoalResponse],
|
||||
)
|
||||
async def list_subgoals_endpoint(
|
||||
character_id: str,
|
||||
status: str | None = Query(
|
||||
default=None, description="按状态过滤:candidate/approved/deferred/rejected"
|
||||
),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[SubgoalResponse]:
|
||||
"""列出某人物的探索性次生目标。"""
|
||||
return await list_subgoals(
|
||||
character_id=character_id,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subgoals/{subgoal_id}/approve", response_model=SubgoalResponse)
|
||||
async def approve_subgoal_endpoint(subgoal_id: str) -> SubgoalResponse:
|
||||
"""通过探索性次生目标(仅标记 approved,本期不自动执行)。"""
|
||||
return await approve_subgoal(subgoal_id)
|
||||
|
||||
|
||||
@router.post("/subgoals/{subgoal_id}/defer", response_model=SubgoalResponse)
|
||||
async def defer_subgoal_endpoint(subgoal_id: str) -> SubgoalResponse:
|
||||
"""延后探索性次生目标(status → deferred)。"""
|
||||
return await defer_subgoal(subgoal_id)
|
||||
|
||||
|
||||
@router.post("/subgoals/{subgoal_id}/reject", response_model=SubgoalResponse)
|
||||
async def reject_subgoal_endpoint(subgoal_id: str) -> SubgoalResponse:
|
||||
"""驳回探索性次生目标(status → rejected)。"""
|
||||
return await reject_subgoal(subgoal_id)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""全局一炁事件 SSE 端点(非 run-scoped)。
|
||||
|
||||
供前端 useGlobalEventStream hook 订阅全局广播事件:
|
||||
- ``proactive.session`` —— 叩心枢新建的主动会话
|
||||
- ``proactive.message`` —— 叩心枢追加到已有会话的消息
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.event_store import DbEventStore
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
|
||||
|
||||
def _get_event_store(request: Request) -> DbEventStore:
|
||||
return request.app.state.event_store
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def global_event_stream(
|
||||
request: Request,
|
||||
) -> EventSourceResponse:
|
||||
"""全局事件 SSE 流:通过 EventStore.subscribe_by_pattern 监听所有
|
||||
proactive.* 事件,推送给前端。"""
|
||||
if not settings.enable_sse:
|
||||
from app.core.errors import AppError
|
||||
|
||||
raise AppError("invalid_request", "SSE 当前未启用。")
|
||||
|
||||
event_store = _get_event_store(request)
|
||||
return EventSourceResponse(_event_generator(event_store))
|
||||
|
||||
|
||||
async def _event_generator(
|
||||
event_store: DbEventStore,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
queue = event_store.subscribe_by_pattern("proactive.*")
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=settings.sse_heartbeat_seconds)
|
||||
yield {
|
||||
"id": event.id,
|
||||
"event": event.type,
|
||||
"data": event.model_dump_json(),
|
||||
}
|
||||
except TimeoutError:
|
||||
yield {
|
||||
"event": "heartbeat",
|
||||
"data": '{"type":"heartbeat"}',
|
||||
}
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
event_store.unsubscribe_by_pattern("proactive.*", queue)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""演化候选审核 API。"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.evolution import EvolutionCandidateResponse
|
||||
from app.services.evolution import (
|
||||
approve_evolution_candidate,
|
||||
list_evolution_candidates,
|
||||
reject_evolution_candidate,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["evolution"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/evolution-candidates",
|
||||
response_model=Page[EvolutionCandidateResponse],
|
||||
)
|
||||
async def list_evolution_candidates_endpoint(
|
||||
character_id: str,
|
||||
status: str | None = Query(
|
||||
default=None, description="按状态过滤:pending/approved/rejected/applied"
|
||||
),
|
||||
candidate_type: str | None = Query(
|
||||
default=None, alias="type", description="按候选类型过滤(契约字典 §12.1)"
|
||||
),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[EvolutionCandidateResponse]:
|
||||
"""列出某人物的演化候选(大周天生成 + 审核状态)。"""
|
||||
return await list_evolution_candidates(
|
||||
character_id=character_id,
|
||||
status=status,
|
||||
candidate_type=candidate_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/evolution-candidates/{candidate_id}/approve",
|
||||
response_model=EvolutionCandidateResponse,
|
||||
)
|
||||
async def approve_candidate(
|
||||
candidate_id: str,
|
||||
) -> EvolutionCandidateResponse:
|
||||
"""审核通过演化候选:派生到下游(人格版本/程序记忆等),status → applied。"""
|
||||
return await approve_evolution_candidate(candidate_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/evolution-candidates/{candidate_id}/reject",
|
||||
response_model=EvolutionCandidateResponse,
|
||||
)
|
||||
async def reject_candidate(
|
||||
candidate_id: str,
|
||||
) -> EvolutionCandidateResponse:
|
||||
"""驳回演化候选:status → rejected,不派生下游。"""
|
||||
return await reject_evolution_candidate(candidate_id)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""大周天抽取候选审核 API(记忆候选 + 世界实体候选)。"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.schemas.candidates import MemoryCandidateResponse
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.world import WorldEntityResponse
|
||||
from app.services.extraction import (
|
||||
approve_memory_proposal,
|
||||
approve_world_entity,
|
||||
list_memory_proposals,
|
||||
list_world_entities_pending,
|
||||
reject_memory_proposal,
|
||||
reject_world_entity,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["extraction"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/memory-candidates",
|
||||
response_model=Page[MemoryCandidateResponse],
|
||||
)
|
||||
async def list_memory_candidates_endpoint(
|
||||
character_id: str,
|
||||
source: str | None = Query(
|
||||
default=None, description="来源过滤:cycle(大周天)/ import(资料导入)"
|
||||
),
|
||||
status: str | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[MemoryCandidateResponse]:
|
||||
"""列出记忆候选(大周天 + 资料导入统一)。"""
|
||||
return await list_memory_proposals(
|
||||
character_id=character_id,
|
||||
source=source,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/memory-candidates/{candidate_id}/approve",
|
||||
response_model=MemoryCandidateResponse,
|
||||
)
|
||||
async def approve_memory_candidate_endpoint(
|
||||
candidate_id: str,
|
||||
) -> MemoryCandidateResponse:
|
||||
"""通过记忆候选:派生为 memories 记录(source=big_cycle)。"""
|
||||
return await approve_memory_proposal(candidate_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/memory-candidates/{candidate_id}/reject",
|
||||
response_model=MemoryCandidateResponse,
|
||||
)
|
||||
async def reject_memory_candidate_endpoint(
|
||||
candidate_id: str,
|
||||
) -> MemoryCandidateResponse:
|
||||
"""驳回记忆候选。"""
|
||||
return await reject_memory_proposal(candidate_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/world/entity-proposals",
|
||||
response_model=Page[WorldEntityResponse],
|
||||
)
|
||||
async def list_world_entity_proposals_endpoint(
|
||||
character_id: str,
|
||||
status: str | None = Query(default="pending", description="默认 pending(候选)"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[WorldEntityResponse]:
|
||||
"""列出世界实体候选(大周天抽取,status=pending 待审核)。"""
|
||||
return await list_world_entities_pending(
|
||||
character_id=character_id, status=status, page=page, page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
@router.post("/world/entities/{entity_id}/approve", response_model=WorldEntityResponse)
|
||||
async def approve_world_entity_endpoint(entity_id: str) -> WorldEntityResponse:
|
||||
"""通过世界实体候选:status → confirmed 进入世界模型。"""
|
||||
return await approve_world_entity(entity_id)
|
||||
|
||||
|
||||
@router.post("/world/entities/{entity_id}/reject", response_model=WorldEntityResponse)
|
||||
async def reject_world_entity_endpoint(entity_id: str) -> WorldEntityResponse:
|
||||
"""驳回世界实体候选。"""
|
||||
return await reject_world_entity(entity_id)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Per-Character 目标管理端点(非 admin,供 /drive 等前端使用)。"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.schemas.admin import CreateGoalRequest, GoalNode
|
||||
from app.services import governance as governance_service
|
||||
|
||||
router = APIRouter(prefix="/goals", tags=["goals"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[GoalNode])
|
||||
async def list_goals(
|
||||
character_id: str = Query(..., description="按人物过滤。"),
|
||||
status: str | None = Query(default=None, description="按状态过滤(§5.12)。"),
|
||||
) -> list[GoalNode]:
|
||||
return await governance_service.list_goals_by_character(character_id, status_filter=status)
|
||||
|
||||
|
||||
@router.post("", response_model=GoalNode, status_code=201)
|
||||
async def create_goal(payload: CreateGoalRequest) -> GoalNode:
|
||||
return await governance_service.create_goal(payload)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""健康检查 API。"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok", "app_name": settings.app_name, "env": settings.app_env}
|
||||
@@ -0,0 +1,224 @@
|
||||
"""资料导入 API(api-spec §8)。
|
||||
|
||||
JSON 提交与文件上传分两个端点(FastAPI 无法优雅地在单端点同时吃 JSON body
|
||||
与 multipart);两者返回结构一致。路径挂在 ``/characters/{character_id}`` 下。
|
||||
候选抽取为异步后台任务:触发即返回 job 状态,前端轮询 ``GET /imports/{id}``
|
||||
(extracting_* → reviewing)与候选列表获取结果。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.candidates import (
|
||||
BatchReviewRequest,
|
||||
BatchReviewResult,
|
||||
MemoryCandidateResponse,
|
||||
ProfileCandidateResponse,
|
||||
)
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.imports import (
|
||||
CreateImportRequest,
|
||||
ImportItemResponse,
|
||||
ImportJobResponse,
|
||||
)
|
||||
from app.services.imports import (
|
||||
batch_review_memory_candidates,
|
||||
batch_review_profile_candidates,
|
||||
create_import_from_json,
|
||||
create_import_from_upload,
|
||||
get_import,
|
||||
list_import_items,
|
||||
list_jobs_page,
|
||||
list_memory_candidates,
|
||||
list_profile_candidates,
|
||||
trigger_memory_extraction,
|
||||
trigger_personality_extraction,
|
||||
trigger_profile_extraction,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["imports"])
|
||||
|
||||
|
||||
@router.post("/characters/{character_id}/imports", response_model=ImportJobResponse)
|
||||
async def create_import_json(
|
||||
character_id: str,
|
||||
payload: CreateImportRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportJobResponse:
|
||||
return await create_import_from_json(
|
||||
character_id, payload, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post("/characters/{character_id}/imports/upload", response_model=ImportJobResponse)
|
||||
async def create_import_upload(
|
||||
character_id: str,
|
||||
file: UploadFile = File(..., description="上传文件(文本或音频)。"),
|
||||
title: str | None = Form(default=None),
|
||||
source_description: str | None = Form(default=None),
|
||||
speaker_label: str | None = Form(default=None, description="音频说话人标签。"),
|
||||
consent_status: str = Form(default="unknown"),
|
||||
usage_scope: str = Form(default="reference_only"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportJobResponse:
|
||||
return await create_import_from_upload(
|
||||
character_id,
|
||||
file,
|
||||
title=title,
|
||||
source_description=source_description,
|
||||
speaker_label=speaker_label,
|
||||
consent_status=consent_status,
|
||||
usage_scope=usage_scope,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/characters/{character_id}/imports", response_model=Page[ImportJobResponse])
|
||||
async def list_imports(
|
||||
character_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[ImportJobResponse]:
|
||||
return await list_jobs_page(
|
||||
character_id, page=page, page_size=page_size, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/imports/{import_id}",
|
||||
response_model=ImportJobResponse,
|
||||
)
|
||||
async def read_import(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportJobResponse:
|
||||
return await get_import(character_id, import_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/imports/{import_id}/items",
|
||||
response_model=Page[ImportItemResponse],
|
||||
)
|
||||
async def read_import_items(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[ImportItemResponse]:
|
||||
return await list_import_items(
|
||||
character_id,
|
||||
import_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
# —— 候选抽取(5b,异步后台任务)——
|
||||
|
||||
|
||||
@router.post(
|
||||
"/characters/{character_id}/imports/{import_id}/profile-candidates",
|
||||
response_model=ImportJobResponse,
|
||||
)
|
||||
async def trigger_profile_candidates(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportJobResponse:
|
||||
return await trigger_profile_extraction(
|
||||
character_id, import_id, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/imports/{import_id}/profile-candidates",
|
||||
response_model=list[ProfileCandidateResponse],
|
||||
)
|
||||
async def list_profile_candidates_api(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
status: str | None = Query(default=None, description="按审核状态过滤。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[ProfileCandidateResponse]:
|
||||
return await list_profile_candidates(
|
||||
character_id, import_id, status=status, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/characters/{character_id}/imports/{import_id}/memory-candidates",
|
||||
response_model=ImportJobResponse,
|
||||
)
|
||||
async def trigger_memory_candidates(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportJobResponse:
|
||||
return await trigger_memory_extraction(
|
||||
character_id, import_id, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/imports/{import_id}/memory-candidates",
|
||||
response_model=list[MemoryCandidateResponse],
|
||||
)
|
||||
async def list_memory_candidates_api(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
status: str | None = Query(default=None, description="按审核状态过滤。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[MemoryCandidateResponse]:
|
||||
return await list_memory_candidates(
|
||||
character_id, import_id, status=status, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/characters/{character_id}/imports/{import_id}/personality-proposals",
|
||||
response_model=ImportJobResponse,
|
||||
)
|
||||
async def trigger_personality_proposals(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportJobResponse:
|
||||
return await trigger_personality_extraction(
|
||||
character_id, import_id, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
# —— 候选审核应用(5c)——
|
||||
|
||||
|
||||
@router.post(
|
||||
"/characters/{character_id}/profile-candidates/batch-review",
|
||||
response_model=BatchReviewResult,
|
||||
)
|
||||
async def batch_review_profile(
|
||||
character_id: str,
|
||||
payload: BatchReviewRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> BatchReviewResult:
|
||||
return await batch_review_profile_candidates(
|
||||
character_id, payload, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/characters/{character_id}/memory-candidates/batch-review",
|
||||
response_model=BatchReviewResult,
|
||||
)
|
||||
async def batch_review_memory(
|
||||
character_id: str,
|
||||
payload: BatchReviewRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> BatchReviewResult:
|
||||
return await batch_review_memory_candidates(
|
||||
character_id, payload, owner_scope=current_user.owner_scope
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""世谱 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.lorebook import (
|
||||
LorebookCreateRequest,
|
||||
LorebookEntryCreateRequest,
|
||||
LorebookEntryResponse,
|
||||
LorebookEntryUpdateRequest,
|
||||
LorebookResponse,
|
||||
LorebookUpdateRequest,
|
||||
)
|
||||
from app.services.idempotency import run_idempotent
|
||||
from app.services.lorebook import (
|
||||
create_entry,
|
||||
create_lorebook,
|
||||
delete_entry,
|
||||
delete_lorebook,
|
||||
get_lorebook,
|
||||
list_lorebooks,
|
||||
update_entry,
|
||||
update_lorebook,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/lorebooks", tags=["lorebooks"])
|
||||
|
||||
|
||||
@router.post("", response_model=LorebookResponse)
|
||||
async def create_lorebook_api(
|
||||
payload: LorebookCreateRequest,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> LorebookResponse:
|
||||
return await run_idempotent(
|
||||
key=idempotency_key,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
payload=payload,
|
||||
response_type=LorebookResponse,
|
||||
resource_type="lorebook",
|
||||
handler=lambda session: create_lorebook(
|
||||
session, payload, owner_scope=current_user.owner_scope
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[LorebookResponse])
|
||||
async def list_lorebooks_api(
|
||||
character_id: str | None = Query(default=None),
|
||||
scope: str | None = Query(default=None),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[LorebookResponse]:
|
||||
return await list_lorebooks(
|
||||
character_id=character_id,
|
||||
owner_scope=current_user.owner_scope,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{lorebook_id}", response_model=LorebookResponse)
|
||||
async def get_lorebook_api(
|
||||
lorebook_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> LorebookResponse:
|
||||
return await get_lorebook(lorebook_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.patch("/{lorebook_id}", response_model=LorebookResponse)
|
||||
async def update_lorebook_api(
|
||||
lorebook_id: str,
|
||||
payload: LorebookUpdateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> LorebookResponse:
|
||||
return await update_lorebook(lorebook_id, payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.delete("/{lorebook_id}", response_model=LorebookResponse)
|
||||
async def delete_lorebook_api(
|
||||
lorebook_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> LorebookResponse:
|
||||
return await delete_lorebook(lorebook_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post("/{lorebook_id}/entries", response_model=LorebookEntryResponse)
|
||||
async def create_entry_api(
|
||||
lorebook_id: str,
|
||||
payload: LorebookEntryCreateRequest,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> LorebookEntryResponse:
|
||||
return await run_idempotent(
|
||||
key=idempotency_key,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
payload=payload,
|
||||
response_type=LorebookEntryResponse,
|
||||
resource_type="lorebook_entry",
|
||||
handler=lambda session: create_entry(
|
||||
session, lorebook_id, payload, owner_scope=current_user.owner_scope
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{lorebook_id}/entries/{entry_id}", response_model=LorebookEntryResponse)
|
||||
async def update_entry_api(
|
||||
lorebook_id: str,
|
||||
entry_id: str,
|
||||
payload: LorebookEntryUpdateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> LorebookEntryResponse:
|
||||
return await update_entry(
|
||||
lorebook_id, entry_id, payload, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{lorebook_id}/entries/{entry_id}", status_code=204)
|
||||
async def delete_entry_api(
|
||||
lorebook_id: str,
|
||||
entry_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
await delete_entry(lorebook_id, entry_id, owner_scope=current_user.owner_scope)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""MCP server API(/tools/mcp-servers,skills-design §6)。
|
||||
|
||||
支持两种传输协议:streamable-http / stdio。
|
||||
stdio 模式通过 /start、/stop、/restart 控制子进程生命周期。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import status as http_status
|
||||
|
||||
from app.api.v1.deps import get_optional_tool_runtime
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.integrations.mcp.process_manager import get_process_manager
|
||||
from app.schemas.mcp import (
|
||||
CreateMcpServerRequest,
|
||||
McpServerResponse,
|
||||
McpServerStatusResponse,
|
||||
UpdateMcpServerRequest,
|
||||
)
|
||||
from app.services import mcp as mcp_service
|
||||
|
||||
router = APIRouter(prefix="/tools/mcp-servers", tags=["mcp"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=McpServerResponse,
|
||||
status_code=http_status.HTTP_201_CREATED,
|
||||
)
|
||||
async def register_mcp_server(
|
||||
payload: CreateMcpServerRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerResponse:
|
||||
"""注册 MCP server。
|
||||
|
||||
streamable-http:同步拉取工具清单并注册到运行时。
|
||||
stdio:只存 DB,需后续调用 /start 启动子进程后拉取工具。
|
||||
"""
|
||||
return await mcp_service.register_mcp_server(
|
||||
name=payload.name,
|
||||
transport=payload.transport,
|
||||
endpoint_url=payload.endpoint_url,
|
||||
auth_token=payload.auth_token,
|
||||
command=payload.command,
|
||||
args_json=payload.args_json,
|
||||
env_json=payload.env_json,
|
||||
cwd=payload.cwd,
|
||||
status=payload.status,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[McpServerResponse])
|
||||
async def list_mcp_servers(
|
||||
status: str | None = Query(default=None, description="按状态过滤:enabled/disabled"),
|
||||
) -> list[McpServerResponse]:
|
||||
"""列举 MCP server(含工具清单摘要)。"""
|
||||
return await mcp_service.list_mcp_servers(status=status)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{server_id}",
|
||||
response_model=McpServerResponse,
|
||||
)
|
||||
async def update_mcp_server(
|
||||
server_id: str,
|
||||
payload: UpdateMcpServerRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerResponse:
|
||||
"""更新 MCP server 字段(部分更新)。"""
|
||||
return await mcp_service.update_mcp_server(
|
||||
server_id,
|
||||
status=payload.status,
|
||||
endpoint_url=payload.endpoint_url,
|
||||
auth_token=payload.auth_token,
|
||||
command=payload.command,
|
||||
args_json=payload.args_json,
|
||||
env_json=payload.env_json,
|
||||
cwd=payload.cwd,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{server_id}", status_code=http_status.HTTP_204_NO_CONTENT)
|
||||
async def delete_mcp_server(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> None:
|
||||
"""删除 MCP server(先 stop 子进程再删)。"""
|
||||
await mcp_service.delete_mcp_server(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{server_id}/start",
|
||||
response_model=McpServerStatusResponse,
|
||||
)
|
||||
async def start_mcp_server(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerStatusResponse:
|
||||
"""启动 stdio MCP server 子进程,拉取工具并注册。"""
|
||||
return await mcp_service.start_mcp_server(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{server_id}/stop",
|
||||
response_model=McpServerStatusResponse,
|
||||
)
|
||||
async def stop_mcp_server(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerStatusResponse:
|
||||
"""停止 stdio MCP server 子进程,注销工具。"""
|
||||
return await mcp_service.stop_mcp_server(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{server_id}/restart",
|
||||
response_model=McpServerStatusResponse,
|
||||
)
|
||||
async def restart_mcp_server(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerStatusResponse:
|
||||
"""重启 stdio MCP server。"""
|
||||
return await mcp_service.restart_mcp_server(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{server_id}/status",
|
||||
response_model=McpServerStatusResponse,
|
||||
)
|
||||
async def get_mcp_server_status(
|
||||
server_id: str,
|
||||
) -> McpServerStatusResponse:
|
||||
"""查询 MCP server 进程状态。"""
|
||||
return await mcp_service.get_mcp_server_status(
|
||||
server_id,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{server_id}/refresh",
|
||||
response_model=McpServerResponse,
|
||||
)
|
||||
async def refresh_mcp_server_tools(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerResponse:
|
||||
"""重新拉取 MCP server 工具清单并注册到运行时。"""
|
||||
return await mcp_service.refresh_mcp_server_tools(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""记忆 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.memory import MemoryCreateRequest, MemoryResponse, MemoryUpdateRequest
|
||||
from app.services.idempotency import run_idempotent
|
||||
from app.services.memory import create_memory, delete_memory, list_memory_page, update_memory
|
||||
|
||||
router = APIRouter(prefix="/memory", tags=["memory"])
|
||||
|
||||
|
||||
@router.get("", response_model=Page[MemoryResponse])
|
||||
async def list_memory(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
memory_type: str | None = Query(
|
||||
default=None,
|
||||
alias="type",
|
||||
description="按类型过滤(如 user/assistant/system,契约字典 memory.type)。",
|
||||
),
|
||||
source: str | None = Query(
|
||||
default=None,
|
||||
description="按来源过滤(如 user_explicit/big_cycle/import,契约字典 memory.source)。",
|
||||
),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[MemoryResponse]:
|
||||
return await list_memory_page(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
memory_type=memory_type,
|
||||
source=source,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=MemoryResponse)
|
||||
async def create_memory_api(
|
||||
payload: MemoryCreateRequest,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> MemoryResponse:
|
||||
return await run_idempotent(
|
||||
key=idempotency_key,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
payload=payload,
|
||||
response_type=MemoryResponse,
|
||||
resource_type="memory",
|
||||
handler=lambda session: create_memory(
|
||||
session, payload, owner_scope=current_user.owner_scope
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{memory_id}", response_model=MemoryResponse)
|
||||
async def update_memory_api(
|
||||
memory_id: str,
|
||||
payload: MemoryUpdateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> MemoryResponse:
|
||||
return await update_memory(memory_id, payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.delete("/{memory_id}", response_model=MemoryResponse)
|
||||
async def delete_memory_api(
|
||||
memory_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> MemoryResponse:
|
||||
return await delete_memory(memory_id, owner_scope=current_user.owner_scope)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""人格 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.personality import (
|
||||
PersonalityProposalResponse,
|
||||
PersonalityResponse,
|
||||
ProposePersonalityUpdateRequest,
|
||||
)
|
||||
from app.services.personality import (
|
||||
approve_personality_proposal,
|
||||
get_current_personality,
|
||||
list_personality_proposals,
|
||||
list_personality_versions,
|
||||
propose_personality_update,
|
||||
reject_personality_proposal,
|
||||
rollback_personality,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/personality", tags=["personality"])
|
||||
|
||||
|
||||
@router.get("/current", response_model=PersonalityResponse)
|
||||
async def current_personality(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityResponse:
|
||||
return await get_current_personality(owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/versions", response_model=Page[PersonalityResponse])
|
||||
async def personality_versions(
|
||||
character_id: str | None = Query(default=None, description="人物 ID,省略取当前激活人物。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[PersonalityResponse]:
|
||||
return await list_personality_versions(
|
||||
character_id=character_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/proposals", response_model=Page[PersonalityProposalResponse])
|
||||
async def personality_proposals(
|
||||
character_id: str | None = Query(default=None, description="人物 ID,省略取当前激活人物。"),
|
||||
status: str | None = Query(
|
||||
default=None, description="按状态过滤:pending / approved / rejected / applied"
|
||||
),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[PersonalityProposalResponse]:
|
||||
"""人格修改建议列表(待审审批墙数据源)。"""
|
||||
return await list_personality_proposals(
|
||||
character_id=character_id,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
# —— 人格修改建议与版本回滚(api-spec §11) ——
|
||||
|
||||
|
||||
@router.post(
|
||||
"/propose-update",
|
||||
response_model=PersonalityProposalResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def propose_personality_update_endpoint(
|
||||
payload: ProposePersonalityUpdateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityProposalResponse:
|
||||
"""提交人格修改建议;进入 pending 待审核,不直接写入人格版本。"""
|
||||
return await propose_personality_update(
|
||||
character_id=payload.character_id,
|
||||
fields=payload.fields,
|
||||
reason=payload.reason,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/proposals/{proposal_id}/approve",
|
||||
response_model=PersonalityProposalResponse,
|
||||
)
|
||||
async def approve_proposal(
|
||||
proposal_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityProposalResponse:
|
||||
"""审核通过人格建议:派生新人格版本并切换人物当前指针。"""
|
||||
return await approve_personality_proposal(proposal_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/proposals/{proposal_id}/reject",
|
||||
response_model=PersonalityProposalResponse,
|
||||
)
|
||||
async def reject_proposal(
|
||||
proposal_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityProposalResponse:
|
||||
"""驳回人格建议:标记 rejected,不生成新版本。"""
|
||||
return await reject_personality_proposal(proposal_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/rollback", response_model=PersonalityResponse)
|
||||
async def rollback_personality_version(
|
||||
version_id: str,
|
||||
character_id: str | None = Query(default=None, description="人物 ID,省略取当前激活人物。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityResponse:
|
||||
"""回滚到指定人格版本;被取代的旧版本标记 rolled_back_at。"""
|
||||
return await rollback_personality(
|
||||
character_id=character_id, version_id=version_id, owner_scope=current_user.owner_scope
|
||||
)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Run 与 SSE API。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Header, Query, Request
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.application.agent.confirmation import ConfirmationRegistry
|
||||
from app.application.agent.run_registry import AgentRunRegistry
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError
|
||||
from app.core.time import to_iso, utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.repositories import runtime as runtime_repo
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.runtime import (
|
||||
AgentControlRequest,
|
||||
AgentControlResponse,
|
||||
ConfirmationResolutionResponse,
|
||||
EventResponse,
|
||||
RunResponse,
|
||||
)
|
||||
from app.services.event_bus import bus
|
||||
from app.services.runtime import (
|
||||
cancel_run,
|
||||
confirmation_exists_in_run,
|
||||
get_run_response,
|
||||
list_run_event_responses,
|
||||
list_runs_response,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/runs", tags=["runs"])
|
||||
|
||||
|
||||
@router.get("", response_model=Page[RunResponse])
|
||||
async def list_runs(
|
||||
character_id: str | None = Query(default=None, description="按人物过滤。"),
|
||||
session_id: str | None = Query(default=None, description="按会话过滤。"),
|
||||
status: str | None = Query(default=None, description="按状态过滤。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[RunResponse]:
|
||||
"""Run 列表(按创建时间倒序);归档页运行时间河数据源。"""
|
||||
return await list_runs_response(
|
||||
character_id=character_id,
|
||||
session_id=session_id,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{run_id}", response_model=RunResponse)
|
||||
async def get_run(run_id: str) -> RunResponse:
|
||||
return await get_run_response(run_id)
|
||||
|
||||
|
||||
@router.get("/{run_id}/events", response_model=list[EventResponse])
|
||||
async def list_run_events(
|
||||
run_id: str,
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[EventResponse]:
|
||||
"""Run 的事件时间线(按 sequence 升序)。"""
|
||||
return await list_run_event_responses(run_id, limit=limit, offset=offset)
|
||||
|
||||
|
||||
def _get_agent_run_registry(request: Request) -> AgentRunRegistry:
|
||||
registry: AgentRunRegistry | None = getattr(request.app.state, "agent_run_registry", None)
|
||||
if registry is None:
|
||||
raise AppError("service_unavailable", "Agent 运行控制未启用。", status_code=503)
|
||||
return registry
|
||||
|
||||
|
||||
@router.post("/{run_id}/cancel", response_model=RunResponse)
|
||||
async def cancel_run_api(run_id: str, request: Request) -> RunResponse:
|
||||
registry = getattr(request.app.state, "agent_run_registry", None)
|
||||
if registry is not None:
|
||||
await registry.abort(run_id)
|
||||
return await cancel_run(run_id)
|
||||
|
||||
|
||||
@router.post("/{run_id}/steer", response_model=AgentControlResponse)
|
||||
async def steer_run(
|
||||
run_id: str,
|
||||
payload: AgentControlRequest,
|
||||
request: Request,
|
||||
) -> AgentControlResponse:
|
||||
if not _get_agent_run_registry(request).steer(run_id, payload.content):
|
||||
raise AppError("conflict", "Run 当前不在本进程执行,无法 steer。", status_code=409)
|
||||
return AgentControlResponse()
|
||||
|
||||
|
||||
@router.post("/{run_id}/follow-up", response_model=AgentControlResponse)
|
||||
async def follow_up_run(
|
||||
run_id: str,
|
||||
payload: AgentControlRequest,
|
||||
request: Request,
|
||||
) -> AgentControlResponse:
|
||||
if not _get_agent_run_registry(request).follow_up(run_id, payload.content):
|
||||
raise AppError("conflict", "Run 当前不在本进程执行,无法追加 follow-up。", status_code=409)
|
||||
return AgentControlResponse()
|
||||
|
||||
|
||||
def _get_confirmation_registry(request: Request) -> ConfirmationRegistry:
|
||||
"""从 app.state 取 ConfirmationRegistry;未挂载时 503。"""
|
||||
registry: ConfirmationRegistry | None = getattr(
|
||||
request.app.state, "confirmation_registry", None
|
||||
)
|
||||
if registry is None:
|
||||
raise AppError(
|
||||
"service_unavailable",
|
||||
"确认机制未启用(进程内 Future 注册表未挂载)",
|
||||
status_code=503,
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
async def _resolve_confirmation(
|
||||
request: Request,
|
||||
run_id: str,
|
||||
confirmation_id: str,
|
||||
approved: bool,
|
||||
) -> ConfirmationResolutionResponse:
|
||||
"""approve/deny 共用逻辑:通过 ConfirmationRegistry 更新确认状态。"""
|
||||
registry = _get_confirmation_registry(request)
|
||||
if await registry.resolve(run_id, approved, confirmation_id=confirmation_id):
|
||||
return ConfirmationResolutionResponse(approved=approved)
|
||||
|
||||
# 记录已处理/不存在——查 events 表区分 410 与 404
|
||||
event_store = getattr(request.app.state, "event_store", None)
|
||||
if event_store is not None and await confirmation_exists_in_run(
|
||||
event_store, run_id, confirmation_id
|
||||
):
|
||||
raise AppError(
|
||||
"confirmation_expired",
|
||||
"确认已失效(服务可能已重启或已处理),请重新发起对话",
|
||||
status_code=410,
|
||||
)
|
||||
raise AppError("confirmation_not_found", "确认项不存在", status_code=404)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{run_id}/confirmations/{confirmation_id}/approve",
|
||||
response_model=ConfirmationResolutionResponse,
|
||||
)
|
||||
async def approve_confirmation(
|
||||
run_id: str,
|
||||
confirmation_id: str,
|
||||
request: Request,
|
||||
) -> ConfirmationResolutionResponse:
|
||||
"""批准高风险工具执行:触发 Agent 循环恢复(confirmed=True 重试)。"""
|
||||
return await _resolve_confirmation(request, run_id, confirmation_id, approved=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{run_id}/confirmations/{confirmation_id}/deny",
|
||||
response_model=ConfirmationResolutionResponse,
|
||||
)
|
||||
async def deny_confirmation(
|
||||
run_id: str,
|
||||
confirmation_id: str,
|
||||
request: Request,
|
||||
) -> ConfirmationResolutionResponse:
|
||||
"""拒绝高风险工具执行:Agent 循环回填拒绝 ToolMessage。"""
|
||||
return await _resolve_confirmation(request, run_id, confirmation_id, approved=False)
|
||||
|
||||
|
||||
@router.get("/{run_id}/stream")
|
||||
async def stream_run(
|
||||
run_id: str,
|
||||
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
||||
) -> EventSourceResponse:
|
||||
"""Run 事件流(SSE):按 run_id 实时订阅事件增量。
|
||||
|
||||
事件通过进程内 ``bus`` 实时推送(``append_event`` 自动 publish),
|
||||
DB 持久化事件供断线重连(``Last-Event-ID`` 回放)。
|
||||
"""
|
||||
if not settings.enable_sse:
|
||||
raise AppError("invalid_request", "SSE 当前未启用。")
|
||||
async with AsyncSessionLocal() as session:
|
||||
run = await runtime_repo.get_run(session, run_id)
|
||||
if run is None:
|
||||
raise AppError("not_found", "Run 不存在。")
|
||||
return EventSourceResponse(_event_generator(run_id, last_event_id))
|
||||
|
||||
|
||||
async def _event_generator(
|
||||
run_id: str, last_event_id: str | None
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
# 断线重连:解析 Last-Event-ID 对应的 sequence 作为回放起点
|
||||
after_sequence = 0
|
||||
if last_event_id:
|
||||
async with AsyncSessionLocal() as session:
|
||||
previous_event = await runtime_repo.get_event(session, last_event_id)
|
||||
if previous_event and previous_event.run_id == run_id:
|
||||
after_sequence = previous_event.sequence
|
||||
|
||||
last_sequence = after_sequence
|
||||
# 先订阅再回放,避免回放与订阅之间的事件落入间隙
|
||||
queue = bus.subscribe(run_id)
|
||||
try:
|
||||
for event in bus.list_after(run_id, after_sequence=last_sequence):
|
||||
last_sequence = event.sequence
|
||||
yield {
|
||||
"id": event.event_id,
|
||||
"event": event.type,
|
||||
"data": event.model_dump_json(),
|
||||
}
|
||||
if bus.is_terminal(event):
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=settings.sse_heartbeat_seconds)
|
||||
except TimeoutError:
|
||||
yield {
|
||||
"event": "heartbeat",
|
||||
"data": json.dumps(
|
||||
{
|
||||
"event_id": "heartbeat",
|
||||
"run_id": run_id,
|
||||
"type": "heartbeat",
|
||||
"sequence": last_sequence,
|
||||
"visibility": "public",
|
||||
"payload": {},
|
||||
"created_at": to_iso(utc_now()),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
}
|
||||
continue
|
||||
if event.sequence <= last_sequence:
|
||||
continue # 回放阶段已转发,去重
|
||||
last_sequence = event.sequence
|
||||
yield {
|
||||
"id": event.event_id,
|
||||
"event": event.type,
|
||||
"data": event.model_dump_json(),
|
||||
}
|
||||
if bus.is_terminal(event):
|
||||
return
|
||||
finally:
|
||||
bus.unsubscribe(run_id, queue)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""安全 API(api-spec §16)。
|
||||
|
||||
audit-events 按人物/枢过滤查询审计时间线;confirmations 的 approve/deny 负责
|
||||
高风险工具确认:通过 ConfirmationRegistry 更新工具确认状态唤醒 Agent 循环。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
|
||||
from app.api.v1.deps import get_confirmation_registry, get_optional_tool_runtime
|
||||
from app.application.agent.confirmation import ConfirmationRegistry
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.schemas.admin import AuditEntry
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.safety import (
|
||||
CreateToolPolicyRequest,
|
||||
SafetyPolicyItem,
|
||||
ToolPolicyResponse,
|
||||
UpdateToolPolicyRequest,
|
||||
)
|
||||
from app.services import audit as audit_service
|
||||
from app.services import governance as governance_service
|
||||
|
||||
router = APIRouter(prefix="/safety", tags=["safety"])
|
||||
|
||||
|
||||
# —— §16 审计事件查询 ——
|
||||
|
||||
|
||||
@router.get("/audit-events", response_model=Page[AuditEntry])
|
||||
async def list_audit_events(
|
||||
character_id: str | None = Query(default=None, description="按人物过滤。"),
|
||||
pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
) -> Page[AuditEntry]:
|
||||
return await audit_service.query_audit_events(
|
||||
character_id=character_id, pivot=pivot, page=page, page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
# —— §16 工具策略治理 ——
|
||||
|
||||
|
||||
async def _refresh_governor(runtime: ToolRuntime | None) -> None:
|
||||
"""策略写操作后刷新 ToolRuntime 的司命决策器;tool_runtime 未就绪则跳过(重启后从 db 加载)。"""
|
||||
if runtime is not None:
|
||||
await runtime.reload_governor()
|
||||
|
||||
|
||||
@router.get("/policies", response_model=list[SafetyPolicyItem])
|
||||
async def list_safety_policies() -> list[SafetyPolicyItem]:
|
||||
return await governance_service.list_safety_policies()
|
||||
|
||||
|
||||
@router.post("/policies", response_model=ToolPolicyResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_safety_policy(
|
||||
payload: CreateToolPolicyRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> ToolPolicyResponse:
|
||||
response = await governance_service.create_tool_policy(payload)
|
||||
await _refresh_governor(runtime)
|
||||
return response
|
||||
|
||||
|
||||
@router.patch("/policies/{tool}", response_model=ToolPolicyResponse)
|
||||
async def update_safety_policy(
|
||||
tool: str,
|
||||
payload: UpdateToolPolicyRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> ToolPolicyResponse:
|
||||
response = await governance_service.update_tool_policy(tool, payload)
|
||||
await _refresh_governor(runtime)
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/policies/{tool}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_safety_policy(
|
||||
tool: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> Response:
|
||||
await governance_service.delete_tool_policy(tool)
|
||||
await _refresh_governor(runtime)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# —— §16 高风险工具确认 ——
|
||||
|
||||
|
||||
def _not_found(confirmation_id: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"error": {"code": "not_found", "message": f"确认不存在或已被消费: {confirmation_id}"}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _cancelled(confirmation_id: str) -> dict[str, str]:
|
||||
return {"status": "cancelled", "confirmation_id": confirmation_id}
|
||||
|
||||
|
||||
@router.post("/confirmations/{confirmation_id}/approve")
|
||||
async def approve(
|
||||
confirmation_id: str,
|
||||
registry: ConfirmationRegistry = Depends(get_confirmation_registry),
|
||||
) -> dict[str, bool | str]:
|
||||
"""批准工具执行。通过 ConfirmationRegistry 唤醒等待的 Agent 循环。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(PendingConfirmationRecord.id == confirmation_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None or record.run_id is None:
|
||||
raise _not_found(confirmation_id)
|
||||
|
||||
# 更新 resolution 并唤醒进程内 Event
|
||||
ok = await registry.resolve(
|
||||
record.run_id,
|
||||
approved=True,
|
||||
confirmation_id=confirmation_id,
|
||||
)
|
||||
if not ok:
|
||||
raise _not_found(confirmation_id)
|
||||
|
||||
return {"status": "resolved", "approved": True, "run_id": record.run_id}
|
||||
|
||||
|
||||
@router.post("/confirmations/{confirmation_id}/deny")
|
||||
async def deny(
|
||||
confirmation_id: str,
|
||||
registry: ConfirmationRegistry = Depends(get_confirmation_registry),
|
||||
) -> dict[str, str]:
|
||||
"""拒绝工具执行。通过 ConfirmationRegistry 通知 Agent 循环。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(PendingConfirmationRecord.id == confirmation_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None or record.run_id is None:
|
||||
raise _not_found(confirmation_id)
|
||||
|
||||
ok = await registry.resolve(
|
||||
record.run_id,
|
||||
approved=False,
|
||||
confirmation_id=confirmation_id,
|
||||
)
|
||||
if not ok:
|
||||
raise _not_found(confirmation_id)
|
||||
|
||||
return {"status": "cancelled", "confirmation_id": confirmation_id}
|
||||
@@ -0,0 +1,151 @@
|
||||
"""沙箱文件预览 API。
|
||||
|
||||
通过此端点,前端可以读取沙箱中由工具写入的文件内容,实现源码/渲染双模式预览。
|
||||
|
||||
使用场景:
|
||||
- write_file 写入 HTML 文件 → 前端 iframe 渲染预览
|
||||
- write_file 写入 SVG → 前端 img 展示
|
||||
- shell 生成图片/文档 → 前端按 MIME 类型渲染
|
||||
- 任何工具的 stdout 生成的文件
|
||||
|
||||
安全约束:
|
||||
- 用户必须经过 JWT 鉴权
|
||||
- 路径由沙箱端校验(防逃逸)
|
||||
- 可选 run_id/character_id 绑定授权上下文
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.api.v1.deps import get_current_user, get_tool_runtime
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.integrations.sandbox.exceptions import SandboxError, SandboxNotFound
|
||||
from app.schemas.auth import CurrentUser
|
||||
|
||||
router = APIRouter(prefix="/sandbox", tags=["sandbox"])
|
||||
|
||||
|
||||
@router.get("/file")
|
||||
async def read_sandbox_file(
|
||||
path: str = Query(
|
||||
...,
|
||||
description="文件路径。isolated=True 时视为工作区相对路径"
|
||||
"(如 write_file 传入的 ``path`` 参数);"
|
||||
"isolated=False 时作为沙箱 base 下的完整相对路径。",
|
||||
),
|
||||
run_id: str | None = Query(
|
||||
None,
|
||||
description="关联的 run_id(可选,写入授权上下文)。仅用于审计链路,不影响文件可读性。",
|
||||
),
|
||||
character_id: str | None = Query(
|
||||
None,
|
||||
description="人物 ID。提供时自动构造隔离工作区路径"
|
||||
"(``workspace/<character_id>/<path>``),"
|
||||
"与 write_file/shell 等工具的 isolated_workspace 路径一致。",
|
||||
),
|
||||
isolated: bool = Query(
|
||||
True,
|
||||
description="是否自动按 isolated_workspace 规则补全路径前缀。"
|
||||
"write_file 写入的文件在 workspace/<character_id>/ 下,"
|
||||
"传递 write_file 的 path 时保持 True 即可。",
|
||||
),
|
||||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||||
_current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Response:
|
||||
"""读取沙箱内文件,以原始字节返回(MIME 类型由路径扩展名推导)。
|
||||
|
||||
前端根据 MIME 类型自行决定展示方式:
|
||||
- ``text/html`` → iframe sandbox 渲染预览
|
||||
- ``image/*`` → img 标签展示
|
||||
- ``text/*`` → 源码高亮
|
||||
- ``application/pdf`` → PDF 内嵌展示
|
||||
- 其他 → 下载
|
||||
|
||||
路径安全:沙箱端会对路径做 canonicalize + starts_with(base) 校验,
|
||||
防止目录穿越到沙箱 base 之外。
|
||||
"""
|
||||
# 1) 推导 MIME 类型
|
||||
mime_type, _ = mimetypes.guess_type(path)
|
||||
if mime_type is None:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
# 2) 从沙箱读取
|
||||
try:
|
||||
result = await runtime.read_sandbox_file(
|
||||
path=path,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
isolated=isolated,
|
||||
)
|
||||
except SandboxNotFound:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": {"code": "file_not_found", "message": f"文件不存在: {path}"}},
|
||||
) from None
|
||||
except SandboxError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "sandbox_error",
|
||||
"message": f"沙箱文件读取失败: {exc}",
|
||||
}
|
||||
},
|
||||
) from None
|
||||
|
||||
# 3) base64url-no-pad → raw bytes
|
||||
pad_len = -len(result.content_b64) % 4
|
||||
content = base64.urlsafe_b64decode(result.content_b64 + "=" * pad_len)
|
||||
|
||||
return Response(content=content, media_type=mime_type)
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
async def list_sandbox_files(
|
||||
path: str = Query(
|
||||
".",
|
||||
description="目录路径。默认工作区根。",
|
||||
),
|
||||
run_id: str | None = Query(None, description="关联的 run_id(可选)"),
|
||||
character_id: str | None = Query(
|
||||
None,
|
||||
description="人物 ID。提供时自动应用 isolated_workspace 隔离前缀。",
|
||||
),
|
||||
isolated: bool = Query(True, description="是否自动补全隔离前缀"),
|
||||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||||
_current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[dict[str, object]]:
|
||||
"""列出沙箱工作区目录内容。
|
||||
|
||||
返回有序列表,每个条目含 ``name`` / ``kind``(``"file"`` | ``"dir"``)/ ``size``。
|
||||
适用于前端文件浏览器导航。
|
||||
"""
|
||||
try:
|
||||
entries = await runtime.list_sandbox_dir(
|
||||
path=path,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
isolated=isolated,
|
||||
)
|
||||
except SandboxNotFound:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": {"code": "dir_not_found", "message": f"目录不存在: {path}"}},
|
||||
) from None
|
||||
except SandboxError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "sandbox_error",
|
||||
"message": f"沙箱目录读取失败: {exc}",
|
||||
}
|
||||
},
|
||||
) from None
|
||||
|
||||
return [{"name": e.name, "kind": e.kind, "size": e.size} for e in entries]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""设置 API(模型网关状态 + 生成偏好)。
|
||||
|
||||
对应前端 settingsApi。模型网关为只读总览,生成偏好可读可写;
|
||||
真实密钥不进入响应。业务逻辑在 services/preferences(与系统配置 core/config/settings 区分)。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.core.errors import AppError
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.preferences import (
|
||||
GenerationPreferencesResponse,
|
||||
ModelEndpointStatus,
|
||||
ModelGatewayStatus,
|
||||
UpdateEndpointOverrideRequest,
|
||||
UpdateGenerationPreferencesRequest,
|
||||
)
|
||||
from app.services.ai_provider_admin import update_endpoint_override
|
||||
from app.services.preferences import (
|
||||
get_generation_preferences,
|
||||
get_provider_gateway,
|
||||
update_generation_preferences,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
@router.get("/models", response_model=ModelGatewayStatus)
|
||||
async def read_provider_gateway() -> ModelGatewayStatus:
|
||||
return get_provider_gateway()
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/models/{model}/endpoints/{endpoint}",
|
||||
response_model=ModelEndpointStatus,
|
||||
)
|
||||
async def update_model_endpoint(
|
||||
model: str,
|
||||
endpoint: str,
|
||||
payload: UpdateEndpointOverrideRequest,
|
||||
) -> ModelEndpointStatus:
|
||||
"""更新 endpoint 覆盖(base_url / api_key / status / weight / priority,DB 优先 env)。
|
||||
|
||||
写后热重载 registry(reset_model_router)。响应脱敏,不回 base_url / api_key 原值。
|
||||
"""
|
||||
await update_endpoint_override(model, endpoint, payload)
|
||||
gateway = get_provider_gateway()
|
||||
for m in gateway.models:
|
||||
if m.name != model:
|
||||
continue
|
||||
for ep in m.endpoints or []:
|
||||
if ep.name == endpoint:
|
||||
return ep
|
||||
raise AppError("not_found", "endpoint 不存在。")
|
||||
|
||||
|
||||
@router.get("/preferences", response_model=GenerationPreferencesResponse)
|
||||
async def read_generation_preferences(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> GenerationPreferencesResponse:
|
||||
return await get_generation_preferences(owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.patch("/preferences", response_model=GenerationPreferencesResponse)
|
||||
async def update_generation_preferences_api(
|
||||
payload: UpdateGenerationPreferencesRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> GenerationPreferencesResponse:
|
||||
return await update_generation_preferences(payload, owner_scope=current_user.owner_scope)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Skill 能力包 API(/tools/skills,skills-design §6)。"""
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
|
||||
from app.schemas.skills import (
|
||||
CreateSkillRequest,
|
||||
SkillResponse,
|
||||
UpdateSkillRequest,
|
||||
)
|
||||
from app.services import skills as skills_service
|
||||
|
||||
router = APIRouter(prefix="/tools/skills", tags=["skills"])
|
||||
|
||||
|
||||
@router.post("", response_model=SkillResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_skill(payload: CreateSkillRequest) -> SkillResponse:
|
||||
"""注册 Skill 能力包(name 唯一)。"""
|
||||
return await skills_service.create_skill(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
trigger=payload.trigger,
|
||||
prompt_fragment=payload.prompt_fragment,
|
||||
tool_bindings=payload.tool_bindings,
|
||||
risk_level=payload.risk_level,
|
||||
status=payload.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[SkillResponse])
|
||||
async def list_skills(
|
||||
status: str | None = Query(default=None, description="按状态过滤:enabled/disabled"),
|
||||
) -> list[SkillResponse]:
|
||||
"""列举 Skill(可按 status 过滤)。"""
|
||||
return await skills_service.list_skills(status=status)
|
||||
|
||||
|
||||
@router.patch("/{skill_id}", response_model=SkillResponse)
|
||||
async def update_skill(skill_id: str, payload: UpdateSkillRequest) -> SkillResponse:
|
||||
"""更新 / 启用 / 禁用 Skill。"""
|
||||
return await skills_service.update_skill(
|
||||
skill_id,
|
||||
description=payload.description,
|
||||
trigger=payload.trigger,
|
||||
prompt_fragment=payload.prompt_fragment,
|
||||
tool_bindings=payload.tool_bindings,
|
||||
risk_level=payload.risk_level,
|
||||
status=payload.status,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{skill_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_skill(skill_id: str) -> None:
|
||||
"""删除 Skill。"""
|
||||
await skills_service.delete_skill(skill_id)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""辰正时态 API(设计 §10)。
|
||||
|
||||
挂载于 /api/v1/time。事件不走 REST(走 EventStore 订阅)。路由薄层:参数校验 → 调
|
||||
service → 转响应;session 由 service 自管(deps.py 惯例)。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from app.api.v1.deps import get_current_user, get_event_store
|
||||
from app.core.config import settings
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.time import (
|
||||
AcquaintanceResponse,
|
||||
AnniversaryCreateRequest,
|
||||
AnniversaryResponse,
|
||||
AnniversaryUpdateRequest,
|
||||
SnapshotResponse,
|
||||
TimeConfigResponse,
|
||||
)
|
||||
from app.services.time_sense import (
|
||||
create_anniversary_for_active,
|
||||
delete_anniversary_or_404,
|
||||
get_acquaintance_for_active,
|
||||
get_anniversary_or_404,
|
||||
get_live_snapshot,
|
||||
list_anniversaries_for_active,
|
||||
update_anniversary_or_404,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/time", tags=["time"])
|
||||
|
||||
|
||||
@router.get("/snapshot", response_model=SnapshotResponse)
|
||||
async def get_snapshot(
|
||||
request: Request, current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> SnapshotResponse:
|
||||
"""当前 TemporalSnapshot + ModulationFactors + context_text。"""
|
||||
result = await get_live_snapshot(get_event_store(request), owner_scope=current_user.owner_scope)
|
||||
return SnapshotResponse(
|
||||
snapshot=result.snapshot,
|
||||
factors=result.factors,
|
||||
context_text=result.context_text,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/anniversaries", response_model=list[AnniversaryResponse])
|
||||
async def list_anniversaries_api(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[AnniversaryResponse]:
|
||||
items = await list_anniversaries_for_active(owner_scope=current_user.owner_scope)
|
||||
return [AnniversaryResponse.model_validate(a) for a in items]
|
||||
|
||||
|
||||
@router.post("/anniversaries", response_model=AnniversaryResponse, status_code=201)
|
||||
async def create_anniversary_api(
|
||||
payload: AnniversaryCreateRequest, current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> AnniversaryResponse:
|
||||
created = await create_anniversary_for_active(payload, owner_scope=current_user.owner_scope)
|
||||
return AnniversaryResponse.model_validate(created)
|
||||
|
||||
|
||||
@router.get("/anniversaries/{anniv_id}", response_model=AnniversaryResponse)
|
||||
async def get_anniversary_api(
|
||||
anniv_id: str, current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> AnniversaryResponse:
|
||||
return AnniversaryResponse.model_validate(
|
||||
await get_anniversary_or_404(anniv_id, owner_scope=current_user.owner_scope)
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/anniversaries/{anniv_id}", response_model=AnniversaryResponse)
|
||||
async def update_anniversary_api(
|
||||
anniv_id: str,
|
||||
payload: AnniversaryUpdateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> AnniversaryResponse:
|
||||
return AnniversaryResponse.model_validate(
|
||||
await update_anniversary_or_404(anniv_id, payload, owner_scope=current_user.owner_scope)
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/anniversaries/{anniv_id}", status_code=204)
|
||||
async def delete_anniversary_api(
|
||||
anniv_id: str, current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> None:
|
||||
await delete_anniversary_or_404(anniv_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/acquaintance", response_model=AcquaintanceResponse)
|
||||
async def get_acquaintance_api(
|
||||
request: Request, current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> AcquaintanceResponse:
|
||||
acq = await get_acquaintance_for_active(
|
||||
get_event_store(request), owner_scope=current_user.owner_scope
|
||||
)
|
||||
return AcquaintanceResponse(total_days=acq.total_days, natural_language=acq.natural_language)
|
||||
|
||||
|
||||
@router.get("/config", response_model=TimeConfigResponse)
|
||||
async def get_time_config_api(
|
||||
current_user: CurrentUser = Depends(get_current_user), # noqa: ARG001
|
||||
) -> TimeConfigResponse:
|
||||
return TimeConfigResponse(
|
||||
user_timezone=settings.user_timezone,
|
||||
home_timezone=settings.home_timezone,
|
||||
default_city=settings.default_city,
|
||||
offline_active_minutes=settings.offline_active_minutes,
|
||||
offline_short_absence_minutes=settings.offline_short_absence_minutes,
|
||||
offline_long_absence_minutes=settings.offline_long_absence_minutes,
|
||||
offline_reunion_impact_minutes=settings.offline_reunion_impact_minutes,
|
||||
care_enabled=settings.care_enabled,
|
||||
jetlag_threshold_minutes=settings.jetlag_threshold_minutes,
|
||||
)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""工具调用 API(api-spec §15)。
|
||||
|
||||
- GET /tools:列出已注册工具策略元数据(从 tool_policies 表,含 executable 派生)。
|
||||
- POST /tools:注册自定义工具策略(百工枢器用治理声明,记审计)。
|
||||
- GET /tools/calls/{tool_call_id}:单次工具调用事件详情。
|
||||
- PATCH /tools/{tool_name}:编辑工具策略元数据(spec §15: risk_level 等)。
|
||||
- DELETE /tools/{tool_name}:注销自定义工具(builtin 不可删)。
|
||||
- POST /tools/{tool_name}/invoke:调用工具;高风险返回 requires_confirmation + confirmation_id,
|
||||
交由 /safety/confirmations/{id}/approve 消费。
|
||||
- POST /tools/{tool_name}/invoke/stream:流式调用(SSE),实时推送 tool.stdout/tool.stderr
|
||||
增量与 tool.completed;司命 deny/ask-未确认/沙箱错误分别以 tool.failed / tool.requires_confirmation 结束流。
|
||||
|
||||
策略写操作(POST/PATCH/DELETE)后刷新 ToolRuntime 的司命决策器,使变更即时生效;
|
||||
tool_runtime 未就绪时仅写库(重启后从 db 加载)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.api.v1.deps import get_confirmation_store, get_optional_tool_runtime, get_tool_runtime
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.integrations.sandbox.exceptions import SandboxError
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
from app.modules.safety.confirmation import (
|
||||
ConfirmationStore,
|
||||
PendingConfirmation,
|
||||
new_confirmation_id,
|
||||
)
|
||||
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation
|
||||
from app.schemas.safety import (
|
||||
CreateToolPolicyRequest,
|
||||
ToolCallDetail,
|
||||
ToolPolicyResponse,
|
||||
UpdateToolPolicyRequest,
|
||||
)
|
||||
from app.schemas.skills import ToolCapability
|
||||
from app.services import governance as governance_service
|
||||
from app.services import skills as skills_service
|
||||
|
||||
router = APIRouter(prefix="/tools", tags=["tools"])
|
||||
|
||||
|
||||
class InvokeRequest(BaseModel):
|
||||
input: dict[str, Any]
|
||||
run_id: str | None = None
|
||||
character_id: str | None = None
|
||||
trace_id: str | None = None
|
||||
confirmed: bool = False
|
||||
|
||||
|
||||
class ToolResultPayload(BaseModel):
|
||||
ok: bool
|
||||
exit_code: int | None = None
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
duration_ms: int = 0
|
||||
timeout: bool = False
|
||||
truncated: bool = False
|
||||
trace_id: str = ""
|
||||
|
||||
|
||||
class CompletedResponse(BaseModel):
|
||||
status: str = "completed"
|
||||
result: ToolResultPayload
|
||||
|
||||
|
||||
class RequiresConfirmationResponse(BaseModel):
|
||||
status: str = "requires_confirmation"
|
||||
confirmation_id: str
|
||||
risk_level: str
|
||||
reason: str
|
||||
|
||||
|
||||
async def _sync_confirmation_record(pending: PendingConfirmation) -> None:
|
||||
"""把 InMemoryConfirmationStore 的待确认记录同步持久化到 DB。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
record = PendingConfirmationRecord(
|
||||
id=pending.confirmation_id,
|
||||
tool=pending.tool,
|
||||
input_json=json.dumps(pending.input) if pending.input else "{}",
|
||||
risk_level=pending.risk_level,
|
||||
reason=pending.reason or "",
|
||||
run_id=pending.run_id,
|
||||
character_id=pending.character_id,
|
||||
trace_id=pending.trace_id,
|
||||
status="pending",
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# —— §15 工具策略治理 ——
|
||||
|
||||
|
||||
@router.get("", response_model=list[ToolPolicyResponse])
|
||||
async def list_tools() -> list[ToolPolicyResponse]:
|
||||
return await governance_service.list_tool_policies()
|
||||
|
||||
|
||||
@router.get("/capabilities", response_model=list[ToolCapability])
|
||||
async def list_capabilities(
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> list[ToolCapability]:
|
||||
"""统一列举所有能力(builtin / mcp / skill,含来源标记,skills-design §6)。"""
|
||||
return await skills_service.list_all_capabilities(runtime)
|
||||
|
||||
|
||||
@router.post("", response_model=ToolPolicyResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register_tool(
|
||||
payload: CreateToolPolicyRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> ToolPolicyResponse:
|
||||
response = await governance_service.create_tool_policy(payload)
|
||||
if runtime is not None:
|
||||
await runtime.reload_governor()
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/calls/{tool_call_id}", response_model=ToolCallDetail)
|
||||
async def get_tool_call(tool_call_id: str) -> ToolCallDetail:
|
||||
return await governance_service.get_tool_call(tool_call_id)
|
||||
|
||||
|
||||
@router.patch("/{tool_name}", response_model=ToolPolicyResponse)
|
||||
async def update_tool(
|
||||
tool_name: str,
|
||||
payload: UpdateToolPolicyRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> ToolPolicyResponse:
|
||||
response = await governance_service.update_tool_policy(tool_name, payload)
|
||||
if runtime is not None:
|
||||
await runtime.reload_governor()
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{tool_name}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tool(
|
||||
tool_name: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> Response:
|
||||
await governance_service.delete_tool_policy(tool_name)
|
||||
if runtime is not None:
|
||||
await runtime.reload_governor()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# —— §15 工具调用 ——
|
||||
|
||||
|
||||
@router.post("/{tool_name}/invoke", response_model=None)
|
||||
async def invoke_tool(
|
||||
tool_name: str,
|
||||
body: InvokeRequest,
|
||||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||||
confirmations: ConfirmationStore = Depends(get_confirmation_store),
|
||||
) -> CompletedResponse | RequiresConfirmationResponse:
|
||||
try:
|
||||
result = await runtime.execute(
|
||||
tool_name,
|
||||
body.input,
|
||||
run_id=body.run_id,
|
||||
character_id=body.character_id,
|
||||
trace_id=body.trace_id,
|
||||
confirmed=body.confirmed,
|
||||
)
|
||||
except RequiresConfirmation as exc:
|
||||
pending = await confirmations.create(
|
||||
PendingConfirmation(
|
||||
confirmation_id=new_confirmation_id(),
|
||||
tool=tool_name,
|
||||
input=body.input,
|
||||
risk_level=exc.risk_level or "L2",
|
||||
reason=exc.message,
|
||||
run_id=body.run_id,
|
||||
character_id=body.character_id,
|
||||
trace_id=body.trace_id,
|
||||
)
|
||||
)
|
||||
# 同步持久化 PendingConfirmationRecord(供 safety/confirmations 新端点查找)
|
||||
await _sync_confirmation_record(pending)
|
||||
return RequiresConfirmationResponse(
|
||||
confirmation_id=pending.confirmation_id,
|
||||
risk_level=pending.risk_level,
|
||||
reason=pending.reason,
|
||||
)
|
||||
|
||||
return CompletedResponse(
|
||||
result=ToolResultPayload(
|
||||
ok=result.ok,
|
||||
exit_code=result.exit_code,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
duration_ms=result.duration_ms,
|
||||
timeout=result.timeout,
|
||||
truncated=result.truncated,
|
||||
trace_id=result.trace_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{tool_name}/invoke/stream")
|
||||
async def invoke_tool_stream(
|
||||
tool_name: str,
|
||||
body: InvokeRequest,
|
||||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||||
) -> EventSourceResponse:
|
||||
"""流式调用工具(SSE)。
|
||||
|
||||
事件:``tool.stdout`` / ``tool.stderr``(data 为 ``{"data":"<base64>"}``,前端解码)、
|
||||
``tool.completed``(data 为结束汇总 JSON)。司命 deny → ``tool.failed``;
|
||||
ask 且未确认 → ``tool.requires_confirmation``(前端应先走非流式 invoke 完成 confirmation,
|
||||
再以 confirmed=True 调用本端点);沙箱错误 → ``tool.failed``。
|
||||
"""
|
||||
|
||||
async def event_gen():
|
||||
try:
|
||||
async for ev in runtime.execute_stream(
|
||||
tool_name,
|
||||
body.input,
|
||||
run_id=body.run_id,
|
||||
character_id=body.character_id,
|
||||
trace_id=body.trace_id,
|
||||
confirmed=body.confirmed,
|
||||
):
|
||||
if ev.kind in ("stdout", "stderr"):
|
||||
yield {"event": f"tool.{ev.kind}", "data": json.dumps({"data": ev.payload})}
|
||||
elif ev.kind == "done":
|
||||
yield {"event": "tool.completed", "data": json.dumps(ev.payload)}
|
||||
except RequiresConfirmation as exc:
|
||||
yield {
|
||||
"event": "tool.requires_confirmation",
|
||||
"data": json.dumps({"risk_level": exc.risk_level, "reason": exc.message}),
|
||||
}
|
||||
except PermissionDenied as exc:
|
||||
yield {
|
||||
"event": "tool.failed",
|
||||
"data": json.dumps({"error_code": "permission_denied", "error_message": str(exc)}),
|
||||
}
|
||||
except SandboxError as exc:
|
||||
yield {
|
||||
"event": "tool.failed",
|
||||
"data": json.dumps({"error_code": exc.code, "error_message": str(exc)}),
|
||||
}
|
||||
|
||||
return EventSourceResponse(event_gen())
|
||||
@@ -0,0 +1,95 @@
|
||||
"""世界模型 API(河图枢运行时,api-spec §12)。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.world import (
|
||||
WorldEntityCreateRequest,
|
||||
WorldEntityResponse,
|
||||
WorldGraphResponse,
|
||||
WorldRelationCreateRequest,
|
||||
WorldRelationResponse,
|
||||
)
|
||||
from app.services.world import (
|
||||
create_entity,
|
||||
create_relation,
|
||||
delete_relation,
|
||||
get_entity_by_id,
|
||||
get_graph,
|
||||
list_entities_page,
|
||||
list_relations_page,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/world", tags=["world"])
|
||||
|
||||
|
||||
@router.get("/entities", response_model=Page[WorldEntityResponse])
|
||||
async def list_entities(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
character_id: str | None = Query(default=None, description="所属人物;省略取当前激活。"),
|
||||
type: str | None = Query(default=None, description="按实体类型过滤(契约字典 §11.1)。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[WorldEntityResponse]:
|
||||
return await list_entities_page(
|
||||
character_id,
|
||||
entity_type=type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/entities", response_model=WorldEntityResponse)
|
||||
async def create_entity_api(
|
||||
payload: WorldEntityCreateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> WorldEntityResponse:
|
||||
return await create_entity(payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/entities/{entity_id}", response_model=WorldEntityResponse)
|
||||
async def get_entity(
|
||||
entity_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> WorldEntityResponse:
|
||||
return await get_entity_by_id(entity_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/relations", response_model=Page[WorldRelationResponse])
|
||||
async def list_relations(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
character_id: str | None = Query(default=None, description="所属人物;省略取当前激活。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[WorldRelationResponse]:
|
||||
return await list_relations_page(
|
||||
character_id, page=page, page_size=page_size, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post("/relations", response_model=WorldRelationResponse)
|
||||
async def create_relation_api(
|
||||
payload: WorldRelationCreateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> WorldRelationResponse:
|
||||
return await create_relation(payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.delete("/relations/{relation_id}", status_code=204)
|
||||
async def delete_relation_api(
|
||||
relation_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
await delete_relation(relation_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/graph", response_model=WorldGraphResponse)
|
||||
async def world_graph(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
character_id: str | None = Query(default=None, description="所属人物;省略取当前激活。"),
|
||||
) -> WorldGraphResponse:
|
||||
"""返回人物的完整世界图谱(无分页),供前端 D3/Force 图谱渲染。"""
|
||||
return await get_graph(character_id, owner_scope=current_user.owner_scope)
|
||||
Whitespace-only changes.
@@ -0,0 +1,82 @@
|
||||
"""非流式 Agent 应用层:工具调用闭环编排。
|
||||
|
||||
入口:
|
||||
- ``run_agent_turn`` 执行一轮带工具调用的对话循环
|
||||
- ``AgentTurnResult`` 循环产出
|
||||
- ``to_ai_provider_tools`` 把 ToolSpec 转成 OpenAI function schema dict
|
||||
- ``default_tools`` 返回全部内置工具(依赖司命 + 确认流控风险)
|
||||
"""
|
||||
|
||||
from app.application.agent.agent import (
|
||||
Agent,
|
||||
AgentConfig,
|
||||
AgentState,
|
||||
AgentStatus,
|
||||
PendingMessageQueue,
|
||||
QueueMode,
|
||||
)
|
||||
from app.application.agent.confirmation import ConfirmationRegistry
|
||||
from app.application.agent.extensions import (
|
||||
AfterToolCallDecision,
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
AgentRunContext,
|
||||
BeforeToolCallDecision,
|
||||
CancellationToken,
|
||||
ExtensionRegistry,
|
||||
ToolCallContext,
|
||||
TurnSnapshot,
|
||||
)
|
||||
from app.application.agent.prompt_composer import compose_system_messages
|
||||
from app.application.agent.python_skills import (
|
||||
PythonSkillActivation,
|
||||
PythonSkillCatalog,
|
||||
PythonSkillDefinition,
|
||||
activate_python_skills,
|
||||
discover_python_skills,
|
||||
)
|
||||
from app.application.agent.run_registry import AgentRunRegistry
|
||||
from app.application.agent.runtime import AgentTurnResult, run_agent_turn
|
||||
from app.application.agent.skill_loader import load_skill_manifests
|
||||
from app.application.agent.tasks import (
|
||||
next_step_for_tool,
|
||||
step_event_payload,
|
||||
task_from_deliberative_plan,
|
||||
task_plan_payload,
|
||||
)
|
||||
from app.application.agent.tools import default_tools, to_ai_provider_tools
|
||||
|
||||
__all__ = [
|
||||
"AfterToolCallDecision",
|
||||
"Agent",
|
||||
"AgentConfig",
|
||||
"AgentContext",
|
||||
"AgentEvent",
|
||||
"AgentRunContext",
|
||||
"AgentRunRegistry",
|
||||
"AgentState",
|
||||
"AgentStatus",
|
||||
"AgentTurnResult",
|
||||
"BeforeToolCallDecision",
|
||||
"CancellationToken",
|
||||
"ConfirmationRegistry",
|
||||
"ExtensionRegistry",
|
||||
"PendingMessageQueue",
|
||||
"PythonSkillActivation",
|
||||
"PythonSkillCatalog",
|
||||
"PythonSkillDefinition",
|
||||
"QueueMode",
|
||||
"ToolCallContext",
|
||||
"TurnSnapshot",
|
||||
"activate_python_skills",
|
||||
"compose_system_messages",
|
||||
"default_tools",
|
||||
"discover_python_skills",
|
||||
"load_skill_manifests",
|
||||
"next_step_for_tool",
|
||||
"run_agent_turn",
|
||||
"step_event_payload",
|
||||
"task_from_deliberative_plan",
|
||||
"task_plan_payload",
|
||||
"to_ai_provider_tools",
|
||||
]
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Pi agent-core 风格的有状态 Agent 门面。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field, replace
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from app.ai_provider.types.chat import ChatChunk, TokenUsage
|
||||
from app.ai_provider.types.messages import BaseMessage, HumanMessage
|
||||
from app.application.agent.confirmation import ConfirmationRegistry
|
||||
from app.application.agent.extensions import (
|
||||
AgentEvent,
|
||||
AgentRunContext,
|
||||
CancellationToken,
|
||||
ExtensionRegistry,
|
||||
)
|
||||
from app.application.agent.runtime import AgentTurnResult, run_agent_turn
|
||||
from app.modules.tool_runtime.definition import ToolRuntimeLike
|
||||
|
||||
|
||||
class AgentStatus(StrEnum):
|
||||
idle = "idle"
|
||||
running = "running"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class QueueMode(StrEnum):
|
||||
all = "all"
|
||||
one_at_a_time = "one-at-a-time"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentState:
|
||||
"""有状态 Agent 的进程内快照。"""
|
||||
|
||||
messages: list[BaseMessage] = field(default_factory=list)
|
||||
status: AgentStatus = AgentStatus.idle
|
||||
streaming_message: str = ""
|
||||
pending_tool_calls: set[str] = field(default_factory=set)
|
||||
error_message: str | None = None
|
||||
usage: TokenUsage = field(default_factory=TokenUsage)
|
||||
|
||||
|
||||
class PendingMessageQueue:
|
||||
"""steering/follow-up 共用的小型 FIFO。"""
|
||||
|
||||
def __init__(self, mode: QueueMode = QueueMode.one_at_a_time) -> None:
|
||||
self.mode = mode
|
||||
self._messages: list[BaseMessage] = []
|
||||
|
||||
def put(self, message: BaseMessage) -> None:
|
||||
self._messages.append(message)
|
||||
|
||||
def drain(self) -> list[BaseMessage]:
|
||||
if not self._messages:
|
||||
return []
|
||||
if self.mode is QueueMode.all:
|
||||
messages, self._messages = self._messages, []
|
||||
return messages
|
||||
return [self._messages.pop(0)]
|
||||
|
||||
def clear(self) -> None:
|
||||
self._messages.clear()
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._messages)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentConfig:
|
||||
"""Stateful Agent 的稳定运行配置。"""
|
||||
|
||||
tools: tuple[dict[str, Any], ...]
|
||||
tool_runtime: ToolRuntimeLike | None
|
||||
model_hint: str | None = None
|
||||
thinking_enabled: bool | None = None
|
||||
reasoning_effort: str | None = None
|
||||
max_iterations: int = 8
|
||||
confirmation_registry: ConfirmationRegistry | None = None
|
||||
confirmation_timeout: float = 300.0
|
||||
on_turn: Callable[[], Awaitable[None]] | None = None
|
||||
on_chunk: Callable[[ChatChunk], Awaitable[None]] | None = None
|
||||
on_tool_event: Callable[[str, dict[str, Any]], Awaitable[None]] | None = None
|
||||
on_confirm_timeout: Callable[[str], Awaitable[None]] | None = None
|
||||
|
||||
|
||||
class Agent:
|
||||
"""管理状态、消息队列、取消与扩展 Hook;低层循环仍由 run_agent_turn 负责。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AgentConfig,
|
||||
*,
|
||||
messages: list[BaseMessage] | None = None,
|
||||
run_context: AgentRunContext | None = None,
|
||||
extensions: ExtensionRegistry | None = None,
|
||||
runner: Callable[..., Awaitable[AgentTurnResult]] = run_agent_turn,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.state = AgentState(messages=list(messages or []))
|
||||
self.run_context = run_context or AgentRunContext()
|
||||
self.extensions = extensions or ExtensionRegistry()
|
||||
self._runner = runner
|
||||
self.steering = PendingMessageQueue()
|
||||
self.follow_ups = PendingMessageQueue()
|
||||
self._active_task: asyncio.Task[AgentTurnResult] | None = None
|
||||
self._accepting_messages = False
|
||||
self._idle = asyncio.Event()
|
||||
self._idle.set()
|
||||
|
||||
def steer(self, message: str | BaseMessage) -> bool:
|
||||
if not self._accepting_messages:
|
||||
return False
|
||||
self.steering.put(self._as_message(message))
|
||||
return True
|
||||
|
||||
def follow_up(self, message: str | BaseMessage) -> bool:
|
||||
if not self._accepting_messages:
|
||||
return False
|
||||
self.follow_ups.put(self._as_message(message))
|
||||
return True
|
||||
|
||||
async def prompt(self, message: str | BaseMessage) -> AgentTurnResult:
|
||||
if self.state.status is AgentStatus.running:
|
||||
raise RuntimeError("Agent 正在运行,请使用 steer 或 follow_up")
|
||||
self.state.messages.append(self._as_message(message))
|
||||
return await self._run()
|
||||
|
||||
async def continue_run(self) -> AgentTurnResult:
|
||||
if self.state.status is AgentStatus.running:
|
||||
raise RuntimeError("Agent 正在运行")
|
||||
return await self._run()
|
||||
|
||||
async def abort(self) -> bool:
|
||||
task = self._active_task
|
||||
if task is None or task.done():
|
||||
return False
|
||||
self.run_context.cancellation.cancel()
|
||||
task.cancel()
|
||||
return True
|
||||
|
||||
async def wait_for_idle(self) -> None:
|
||||
await self._idle.wait()
|
||||
|
||||
async def reset(self) -> None:
|
||||
await self.abort()
|
||||
await self.wait_for_idle()
|
||||
self.state = AgentState()
|
||||
self.steering.clear()
|
||||
self.follow_ups.clear()
|
||||
|
||||
async def _run(self) -> AgentTurnResult:
|
||||
if self.run_context.cancellation.cancelled:
|
||||
self.run_context = replace(self.run_context, cancellation=CancellationToken())
|
||||
self.state.status = AgentStatus.running
|
||||
self.state.streaming_message = ""
|
||||
self.state.pending_tool_calls.clear()
|
||||
self.state.error_message = None
|
||||
self._accepting_messages = True
|
||||
self._idle.clear()
|
||||
try:
|
||||
await self.extensions.publish(AgentEvent("agent.started"), self.run_context)
|
||||
task = asyncio.create_task(self._run_loop())
|
||||
self._active_task = task
|
||||
result = await task
|
||||
self._accepting_messages = False
|
||||
self.state.status = AgentStatus.completed
|
||||
self.state.streaming_message = result.content
|
||||
self.state.usage = result.usage
|
||||
await self.extensions.publish(
|
||||
AgentEvent("agent.ended", {"iterations": result.iterations}), self.run_context
|
||||
)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
self._accepting_messages = False
|
||||
self.state.status = AgentStatus.cancelled
|
||||
await self.extensions.publish(AgentEvent("agent.cancelled"), self.run_context)
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._accepting_messages = False
|
||||
self.state.status = AgentStatus.failed
|
||||
self.state.error_message = str(exc)
|
||||
with suppress(Exception):
|
||||
await self.extensions.publish(
|
||||
AgentEvent("agent.failed", {"error": str(exc)}), self.run_context
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
self._accepting_messages = False
|
||||
with suppress(Exception):
|
||||
await self.extensions.publish(
|
||||
AgentEvent("agent.settled", {"status": self.state.status.value}),
|
||||
self.run_context,
|
||||
)
|
||||
self._active_task = None
|
||||
self._idle.set()
|
||||
|
||||
async def _run_loop(self) -> AgentTurnResult:
|
||||
remaining = self.config.max_iterations
|
||||
total_iterations = 0
|
||||
total_usage = TokenUsage()
|
||||
total_tool_history: list[dict[str, Any]] = []
|
||||
final = AgentTurnResult(content="", reasoning="")
|
||||
while remaining > 0:
|
||||
working_messages = list(self.state.messages)
|
||||
final = await self._runner(
|
||||
messages=working_messages,
|
||||
tools=list(self.config.tools),
|
||||
tool_runtime=self.config.tool_runtime,
|
||||
model_hint=self.config.model_hint,
|
||||
thinking_enabled=self.config.thinking_enabled,
|
||||
reasoning_effort=self.config.reasoning_effort,
|
||||
max_iterations=remaining,
|
||||
run_id=self.run_context.run_id,
|
||||
character_id=self.run_context.character_id,
|
||||
trace_id=self.run_context.trace_id,
|
||||
on_turn=self.config.on_turn,
|
||||
on_chunk=self._on_chunk,
|
||||
on_tool_event=self._on_tool_event,
|
||||
confirmation_registry=self.config.confirmation_registry,
|
||||
confirmation_timeout=self.config.confirmation_timeout,
|
||||
on_confirm_timeout=self.config.on_confirm_timeout,
|
||||
extensions=self.extensions,
|
||||
run_context=self.run_context,
|
||||
get_steering_messages=self.steering.drain,
|
||||
)
|
||||
self.state.messages = working_messages
|
||||
total_iterations += final.iterations
|
||||
total_usage += final.usage
|
||||
total_tool_history.extend(final.tool_history)
|
||||
remaining -= final.iterations
|
||||
if final.truncated:
|
||||
return AgentTurnResult(
|
||||
content=final.content,
|
||||
reasoning=final.reasoning,
|
||||
tool_history=total_tool_history,
|
||||
iterations=total_iterations,
|
||||
truncated=True,
|
||||
usage=total_usage,
|
||||
)
|
||||
if remaining <= 0:
|
||||
return AgentTurnResult(
|
||||
content=final.content,
|
||||
reasoning=final.reasoning,
|
||||
tool_history=total_tool_history,
|
||||
iterations=total_iterations,
|
||||
truncated=bool(self.follow_ups),
|
||||
usage=total_usage,
|
||||
)
|
||||
follow_ups = self.follow_ups.drain()
|
||||
if not follow_ups:
|
||||
return AgentTurnResult(
|
||||
content=final.content,
|
||||
reasoning=final.reasoning,
|
||||
tool_history=total_tool_history,
|
||||
iterations=total_iterations,
|
||||
truncated=final.truncated,
|
||||
usage=total_usage,
|
||||
)
|
||||
self.state.messages.extend(follow_ups)
|
||||
return final
|
||||
|
||||
async def _on_chunk(self, chunk: ChatChunk) -> None:
|
||||
if chunk.delta_content:
|
||||
self.state.streaming_message += chunk.delta_content
|
||||
if self.config.on_chunk is not None:
|
||||
await self.config.on_chunk(chunk)
|
||||
|
||||
async def _on_tool_event(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||
call_id = str(payload.get("tool_call_id", ""))
|
||||
if event_type == "tool.started" and call_id:
|
||||
self.state.pending_tool_calls.add(call_id)
|
||||
elif event_type in ("tool.completed", "tool.failed") and call_id:
|
||||
self.state.pending_tool_calls.discard(call_id)
|
||||
# 先转发 SSE(用户可见流优先),再把同一事件发布进扩展事件流,
|
||||
# 让 AgentEventListener 也能观测工具级生命周期。
|
||||
# 扩展 listener 异常被吞掉,避免观测副菜阻断工具事件主流程。
|
||||
if self.config.on_tool_event is not None:
|
||||
await self.config.on_tool_event(event_type, payload)
|
||||
with suppress(Exception):
|
||||
await self.extensions.publish(
|
||||
AgentEvent(event_type, payload), self.run_context
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _as_message(message: str | BaseMessage) -> BaseMessage:
|
||||
return HumanMessage(content=message) if isinstance(message, str) else message
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Agent",
|
||||
"AgentConfig",
|
||||
"AgentState",
|
||||
"AgentStatus",
|
||||
"PendingMessageQueue",
|
||||
"QueueMode",
|
||||
]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""混合确认注册表 — 同进程 Event 唤醒 + 重启后 DB polling 兜底。
|
||||
|
||||
Agent 循环在工具需要确认时:
|
||||
1. 写 PendingConfirmationRecord 到 DB(持久化挂起状态)
|
||||
2. 调 ``ConfirmationRegistry.wait(run_id)`` — 同进程用 Event 0 延迟唤醒,
|
||||
重启后 polling DB 兜底
|
||||
|
||||
approve/deny 端点统一调 ``registry.resolve(run_id, approved)``:
|
||||
- 更新 PendingConfirmationRecord.status + resolved_at
|
||||
- 同一进程直接 set Event
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import or_, select, update
|
||||
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
|
||||
|
||||
class ConfirmationRegistry:
|
||||
"""混合确认注册表。
|
||||
|
||||
- 同进程:``asyncio.Event`` 直接唤醒,0 延迟
|
||||
- 重启后:Event 不存在 → polling ``PendingConfirmationRecord.status`` 兜底
|
||||
- approve/deny 端点统一调用 ``resolve(run_id, approved)``
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._events: dict[str, asyncio.Event] = {}
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
poll_interval: float = 0.5,
|
||||
timeout: float = 60,
|
||||
confirmation_id: str | None = None,
|
||||
) -> bool:
|
||||
"""等待确认。
|
||||
|
||||
同一进程的事件直接唤醒;跨进程或重启后 polling 兜底。
|
||||
|
||||
``confirmation_id`` 可选,传入时会同时注册为该 Event 的别名,
|
||||
使得外部以 ``confirmation_id`` 调 ``resolve`` 时也能立即唤醒同进程的 Event
|
||||
(无需等 DB polling 兜底)。
|
||||
|
||||
返回:
|
||||
True=批准, False=拒绝 ; 超时抛出 TimeoutError
|
||||
"""
|
||||
event = asyncio.Event()
|
||||
self._events[run_id] = event
|
||||
if confirmation_id:
|
||||
self._events[confirmation_id] = event
|
||||
|
||||
try:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
# 1) polling DB 获取真实状态(Event 只用于唤醒,不携带批准/拒绝值)
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
if confirmation_id is None:
|
||||
stmt = select(PendingConfirmationRecord).where(
|
||||
or_(
|
||||
PendingConfirmationRecord.id == run_id,
|
||||
PendingConfirmationRecord.run_id == run_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = select(PendingConfirmationRecord).where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.run_id == run_id,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is not None and record.status != "pending":
|
||||
return record.status == "approved"
|
||||
|
||||
# 2) 等一个间隔,同时监听 Event(Event set 只表示「有变化」,实际值靠 DB)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(event.wait()),
|
||||
timeout=poll_interval,
|
||||
)
|
||||
# Event 被唤醒 → 回到循环顶部重新查 DB
|
||||
continue
|
||||
except TimeoutError:
|
||||
continue
|
||||
|
||||
# 超时
|
||||
raise TimeoutError(f"确认超时(等待 {timeout:.0f}s 未裁决)")
|
||||
finally:
|
||||
self._events.pop(run_id, None)
|
||||
if confirmation_id:
|
||||
self._events.pop(confirmation_id, None)
|
||||
|
||||
async def resolve(
|
||||
self,
|
||||
run_id: str,
|
||||
approved: bool,
|
||||
*,
|
||||
confirmation_id: str | None = None,
|
||||
) -> bool:
|
||||
"""精确裁决一个待确认工具调用,并唤醒对应等待者。"""
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
target_id = confirmation_id or run_id
|
||||
async with AsyncSessionLocal() as session:
|
||||
status = "approved" if approved else "denied"
|
||||
stmt = update(PendingConfirmationRecord).where(
|
||||
PendingConfirmationRecord.status == "pending"
|
||||
)
|
||||
if confirmation_id is None:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
PendingConfirmationRecord.id == run_id,
|
||||
PendingConfirmationRecord.run_id == run_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = stmt.where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.run_id == run_id,
|
||||
)
|
||||
result = await session.execute(
|
||||
stmt.values(status=status, resolved_at=datetime.now(UTC))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
return False
|
||||
|
||||
event = self._events.get(target_id) or self._events.get(run_id)
|
||||
if event is not None:
|
||||
event.set()
|
||||
return True
|
||||
|
||||
def has(self, run_id: str) -> bool:
|
||||
"""是否仍在等待(同进程检查)。"""
|
||||
event = self._events.get(run_id)
|
||||
return event is not None and not event.is_set()
|
||||
|
||||
|
||||
__all__ = ["ConfirmationRegistry"]
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Agent 扩展契约与注册表。
|
||||
|
||||
扩展只接触本次运行的快照与 ToolResult,不持有数据库会话、密钥或安全治理对象。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any
|
||||
|
||||
from app.ai_provider.types.chat import TokenUsage
|
||||
from app.ai_provider.types.messages import BaseMessage
|
||||
from app.modules.tool_runtime.definition import ToolResult
|
||||
|
||||
|
||||
class CancellationToken:
|
||||
"""协作式取消信号;阻塞 await 仍由活动 asyncio.Task.cancel() 负责中断。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._cancelled = False
|
||||
|
||||
@property
|
||||
def cancelled(self) -> bool:
|
||||
return self._cancelled
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancelled = True
|
||||
|
||||
def raise_if_cancelled(self) -> None:
|
||||
if self._cancelled:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentRunContext:
|
||||
"""扩展可见的最小运行上下文。"""
|
||||
|
||||
run_id: str | None = None
|
||||
session_id: str | None = None
|
||||
character_id: str | None = None
|
||||
trace_id: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
cancellation: CancellationToken = field(default_factory=CancellationToken)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentContext:
|
||||
"""每轮发送给模型前可转换的上下文快照。"""
|
||||
|
||||
messages: tuple[BaseMessage, ...]
|
||||
tools: tuple[dict[str, Any], ...]
|
||||
model_hint: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentEvent:
|
||||
"""Agent Core 内部生命周期事件。"""
|
||||
|
||||
type: str
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallContext:
|
||||
"""工具调用 Hook 的稳定输入。"""
|
||||
|
||||
tool_call_id: str
|
||||
name: str
|
||||
input: dict[str, Any]
|
||||
messages: tuple[BaseMessage, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BeforeToolCallDecision:
|
||||
"""执行前决策;扩展只能收紧或改参,不能绕过 ToolRuntime。"""
|
||||
|
||||
allowed: bool = True
|
||||
reason: str | None = None
|
||||
input: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AfterToolCallDecision:
|
||||
"""执行后结果覆盖与提前终止提示。"""
|
||||
|
||||
result: ToolResult | None = None
|
||||
terminate: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnSnapshot:
|
||||
"""一轮模型调用及其工具执行结果摘要。"""
|
||||
|
||||
iteration: int
|
||||
content: str
|
||||
reasoning: str
|
||||
tool_calls: tuple[dict[str, Any], ...]
|
||||
tool_results: tuple[dict[str, Any], ...]
|
||||
usage: TokenUsage = field(default_factory=TokenUsage)
|
||||
|
||||
|
||||
ContextTransformer = Callable[[AgentContext, AgentRunContext], Awaitable[AgentContext]]
|
||||
BeforeToolCallHook = Callable[
|
||||
[ToolCallContext, AgentRunContext], Awaitable[BeforeToolCallDecision | None]
|
||||
]
|
||||
AfterToolCallHook = Callable[
|
||||
[ToolCallContext, ToolResult, AgentRunContext], Awaitable[AfterToolCallDecision | None]
|
||||
]
|
||||
TurnPolicy = Callable[[TurnSnapshot, AgentRunContext], Awaitable[bool]]
|
||||
AgentEventListener = Callable[[AgentEvent, AgentRunContext], Awaitable[None]]
|
||||
|
||||
|
||||
class ExtensionRegistry:
|
||||
"""受约束的 Agent 扩展注册表;按注册顺序执行 Hook。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._context_transformers: list[ContextTransformer] = []
|
||||
self._before_tool_hooks: list[BeforeToolCallHook] = []
|
||||
self._after_tool_hooks: list[AfterToolCallHook] = []
|
||||
self._turn_policies: list[TurnPolicy] = []
|
||||
self._event_listeners: list[AgentEventListener] = []
|
||||
|
||||
def add_context_transformer(self, transformer: ContextTransformer) -> None:
|
||||
self._context_transformers.append(transformer)
|
||||
|
||||
def add_before_tool_hook(self, hook: BeforeToolCallHook) -> None:
|
||||
self._before_tool_hooks.append(hook)
|
||||
|
||||
def add_after_tool_hook(self, hook: AfterToolCallHook) -> None:
|
||||
self._after_tool_hooks.append(hook)
|
||||
|
||||
def add_turn_policy(self, policy: TurnPolicy) -> None:
|
||||
self._turn_policies.append(policy)
|
||||
|
||||
def add_event_listener(self, listener: AgentEventListener) -> Callable[[], None]:
|
||||
self._event_listeners.append(listener)
|
||||
|
||||
def unsubscribe() -> None:
|
||||
if listener in self._event_listeners:
|
||||
self._event_listeners.remove(listener)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
def extend(self, registry: ExtensionRegistry) -> None:
|
||||
"""按原注册顺序合并另一组扩展。"""
|
||||
self._context_transformers.extend(registry._context_transformers)
|
||||
self._before_tool_hooks.extend(registry._before_tool_hooks)
|
||||
self._after_tool_hooks.extend(registry._after_tool_hooks)
|
||||
self._turn_policies.extend(registry._turn_policies)
|
||||
self._event_listeners.extend(registry._event_listeners)
|
||||
|
||||
async def transform_context(
|
||||
self, context: AgentContext, run: AgentRunContext
|
||||
) -> AgentContext:
|
||||
for transformer in self._context_transformers:
|
||||
run.cancellation.raise_if_cancelled()
|
||||
context = await transformer(context, run)
|
||||
return context
|
||||
|
||||
async def before_tool_call(
|
||||
self, call: ToolCallContext, run: AgentRunContext
|
||||
) -> BeforeToolCallDecision:
|
||||
current = call
|
||||
for hook in self._before_tool_hooks:
|
||||
run.cancellation.raise_if_cancelled()
|
||||
decision = await hook(current, run)
|
||||
if decision is None:
|
||||
continue
|
||||
if not decision.allowed:
|
||||
return decision
|
||||
if decision.input is not None:
|
||||
current = replace(current, input=decision.input)
|
||||
return BeforeToolCallDecision(input=current.input)
|
||||
|
||||
async def after_tool_call(
|
||||
self,
|
||||
call: ToolCallContext,
|
||||
result: ToolResult,
|
||||
run: AgentRunContext,
|
||||
) -> AfterToolCallDecision:
|
||||
current = result
|
||||
terminate = False
|
||||
for hook in self._after_tool_hooks:
|
||||
run.cancellation.raise_if_cancelled()
|
||||
decision = await hook(call, current, run)
|
||||
if decision is None:
|
||||
continue
|
||||
if decision.result is not None:
|
||||
current = decision.result
|
||||
terminate = terminate or decision.terminate
|
||||
return AfterToolCallDecision(result=current, terminate=terminate)
|
||||
|
||||
async def should_stop(self, turn: TurnSnapshot, run: AgentRunContext) -> bool:
|
||||
for policy in self._turn_policies:
|
||||
run.cancellation.raise_if_cancelled()
|
||||
if await policy(turn, run):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def publish(self, event: AgentEvent, run: AgentRunContext) -> None:
|
||||
for listener in tuple(self._event_listeners):
|
||||
await listener(event, run)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return any(
|
||||
(
|
||||
self._context_transformers,
|
||||
self._before_tool_hooks,
|
||||
self._after_tool_hooks,
|
||||
self._turn_policies,
|
||||
self._event_listeners,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AfterToolCallDecision",
|
||||
"AgentContext",
|
||||
"AgentEvent",
|
||||
"AgentRunContext",
|
||||
"BeforeToolCallDecision",
|
||||
"CancellationToken",
|
||||
"ExtensionRegistry",
|
||||
"ToolCallContext",
|
||||
"TurnSnapshot",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""通用 Agent 提示组合。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.agent import AgentDefinition
|
||||
from app.modules.skills import SkillManifest
|
||||
|
||||
|
||||
def compose_system_messages(
|
||||
base_messages: list[str],
|
||||
agent: AgentDefinition | None,
|
||||
skills: list[SkillManifest],
|
||||
) -> list[str]:
|
||||
"""按稳定基础提示、Agent 身份、技能说明的顺序组合提示。"""
|
||||
messages = list(base_messages)
|
||||
if agent is not None and agent.system_prompt:
|
||||
messages.append(f"【Agent:{agent.name}】\n{agent.system_prompt}")
|
||||
for skill in skills:
|
||||
if skill.prompt:
|
||||
messages.append(f"【{skill.name}·能力注入】\n{skill.prompt}")
|
||||
return messages
|
||||
@@ -0,0 +1,132 @@
|
||||
"""受信任 Python Skill 的发现、匹配与 Run 级激活。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import pkgutil
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from app.application.agent.extensions import ExtensionRegistry
|
||||
|
||||
SkillCleanup = Callable[[], Awaitable[None] | None]
|
||||
SkillSetup = Callable[[ExtensionRegistry], SkillCleanup | None]
|
||||
SkillMatcher = Callable[[str, dict[str, Any]], bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PythonSkillDefinition:
|
||||
"""部署期 Python Skill 契约。"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
triggers: tuple[str, ...]
|
||||
instructions: str
|
||||
setup: SkillSetup
|
||||
version: str = "1"
|
||||
enabled: bool = True
|
||||
match: SkillMatcher | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PythonSkillActivation:
|
||||
"""单个 Run 的 Skill 激活结果。"""
|
||||
|
||||
skills: tuple[PythonSkillDefinition, ...]
|
||||
cleanups: list[SkillCleanup]
|
||||
|
||||
async def close(self) -> None:
|
||||
"""按激活逆序释放 Skill 资源。"""
|
||||
while self.cleanups:
|
||||
result = self.cleanups.pop()()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
class PythonSkillCatalog:
|
||||
"""只保存 Skill 描述与 setup,不持有 Run 状态。"""
|
||||
|
||||
def __init__(self, skills: Iterable[PythonSkillDefinition] = ()) -> None:
|
||||
self._skills: dict[str, PythonSkillDefinition] = {}
|
||||
for skill in skills:
|
||||
if skill.name in self._skills:
|
||||
raise ValueError(f"Python Skill 名称重复:{skill.name}")
|
||||
self._skills[skill.name] = skill
|
||||
|
||||
@property
|
||||
def skills(self) -> tuple[PythonSkillDefinition, ...]:
|
||||
return tuple(self._skills.values())
|
||||
|
||||
def match(
|
||||
self,
|
||||
context_text: str,
|
||||
*,
|
||||
skill_refs: tuple[str, ...] = (),
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> tuple[PythonSkillDefinition, ...]:
|
||||
"""返回显式引用、关键词或自定义匹配器命中的 enabled Skills。"""
|
||||
refs = set(skill_refs)
|
||||
text = context_text.casefold()
|
||||
match_metadata = metadata or {}
|
||||
return tuple(
|
||||
skill
|
||||
for skill in self._skills.values()
|
||||
if skill.enabled
|
||||
and (
|
||||
skill.name in refs
|
||||
or any(trigger.casefold() in text for trigger in skill.triggers if trigger)
|
||||
or (skill.match is not None and skill.match(context_text, match_metadata))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def activate_python_skills(
|
||||
registry: ExtensionRegistry,
|
||||
skills: Iterable[PythonSkillDefinition],
|
||||
) -> PythonSkillActivation:
|
||||
"""把命中的 Skills 注册到当前 Run,并在部分失败时清理已激活项。"""
|
||||
activation = PythonSkillActivation(tuple(skills), [])
|
||||
try:
|
||||
for skill in activation.skills:
|
||||
cleanup = skill.setup(registry)
|
||||
if cleanup is not None:
|
||||
activation.cleanups.append(cleanup)
|
||||
except Exception:
|
||||
await activation.close()
|
||||
raise
|
||||
return activation
|
||||
|
||||
|
||||
def discover_python_skills(packages: Iterable[str] = ()) -> PythonSkillCatalog:
|
||||
"""从受信任 packages 发现定义,但不执行 Skill setup。"""
|
||||
modules: list[ModuleType] = []
|
||||
for package_name in dict.fromkeys(packages):
|
||||
package = importlib.import_module(package_name)
|
||||
package_path = getattr(package, "__path__", None)
|
||||
if package_path is None:
|
||||
raise TypeError(f"Python Skill 包必须是 package:{package_name}")
|
||||
modules.extend(
|
||||
importlib.import_module(module.name)
|
||||
for module in pkgutil.iter_modules(package_path, f"{package.__name__}.")
|
||||
if not module.name.rsplit(".", 1)[-1].startswith("_")
|
||||
)
|
||||
|
||||
definitions: list[PythonSkillDefinition] = []
|
||||
for module in sorted(modules, key=lambda item: item.__name__):
|
||||
skill = getattr(module, "SKILL", None)
|
||||
if not isinstance(skill, PythonSkillDefinition):
|
||||
raise TypeError(f"Python Skill 缺少 SKILL 定义:{module.__name__}")
|
||||
definitions.append(skill)
|
||||
return PythonSkillCatalog(definitions)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PythonSkillActivation",
|
||||
"PythonSkillCatalog",
|
||||
"PythonSkillDefinition",
|
||||
"activate_python_skills",
|
||||
"discover_python_skills",
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
"""活动 Agent Run 注册表:为 steer、follow-up、abort 提供进程内控制面。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.application.agent.agent import Agent
|
||||
|
||||
|
||||
class AgentRunRegistry:
|
||||
"""跟踪当前进程中的活动 Agent;持久状态仍以 Run/EventStore 为准。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._agents: dict[str, Agent] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def register(self, run_id: str, agent: Agent) -> None:
|
||||
async with self._lock:
|
||||
self._agents[run_id] = agent
|
||||
|
||||
async def unregister(self, run_id: str, agent: Agent) -> None:
|
||||
async with self._lock:
|
||||
if self._agents.get(run_id) is agent:
|
||||
self._agents.pop(run_id, None)
|
||||
|
||||
def get(self, run_id: str) -> Agent | None:
|
||||
return self._agents.get(run_id)
|
||||
|
||||
def steer(self, run_id: str, content: str) -> bool:
|
||||
agent = self.get(run_id)
|
||||
if agent is None:
|
||||
return False
|
||||
return agent.steer(content)
|
||||
|
||||
def follow_up(self, run_id: str, content: str) -> bool:
|
||||
agent = self.get(run_id)
|
||||
if agent is None:
|
||||
return False
|
||||
return agent.follow_up(content)
|
||||
|
||||
async def abort(self, run_id: str) -> bool:
|
||||
agent = self.get(run_id)
|
||||
if agent is None or not await agent.abort():
|
||||
return False
|
||||
await agent.wait_for_idle()
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["AgentRunRegistry"]
|
||||
@@ -0,0 +1,840 @@
|
||||
"""流式 Agent 循环:stream → 累积 tool_calls → execute → 回填 ToolMessage → 再 stream。
|
||||
|
||||
阶段2b 步骤A:把 Agent 工具调用闭环从非流式 ``router.chat`` 改成流式 ``router.stream``,
|
||||
并在主循环中暴露两个可选 async 回调(``on_chunk`` / ``on_tool_event``)供上层(步骤B
|
||||
send_message)接入 SSE 增量冲刷与工具事件。本模块不感知 SSE / 前端,回调签名仅约定:
|
||||
|
||||
- ``on_chunk(chunk: ChatChunk)``:每个流式 chunk 透传(含 delta_content / delta_reasoning
|
||||
/ tool_call_chunks / usage 等),上层可据此写 ``message.delta``。
|
||||
- ``on_tool_event(event_type: str, payload: dict)``:工具生命周期事件:
|
||||
- ``"tool.started"``:执行前
|
||||
- ``"tool.completed"``:执行后,含 status(success / error / denied)
|
||||
- ``"tool.requires_confirmation"``(阶段3 新增):高风险工具抛 RequiresConfirmation 时
|
||||
发出,带 confirmation_id / risk_level / args,供前端展示确认卡片、approve/deny 端点
|
||||
据此恢复循环。
|
||||
|
||||
阶段3 步骤1:工具确认挂起/恢复机制。
|
||||
- 主循环遇到 ``RequiresConfirmation`` 时,通过 ``confirmation_registry.register(cid)`` 取
|
||||
一个 ``Future[bool]`` 并 ``await``;外部 approve/deny 端点(步骤2)调
|
||||
``registry.resolve(cid, approved)`` 触发恢复。
|
||||
- 批准:以 ``confirmed=True`` 重试 execute;拒绝:回填"用户拒绝"ToolMessage,循环继续。
|
||||
- 超时:``confirmation_timeout``(默认 300s)未裁决按拒绝处理;该超时受外层
|
||||
``run_timeout``(send_message 的 ``asyncio.timeout``)约束——确认必须在 run 超时内完成。
|
||||
- ``confirmation_registry`` 为 None 时降级:回填"需确认但未启用"消息,循环不卡(向下兼容)。
|
||||
|
||||
设计要点:
|
||||
- 流式 ``router.stream(...)`` 返回 ``AsyncIterator[ChatChunk]``;流结束(async for 正常
|
||||
退出)即本轮模型输出完毕,``ToolCallAccumulator.drain()`` 给出完整 tool_calls——不依赖
|
||||
``finish_reason`` 判断。
|
||||
- 工具执行经 ``ToolRuntime.execute``——司命评估与事件落库由其内部完成,本层只负责调用与
|
||||
错误兜底。
|
||||
- 工具失败**不中断循环**:异常被捕获、错误信息以 ToolMessage content 回填,让模型自行决策。
|
||||
RequiresConfirmation 单独处理(不走错误兜底分支)。
|
||||
- usage 累计每轮 prompt/completion/total_tokens。
|
||||
- ``on_chunk`` / ``on_tool_event`` 默认 None;不传时本循环与纯后端逻辑等价。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.ai_provider import ProviderManagerFactory, TokenUsage
|
||||
from app.ai_provider.llm import ToolCallAccumulator
|
||||
from app.ai_provider.types.chat import ChatChunk
|
||||
from app.ai_provider.types.messages import AIMessage, BaseMessage, ToolMessage
|
||||
from app.application.agent.confirmation import ConfirmationRegistry
|
||||
from app.application.agent.extensions import (
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
AgentRunContext,
|
||||
CancellationToken,
|
||||
ExtensionRegistry,
|
||||
ToolCallContext,
|
||||
TurnSnapshot,
|
||||
)
|
||||
from app.core.logging import logger
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
from app.modules.safety.confirmation import new_confirmation_id
|
||||
from app.modules.safety.exceptions import RequiresConfirmation
|
||||
from app.modules.tool_runtime.definition import ToolResult, ToolRuntimeLike
|
||||
|
||||
# 默认最大循环次数:覆盖常见多步工具调用,超限则截断返回,避免无限循环。
|
||||
DEFAULT_MAX_ITERATIONS = 8
|
||||
|
||||
# 默认确认超时:高风险工具挂起等待用户裁决的最长时间(秒);超时按拒绝处理。
|
||||
DEFAULT_CONFIRMATION_TIMEOUT = 300.0
|
||||
|
||||
# 回调类型别名:给上层(步骤B send_message)类型检查用
|
||||
OnChunkCallback = Callable[[ChatChunk], Awaitable[None]]
|
||||
OnToolEventCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentTurnResult:
|
||||
"""单轮 Agent 循环的产出。"""
|
||||
|
||||
content: str # 最终回复正文(最后一次模型输出)
|
||||
reasoning: str # 最终回复的思考内容(如有)
|
||||
tool_history: list[dict[str, Any]] = field(
|
||||
default_factory=list
|
||||
) # [{name, args, result, status, tool_call_id?}]
|
||||
iterations: int = 0 # 实际循环次数
|
||||
truncated: bool = False # 是否因达上限截断
|
||||
usage: TokenUsage = field(default_factory=TokenUsage) # 累计 token 用量
|
||||
|
||||
|
||||
def _sum_usage(acc: TokenUsage, delta: TokenUsage) -> TokenUsage:
|
||||
return acc + delta
|
||||
|
||||
|
||||
def _parse_tool_args(args_str: str) -> dict[str, Any]:
|
||||
"""把累积到的 args JSON 字符串解析成 dict;解析失败时回退 ``{"_raw": ...}``。"""
|
||||
if not args_str:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(args_str)
|
||||
except json.JSONDecodeError:
|
||||
return {"_raw": args_str}
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
# 非对象 JSON(数组/字符串/数字)——包成 _raw 让模型自己理解
|
||||
return {"_raw": args_str}
|
||||
|
||||
|
||||
def _ai_message_from_stream(
|
||||
content: str,
|
||||
reasoning: str,
|
||||
tool_calls: list[dict[str, Any]],
|
||||
raw_content_blocks: list[dict[str, Any]] | None = None,
|
||||
) -> AIMessage:
|
||||
"""把流式累积的 content/reasoning/tool_calls 还原成 AIMessage 以便追加到 messages 历史。
|
||||
|
||||
``tool_calls`` 元素结构为 ``{"index", "id"?, "name"?, "args": str}``(由
|
||||
``ToolCallAccumulator.drain`` 产出,``args`` 为完整 JSON 字符串)。AIMessage 期望
|
||||
``{"name": ..., "args": dict, "id": ...}``,故需反序列化 args 并补全缺失 id。
|
||||
"""
|
||||
lc_tool_calls: list[dict[str, Any]] = []
|
||||
for entry in tool_calls:
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
# 没有 name 的 tool_call 无法执行,跳过(保留在累积器视图但不进历史)
|
||||
continue
|
||||
tc_id = entry.get("id") or f"call_{name}"
|
||||
lc_tool_calls.append(
|
||||
{
|
||||
"name": name,
|
||||
"args": _parse_tool_args(entry.get("args", "")),
|
||||
"id": tc_id,
|
||||
}
|
||||
)
|
||||
return AIMessage(
|
||||
content=content,
|
||||
tool_calls=lc_tool_calls,
|
||||
reasoning=reasoning,
|
||||
raw_content_blocks=raw_content_blocks,
|
||||
)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = 4000) -> str:
|
||||
"""截断超长文本(沙箱 stdout/stderr 可能很大),避免喂回模型时爆 token。
|
||||
|
||||
措辞明确这是「回显截断」而非数据损坏,并告知取后续内容的方法:否则模型读到大文件
|
||||
被 read_file 截断的结果,会反复换方法重读,陷入循环直到 ``max_iterations`` 截断。
|
||||
阈值 4000 兼顾 token 成本与单次可读量;大文件配合 read_file 的 offset/limit 分段读取。
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return (
|
||||
f"{text[:limit]}\n…[输出过长,仅回显前 {limit} 字符;原始内容完整。"
|
||||
f"如需后续内容:read_file 用 offset/limit 分段,或 shell 用 sed -n / tail / head 限定输出]"
|
||||
)
|
||||
|
||||
|
||||
async def _publish_message_lifecycle(
|
||||
registry: ExtensionRegistry,
|
||||
run: AgentRunContext,
|
||||
message: BaseMessage,
|
||||
) -> None:
|
||||
"""发布一条完整消息的 start/end 生命周期。"""
|
||||
if not registry.enabled:
|
||||
return
|
||||
payload = {"role": message.role, "message": message.to_dict()}
|
||||
await registry.publish(AgentEvent("message.started", payload), run)
|
||||
await registry.publish(AgentEvent("message.ended", payload), run)
|
||||
|
||||
|
||||
def _tool_result_text(result: ToolResult) -> str:
|
||||
"""把 ToolResult 转成回填给模型的紧凑文本。
|
||||
|
||||
显式标注 ``status=成功/失败``:否则成功路径(如 write_file 落盘后 stdout/stderr 全空)
|
||||
只剩孤零零的 ``exit_code=0``,模型缺乏「已成功」的明确语义,倾向再调 read_file 验证,
|
||||
叠加回显截断极易陷入反复重试。
|
||||
"""
|
||||
status = "成功" if result.ok else "失败"
|
||||
parts = [f"status={status} exit_code={result.exit_code}"]
|
||||
if result.stdout:
|
||||
parts.append(f"stdout:\n{_truncate(result.stdout)}")
|
||||
if result.stderr:
|
||||
parts.append(f"stderr:\n{_truncate(result.stderr)}")
|
||||
if result.timeout:
|
||||
parts.append("timeout=true")
|
||||
if result.truncated:
|
||||
parts.append("truncated=true(仅截断回显给模型的输出,不影响已落盘等实际副作用)")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# 控制流哨兵异常:只在本模块内传递,不泄漏到 run_agent_turn 调用方。
|
||||
class _ToolDenied(Exception):
|
||||
"""用户拒绝 / 确认超时。``message`` 为回填文本,``reason`` 为内部归因(denied/timeout)。"""
|
||||
|
||||
def __init__(self, message: str, *, reason: str = "denied") -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class _ToolSkipped(Exception):
|
||||
"""confirmation_registry 未启用时的降级:回填"需确认但未启用"消息。"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
async def _execute_tool_with_confirmation(
|
||||
*,
|
||||
tool_runtime: ToolRuntimeLike | None,
|
||||
name: str,
|
||||
tool_input: dict[str, Any],
|
||||
args_str: str,
|
||||
tool_call_id: str,
|
||||
run_id: str | None,
|
||||
character_id: str | None,
|
||||
trace_id: str | None,
|
||||
base_confirmed: bool,
|
||||
confirmation_registry: ConfirmationRegistry | None,
|
||||
confirmation_timeout: float,
|
||||
on_tool_event: OnToolEventCallback | None,
|
||||
cancellation: CancellationToken | None = None,
|
||||
on_timeout: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> ToolResult:
|
||||
"""执行单次工具调用,处理 RequiresConfirmation 挂起/恢复。
|
||||
|
||||
- 正常返回 ToolResult;任意终态(拒绝/超时/降级)以本模块哨兵异常向调用方信号。
|
||||
- 批准路径以 ``confirmed=True`` 重试 execute(绕过司命 ask),失败仍正常返回 ToolResult
|
||||
(由调用方按 ok=False 回填 error 文本)。
|
||||
- ``on_timeout(confirmation_id)``:确认超时时回调,供调用方标记超时。
|
||||
"""
|
||||
# base_confirmed=True 表示调用方已保证确认(如 API 已先确认),直接执行
|
||||
use_confirmed = base_confirmed
|
||||
if tool_runtime is None:
|
||||
raise _ToolSkipped("工具运行时未启用,已跳过工具调用。")
|
||||
|
||||
while True:
|
||||
try:
|
||||
return await tool_runtime.execute(
|
||||
name,
|
||||
tool_input,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
trace_id=trace_id,
|
||||
confirmed=use_confirmed,
|
||||
context={"cancellation": cancellation} if cancellation is not None else None,
|
||||
)
|
||||
except RequiresConfirmation as exc:
|
||||
if use_confirmed:
|
||||
raise _ToolSkipped(f"工具 {name} 在已确认状态下仍要求确认,已跳过。") from exc
|
||||
|
||||
# ---- registry 未启用:降级 ----
|
||||
if confirmation_registry is None:
|
||||
raise _ToolSkipped(
|
||||
f"工具 {name} 需要用户确认(风险等级 {exc.risk_level or 'L2'}),"
|
||||
"但当前未启用确认流程,已跳过。"
|
||||
) from exc
|
||||
|
||||
# ---- registry 已启用:登记 + 持久化待确认 + 发事件 + await ----
|
||||
confirmation_id = new_confirmation_id()
|
||||
|
||||
# 先持久化待确认记录(多 worker 共享确认状态),再发事件。
|
||||
# 顺序保证:resolve/deny 回调(on_tool_event 触发的 create_task)看到记录时,
|
||||
# DB 里已有该行,不会空转超时。
|
||||
if run_id:
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
record = PendingConfirmationRecord(
|
||||
id=confirmation_id,
|
||||
tool=name,
|
||||
input_json=json.dumps(
|
||||
{
|
||||
"name": name,
|
||||
"args": tool_input,
|
||||
"tool_call_id": tool_call_id,
|
||||
"args_str": args_str,
|
||||
}
|
||||
),
|
||||
risk_level=exc.risk_level or "L2",
|
||||
reason=exc.message,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
status="pending",
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.requires_confirmation",
|
||||
{
|
||||
"confirmation_id": confirmation_id,
|
||||
"run_id": run_id,
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"risk_level": exc.risk_level,
|
||||
"reason": exc.message,
|
||||
"args": args_str,
|
||||
},
|
||||
)
|
||||
|
||||
# 使用混合确认注册表(同进程 Event + 重启后 polling)。无 run_id 的
|
||||
# 独立调用仍可等待到超时,避免把“未裁决”误报成“确认机制未启用”。
|
||||
confirmation_run_id = run_id or confirmation_id
|
||||
try:
|
||||
approved = await confirmation_registry.wait(
|
||||
confirmation_run_id,
|
||||
confirmation_id=confirmation_id,
|
||||
timeout=confirmation_timeout,
|
||||
)
|
||||
except TimeoutError:
|
||||
if on_timeout is not None:
|
||||
await on_timeout(confirmation_id)
|
||||
raise _ToolDenied(
|
||||
f"工具 {name} 的确认超时(等待 {confirmation_timeout:.0f}s 未裁决),已按拒绝处理。",
|
||||
reason="timeout",
|
||||
) from None
|
||||
|
||||
if not approved:
|
||||
raise _ToolDenied(f"用户拒绝了工具 {name} 的执行。") from exc
|
||||
|
||||
# 批准:以 confirmed=True 重试 execute(循环回到顶部 try)
|
||||
logger.info(
|
||||
"agent_tool_confirmed",
|
||||
tool=name,
|
||||
confirmation_id=confirmation_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
use_confirmed = True
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.started",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"args": args_str,
|
||||
"confirmed": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def run_agent_turn(
|
||||
*,
|
||||
messages: list[BaseMessage],
|
||||
tools: list[dict[str, Any]],
|
||||
tool_runtime: ToolRuntimeLike | None,
|
||||
model_hint: str | None = None,
|
||||
thinking_enabled: bool | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
||||
# 可选上下文:透传给 ToolRuntime.execute 做事件归属与工作目录隔离
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
# 可选:高风险工具是否已由上层确认(透传给 ToolRuntime.execute)
|
||||
confirmed: bool = False,
|
||||
# 可选:每轮开始时回调(供外层重置 per-turn 状态如 reasoning_finalized)
|
||||
on_turn: Callable[[], Awaitable[None]] | None = None,
|
||||
# 可选回调:把流式 chunk 透传给上层做 SSE 增量冲刷(默认 None 表示不回调)
|
||||
on_chunk: OnChunkCallback | None = None,
|
||||
# 可选回调:工具生命周期事件("tool.started" / "tool.completed" /
|
||||
# "tool.requires_confirmation")
|
||||
on_tool_event: OnToolEventCallback | None = None,
|
||||
# 可选:确认 Future 注册表(阶段3 步骤1)。缺省 None 时遇 RequiresConfirmation 降级。
|
||||
confirmation_registry: ConfirmationRegistry | None = None,
|
||||
# 可选:单次确认挂起超时(秒),默认 300s;超时按拒绝处理。
|
||||
confirmation_timeout: float = DEFAULT_CONFIRMATION_TIMEOUT,
|
||||
# 可选:确认超时回调(confirmation_id → None),供调用方标记 DB 超时状态。
|
||||
on_confirm_timeout: Callable[[str], Awaitable[None]] | None = None,
|
||||
# 受约束扩展 Hook 与本次运行上下文。
|
||||
extensions: ExtensionRegistry | None = None,
|
||||
run_context: AgentRunContext | None = None,
|
||||
# Stateful Agent 的 steering 队列;每轮工具批次完成后消费。
|
||||
get_steering_messages: Callable[[], list[BaseMessage]] | None = None,
|
||||
) -> AgentTurnResult:
|
||||
"""执行一轮带工具调用的 Agent 循环(流式版)。
|
||||
|
||||
循环逻辑见模块 docstring;返回值始终是 ``AgentTurnResult``,工具失败不抛异常。
|
||||
``max_iterations`` 为硬上限:达到上限且模型仍返回 tool_calls 时返回 truncated=True。
|
||||
``on_chunk`` / ``on_tool_event`` 缺省 None 时与纯后端逻辑等价。
|
||||
``on_confirm_timeout(confirmation_id)``:确认超时时回调,用于标记 DB 记录。
|
||||
|
||||
确认挂起/恢复(阶段3 步骤1):``confirmation_registry`` 缺省 None 时,遇
|
||||
``RequiresConfirmation`` 走降级路径(回填"需确认但未启用"消息,循环继续);否则按
|
||||
register → 发 ``tool.requires_confirmation`` 事件 → ``await Future``(带超时)→
|
||||
批准重试 / 拒绝回填 的流程处理。``confirmation_timeout`` 须小于上层 run_timeout,否则
|
||||
run 先超时。
|
||||
"""
|
||||
if max_iterations < 1:
|
||||
raise ValueError("max_iterations 必须 >= 1")
|
||||
|
||||
manager = ProviderManagerFactory.create()
|
||||
extension_registry = extensions or ExtensionRegistry()
|
||||
extension_context = run_context or AgentRunContext(
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
usage = TokenUsage()
|
||||
tool_history: list[dict[str, Any]] = []
|
||||
iterations = 0
|
||||
# 最后一轮累积值(用于 truncated 分支返回 content/reasoning)
|
||||
last_content = ""
|
||||
last_reasoning = ""
|
||||
|
||||
while iterations < max_iterations:
|
||||
iterations += 1
|
||||
if on_turn is not None:
|
||||
await on_turn()
|
||||
if extension_registry.enabled:
|
||||
await extension_registry.publish(
|
||||
AgentEvent("agent.turn.started", {"iteration": iterations}),
|
||||
extension_context,
|
||||
)
|
||||
|
||||
# ---- 本轮流式消费 ----
|
||||
acc = ToolCallAccumulator()
|
||||
content_buf = ""
|
||||
reasoning_buf = ""
|
||||
finish_reason: str | None = None
|
||||
raw_blocks: dict[int, dict[str, Any]] = {}
|
||||
params: dict[str, object] = {}
|
||||
if thinking_enabled is not None:
|
||||
params["thinking_enabled"] = thinking_enabled
|
||||
if reasoning_effort is not None:
|
||||
params["reasoning_effort"] = reasoning_effort
|
||||
extension_context.cancellation.raise_if_cancelled()
|
||||
request_context = AgentContext(tuple(messages), tuple(tools), model_hint)
|
||||
if extension_registry.enabled:
|
||||
request_context = await extension_registry.transform_context(
|
||||
request_context,
|
||||
extension_context,
|
||||
)
|
||||
if extension_registry.enabled:
|
||||
await extension_registry.publish(
|
||||
AgentEvent("message.started", {"role": "assistant", "iteration": iterations}),
|
||||
extension_context,
|
||||
)
|
||||
stream = manager.chat_stream(
|
||||
list(request_context.messages),
|
||||
model_hint=request_context.model_hint,
|
||||
params=params,
|
||||
tools=list(request_context.tools) or None,
|
||||
)
|
||||
async for chunk in stream:
|
||||
if on_chunk is not None:
|
||||
await on_chunk(chunk)
|
||||
if extension_registry.enabled and (chunk.delta_content or chunk.delta_reasoning):
|
||||
await extension_registry.publish(
|
||||
AgentEvent(
|
||||
"message.updated",
|
||||
{
|
||||
"role": "assistant",
|
||||
"iteration": iterations,
|
||||
"delta": chunk.delta_content,
|
||||
"reasoning_delta": chunk.delta_reasoning,
|
||||
},
|
||||
),
|
||||
extension_context,
|
||||
)
|
||||
if chunk.delta_content:
|
||||
content_buf += chunk.delta_content
|
||||
if chunk.delta_reasoning:
|
||||
reasoning_buf += chunk.delta_reasoning
|
||||
if chunk.finish_reason is not None:
|
||||
finish_reason = chunk.finish_reason
|
||||
if chunk.content_block_start is not None and chunk.content_block_index is not None:
|
||||
raw_blocks[chunk.content_block_index] = dict(chunk.content_block_start)
|
||||
if chunk.content_block_delta is not None and chunk.content_block_index is not None:
|
||||
block = raw_blocks.get(chunk.content_block_index)
|
||||
if block is not None:
|
||||
delta = chunk.content_block_delta
|
||||
delta_type = delta.get("type")
|
||||
if delta_type == "text_delta":
|
||||
block["text"] = f"{block.get('text', '')}{delta.get('text', '')}"
|
||||
elif delta_type == "thinking_delta":
|
||||
block["thinking"] = (
|
||||
f"{block.get('thinking', '')}{delta.get('thinking', '')}"
|
||||
)
|
||||
elif delta_type == "signature_delta":
|
||||
block["signature"] = (
|
||||
f"{block.get('signature', '')}{delta.get('signature', '')}"
|
||||
)
|
||||
elif delta_type == "input_json_delta":
|
||||
block["_partial_json"] = (
|
||||
f"{block.get('_partial_json', '')}{delta.get('partial_json', '')}"
|
||||
)
|
||||
if chunk.usage is not None:
|
||||
usage = _sum_usage(usage, chunk.usage)
|
||||
acc.add_chunk(chunk)
|
||||
|
||||
# 流结束即本轮模型输出完毕
|
||||
tool_calls = acc.drain()
|
||||
last_content = content_buf
|
||||
last_reasoning = reasoning_buf
|
||||
|
||||
raw_content_blocks: list[dict[str, Any]] = []
|
||||
for index in sorted(raw_blocks):
|
||||
block = raw_blocks[index]
|
||||
partial_json = block.pop("_partial_json", None)
|
||||
if partial_json is not None:
|
||||
block["input"] = _parse_tool_args(partial_json)
|
||||
raw_content_blocks.append(block)
|
||||
|
||||
# 把模型输出(含 thinking signature / tool_calls)原样追加到历史。
|
||||
ai_message = _ai_message_from_stream(
|
||||
content_buf,
|
||||
reasoning_buf,
|
||||
tool_calls,
|
||||
raw_content_blocks or None,
|
||||
)
|
||||
messages.append(ai_message)
|
||||
if extension_registry.enabled:
|
||||
await extension_registry.publish(
|
||||
AgentEvent(
|
||||
"message.ended",
|
||||
{
|
||||
"role": "assistant",
|
||||
"iteration": iterations,
|
||||
"message": ai_message.to_dict(),
|
||||
"finish_reason": finish_reason,
|
||||
},
|
||||
),
|
||||
extension_context,
|
||||
)
|
||||
|
||||
# 无工具调用:先消费 steering,再决定是否结束。
|
||||
if not any(tc.get("name") for tc in tool_calls):
|
||||
steering = get_steering_messages() if get_steering_messages is not None else []
|
||||
turn = TurnSnapshot(
|
||||
iteration=iterations,
|
||||
content=content_buf,
|
||||
reasoning=reasoning_buf,
|
||||
tool_calls=(),
|
||||
tool_results=tuple(tool_history),
|
||||
usage=usage,
|
||||
)
|
||||
if extension_registry.enabled:
|
||||
await extension_registry.publish(
|
||||
AgentEvent("agent.turn.ended", {"iteration": iterations}),
|
||||
extension_context,
|
||||
)
|
||||
# TurnPolicy 在此分支不参与终止决策:模型无工具调用时已由 steering /
|
||||
# finish_reason / 默认 return 收尾,强行接入会覆盖 pause_turn / refusal
|
||||
# 既有处理;其真实终止作用在下方工具分支(should_stop or terminate_after_tools)。
|
||||
await extension_registry.should_stop(turn, extension_context)
|
||||
if steering:
|
||||
messages.extend(steering)
|
||||
continue
|
||||
if finish_reason == "pause_turn":
|
||||
continue
|
||||
if finish_reason == "refusal":
|
||||
raise RuntimeError("模型拒绝了当前请求。")
|
||||
return AgentTurnResult(
|
||||
content=content_buf,
|
||||
reasoning=reasoning_buf,
|
||||
tool_history=tool_history,
|
||||
iterations=iterations,
|
||||
truncated=finish_reason == "length",
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# 逐个执行 tool_call 并把结果以 ToolMessage 回填。工具可能有副作用或需要确认,
|
||||
# 在 ToolSpec 未声明并行安全前保持确定性串行执行。
|
||||
turn_history_start = len(tool_history)
|
||||
terminate_after_tools = False
|
||||
for call_index, entry in enumerate(tool_calls):
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
continue
|
||||
args_str = entry.get("args", "")
|
||||
tool_input = _parse_tool_args(args_str)
|
||||
tool_call_id = entry.get("id") or f"call_{iterations}_{call_index}_{name}"
|
||||
hook_call = ToolCallContext(
|
||||
tool_call_id=tool_call_id,
|
||||
name=name,
|
||||
input=tool_input,
|
||||
messages=tuple(messages),
|
||||
)
|
||||
if extension_registry.enabled:
|
||||
decision = await extension_registry.before_tool_call(
|
||||
hook_call,
|
||||
extension_context,
|
||||
)
|
||||
if not decision.allowed:
|
||||
denied_text = decision.reason or f"扩展阻止了工具 {name} 的执行。"
|
||||
tool_history.append(
|
||||
{
|
||||
"name": name,
|
||||
"args": tool_input,
|
||||
"result": denied_text,
|
||||
"status": "denied",
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
)
|
||||
tool_message = ToolMessage(content=denied_text, tool_call_id=tool_call_id)
|
||||
messages.append(tool_message)
|
||||
await _publish_message_lifecycle(
|
||||
extension_registry, extension_context, tool_message
|
||||
)
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.completed",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"status": "denied",
|
||||
"result": denied_text[:500],
|
||||
},
|
||||
)
|
||||
continue
|
||||
if decision.input is not None:
|
||||
tool_input = decision.input
|
||||
args_str = json.dumps(tool_input, ensure_ascii=False)
|
||||
hook_call = ToolCallContext(
|
||||
tool_call_id=tool_call_id,
|
||||
name=name,
|
||||
input=tool_input,
|
||||
messages=tuple(messages),
|
||||
)
|
||||
|
||||
# 执行前事件
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.started",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"args": args_str,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
tool_result = await _execute_tool_with_confirmation(
|
||||
tool_runtime=tool_runtime,
|
||||
name=name,
|
||||
tool_input=tool_input,
|
||||
args_str=args_str,
|
||||
tool_call_id=tool_call_id,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
trace_id=trace_id,
|
||||
base_confirmed=confirmed,
|
||||
confirmation_registry=confirmation_registry,
|
||||
confirmation_timeout=confirmation_timeout,
|
||||
on_tool_event=on_tool_event,
|
||||
cancellation=extension_context.cancellation,
|
||||
on_timeout=on_confirm_timeout,
|
||||
)
|
||||
except _ToolDenied as denied:
|
||||
# 用户拒绝或确认超时:回填拒绝消息,循环继续让模型自决策
|
||||
logger.info(
|
||||
"agent_tool_denied",
|
||||
tool=name,
|
||||
reason=denied.reason,
|
||||
run_id=run_id,
|
||||
)
|
||||
denied_text = denied.message
|
||||
tool_history.append(
|
||||
{
|
||||
"name": name,
|
||||
"args": tool_input,
|
||||
"result": denied_text,
|
||||
"status": "denied",
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
)
|
||||
tool_message = ToolMessage(content=denied_text, tool_call_id=tool_call_id)
|
||||
messages.append(tool_message)
|
||||
await _publish_message_lifecycle(
|
||||
extension_registry, extension_context, tool_message
|
||||
)
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.completed",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"status": "denied",
|
||||
"result": denied_text[:500],
|
||||
},
|
||||
)
|
||||
continue
|
||||
except _ToolSkipped as skipped:
|
||||
# registry 未启用:回填"需确认但未启用",循环继续
|
||||
skipped_text = skipped.message
|
||||
tool_history.append(
|
||||
{
|
||||
"name": name,
|
||||
"args": tool_input,
|
||||
"result": skipped_text,
|
||||
"status": "skipped",
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
)
|
||||
tool_message = ToolMessage(content=skipped_text, tool_call_id=tool_call_id)
|
||||
messages.append(tool_message)
|
||||
await _publish_message_lifecycle(
|
||||
extension_registry, extension_context, tool_message
|
||||
)
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.completed",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"status": "skipped",
|
||||
"result": skipped_text[:500],
|
||||
},
|
||||
)
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001 - 任意工具错误都回填、不中断循环
|
||||
# 司命 deny / 沙箱错误 / 未知工具等:错误信息回填,循环继续
|
||||
logger.warning(
|
||||
"agent_tool_failed",
|
||||
tool=name,
|
||||
error=str(exc),
|
||||
run_id=run_id,
|
||||
)
|
||||
error_text = f"工具执行失败:{type(exc).__name__}: {_truncate(str(exc))}"
|
||||
tool_history.append(
|
||||
{
|
||||
"name": name,
|
||||
"args": tool_input,
|
||||
"result": error_text,
|
||||
"status": "error",
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
)
|
||||
tool_message = ToolMessage(content=error_text, tool_call_id=tool_call_id)
|
||||
messages.append(tool_message)
|
||||
await _publish_message_lifecycle(
|
||||
extension_registry, extension_context, tool_message
|
||||
)
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.completed",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"status": "error",
|
||||
"result": error_text[:500],
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
# 成功路径:扩展可变换结果或提示终止;安全裁定已在 ToolRuntime 内完成。
|
||||
if extension_registry.enabled:
|
||||
after = await extension_registry.after_tool_call(
|
||||
hook_call,
|
||||
tool_result,
|
||||
extension_context,
|
||||
)
|
||||
tool_result = after.result or tool_result
|
||||
terminate_after_tools = terminate_after_tools or after.terminate
|
||||
outcome = _tool_result_text(tool_result)
|
||||
status = "success" if tool_result.ok else "error"
|
||||
if not tool_result.ok:
|
||||
# 沙箱执行返回非 ok(exit_code != 0 等)——不抛异常,回填让模型自决策
|
||||
logger.info(
|
||||
"agent_tool_returned_error",
|
||||
tool=name,
|
||||
exit_code=tool_result.exit_code,
|
||||
stderr=tool_result.stderr[:500] if tool_result.stderr else "",
|
||||
run_id=run_id,
|
||||
)
|
||||
tool_history.append(
|
||||
{
|
||||
"name": name,
|
||||
"args": tool_input,
|
||||
"result": outcome,
|
||||
"status": status,
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
)
|
||||
tool_message = ToolMessage(content=outcome, tool_call_id=tool_call_id)
|
||||
messages.append(tool_message)
|
||||
await _publish_message_lifecycle(extension_registry, extension_context, tool_message)
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.completed",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"status": status,
|
||||
"result": outcome[:500],
|
||||
},
|
||||
)
|
||||
|
||||
steering = get_steering_messages() if get_steering_messages is not None else []
|
||||
if steering:
|
||||
messages.extend(steering)
|
||||
turn = TurnSnapshot(
|
||||
iteration=iterations,
|
||||
content=content_buf,
|
||||
reasoning=reasoning_buf,
|
||||
tool_calls=tuple(tool_calls),
|
||||
tool_results=tuple(tool_history[turn_history_start:]),
|
||||
usage=usage,
|
||||
)
|
||||
should_stop = terminate_after_tools
|
||||
if extension_registry.enabled:
|
||||
await extension_registry.publish(
|
||||
AgentEvent("agent.turn.ended", {"iteration": iterations}),
|
||||
extension_context,
|
||||
)
|
||||
should_stop = should_stop or await extension_registry.should_stop(
|
||||
turn,
|
||||
extension_context,
|
||||
)
|
||||
if should_stop and not steering:
|
||||
return AgentTurnResult(
|
||||
content=content_buf,
|
||||
reasoning=reasoning_buf,
|
||||
tool_history=tool_history,
|
||||
iterations=iterations,
|
||||
truncated=False,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# 达上限仍带 tool_calls:截断返回,content 用最后一次模型输出或说明已截断
|
||||
content = last_content or f"Agent 循环达到最大次数 {max_iterations},已截断。"
|
||||
return AgentTurnResult(
|
||||
content=content,
|
||||
reasoning=last_reasoning,
|
||||
tool_history=tool_history,
|
||||
iterations=iterations,
|
||||
truncated=True,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_CONFIRMATION_TIMEOUT",
|
||||
"DEFAULT_MAX_ITERATIONS",
|
||||
"AgentTurnResult",
|
||||
"OnChunkCallback",
|
||||
"OnToolEventCallback",
|
||||
"run_agent_turn",
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
"""从既有 Skill 存储加载运行时 manifest。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.skills import SkillManifest, parse_skill_manifest
|
||||
from app.repositories.json_utils import loads_list
|
||||
from app.services.skills import get_enabled_skills_by_names, match_active_skills
|
||||
|
||||
|
||||
async def load_skill_manifests(
|
||||
context_text: str,
|
||||
*,
|
||||
skill_refs: tuple[str, ...] = (),
|
||||
) -> list[SkillManifest]:
|
||||
"""加载关键词命中或显式引用的 enabled Skill。"""
|
||||
skills = await match_active_skills(context_text)
|
||||
if skill_refs:
|
||||
by_name = {skill.name: skill for skill in skills}
|
||||
for skill in await get_enabled_skills_by_names(skill_refs):
|
||||
by_name.setdefault(skill.name, skill)
|
||||
skills = list(by_name.values())
|
||||
return [parse_skill_manifest(skill, loads_list(skill.tool_bindings_json)) for skill in skills]
|
||||
@@ -0,0 +1,95 @@
|
||||
"""将洛书规划结果映射为通用 Agent 任务,并写入 Run 事件。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.agent import AgentTask, Plan, PlanStep
|
||||
from app.modules.planner import DeliberativePlan
|
||||
|
||||
|
||||
def task_from_deliberative_plan(
|
||||
plan: DeliberativePlan,
|
||||
*,
|
||||
run_id: str,
|
||||
agent_name: str,
|
||||
objective: str,
|
||||
) -> AgentTask:
|
||||
"""把一次规划结果转换为仅在当前 Run 存活的 AgentTask。"""
|
||||
return AgentTask(
|
||||
id=f"task_{run_id}",
|
||||
objective=objective,
|
||||
agent_name=agent_name,
|
||||
run_id=run_id,
|
||||
status="running",
|
||||
plan=Plan(
|
||||
steps=tuple(
|
||||
PlanStep(
|
||||
id=str(step.id),
|
||||
title=step.title,
|
||||
description=step.description,
|
||||
tool=step.tool,
|
||||
risk_level=step.risk_level,
|
||||
)
|
||||
for step in plan.steps
|
||||
),
|
||||
assumptions=tuple(plan.assumptions),
|
||||
risks=tuple(plan.risks),
|
||||
status="running",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def next_step_for_tool(task: AgentTask, tool_name: str, completed_step_ids: set[str]) -> PlanStep | None:
|
||||
"""返回尚未处理且明确绑定到工具的首个计划步骤。"""
|
||||
if task.plan is None:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
step
|
||||
for step in task.plan.steps
|
||||
if step.id not in completed_step_ids and step.tool == tool_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def step_event_payload(
|
||||
task: AgentTask,
|
||||
step: PlanStep,
|
||||
*,
|
||||
status: str,
|
||||
result_summary: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""生成计划步骤生命周期事件 payload。"""
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"step_id": step.id,
|
||||
"title": step.title,
|
||||
"tool": step.tool,
|
||||
"risk_level": step.risk_level,
|
||||
"status": status,
|
||||
**({"result_summary": result_summary} if result_summary else {}),
|
||||
}
|
||||
|
||||
|
||||
def task_plan_payload(task: AgentTask) -> dict[str, object]:
|
||||
"""生成安全且可直接展示的计划事件 payload。"""
|
||||
assert task.plan is not None
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"agent_name": task.agent_name,
|
||||
"objective": task.objective,
|
||||
"status": task.plan.status,
|
||||
"assumptions": list(task.plan.assumptions),
|
||||
"risks": list(task.plan.risks),
|
||||
"steps": [
|
||||
{
|
||||
"id": step.id,
|
||||
"title": step.title,
|
||||
"description": step.description,
|
||||
"tool": step.tool,
|
||||
"risk_level": step.risk_level,
|
||||
"status": step.status,
|
||||
}
|
||||
for step in task.plan.steps
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"""ToolSpec → OpenAI 函数 schema 的转换与默认工具集选取。
|
||||
|
||||
直接产出 OpenAI function schema dict(``{"type": "function", "function": {...}}``),
|
||||
与 ChatResult.tool_calls 里的 name/args 回填闭环天然对齐。
|
||||
|
||||
ToolSpec 的 name/description 直接映射;参数 schema 因 ToolSpec 未内嵌 JSON Schema,
|
||||
这里按工具 name 查表给出最小参数 schema(builtins 内工具参数固定,可选参数也显式列出)。
|
||||
未知工具默认空参数 schema(``{"type": "object", "properties": {}}``),模型按需传参。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.tool_runtime.definition import ToolSpec
|
||||
|
||||
# 内置工具的参数 schema(name → JSON Schema)。新增工具时扩展此表即可。
|
||||
# 保持最小集合:只声明 known 工具的参数;调用方传入未登记工具时回退空对象 schema。
|
||||
_PARAM_SCHEMAS: dict[str, dict[str, Any]] = {
|
||||
"datetime": {"type": "object", "properties": {}, "required": []},
|
||||
"read_file": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "workspace 内的文件相对路径。"},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "起始行号(1-based),默认 1。大文件分段读取后续内容时使用。",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "读取的行数上限;不传则读到末尾。大文件分段读取时使用。",
|
||||
},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
"list_dir": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "workspace 内的目录相对路径,缺省为 '.'。",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"write_file": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "workspace 内文件相对路径。"},
|
||||
"content": {"type": "string", "description": "要写入的文本内容。"},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
"shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "要在沙箱中执行的 shell 命令。工作目录为沙箱隔离的 workspace 目录(并非 /home/user 或 /tmp 等 Linux 路径,这些路径不存在,不要 cd 到它们)。read_file/write_file 操作的也是这个 workspace 目录,shell 里 cd 到别的目录将看不到这些文件。",
|
||||
},
|
||||
"timeout_ms": {"type": "integer", "description": "可选超时(毫秒),默认 30000。"},
|
||||
"stdin": {"type": "string", "description": "可选标准输入。"},
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
"fetch_url": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "要抓取的网页 URL。"},
|
||||
"max_chars": {
|
||||
"type": "integer",
|
||||
"description": "正文最大字符数,默认 8000。",
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
}
|
||||
|
||||
_EMPTY_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}, "required": []}
|
||||
|
||||
|
||||
def to_ai_provider_tools(tool_specs: list[ToolSpec]) -> list[dict[str, Any]]:
|
||||
"""把 ToolSpec 列表转成 OpenAI function schema dict 列表。
|
||||
|
||||
先查 ``_PARAM_SCHEMAS``(内置工具),未命中则用 ``spec.input_schema``(MCP 工具),
|
||||
仍为空则回退无参占位 ``_EMPTY_SCHEMA``。
|
||||
"""
|
||||
tools: list[dict[str, Any]] = []
|
||||
for spec in tool_specs:
|
||||
params = _PARAM_SCHEMAS.get(spec.name)
|
||||
if not params and spec.name.startswith("skill:"):
|
||||
params = _PARAM_SCHEMAS.get(spec.name.rsplit("/", 1)[-1])
|
||||
if not params:
|
||||
params = spec.input_schema or _EMPTY_SCHEMA
|
||||
tools.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": spec.name,
|
||||
"description": spec.description,
|
||||
"parameters": params,
|
||||
},
|
||||
}
|
||||
)
|
||||
return tools
|
||||
|
||||
|
||||
# 核心工具恒绑(沙箱类,始终可用)。
|
||||
_CORE_TOOL_NAMES = ["datetime", "read_file", "list_dir", "write_file", "shell"]
|
||||
|
||||
|
||||
def default_tools(
|
||||
tool_specs_by_name: dict[str, ToolSpec] | None = None,
|
||||
*,
|
||||
allowed_tools: tuple[str, ...] = (),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""返回工具对应的 OpenAI function schema 列表。
|
||||
|
||||
- 未传 ``tool_specs_by_name`` 时,从 ``builtin_specs()`` 加载仅返回核心工具
|
||||
(datetime/read_file/list_dir/write_file/shell),共 5 个。
|
||||
- 传入自定义工具池时(如 ``tool_runtime._resolve_specs()``),返回池中所有工具,
|
||||
含 MCP 注册的工具,不限于核心工具。
|
||||
"""
|
||||
if tool_specs_by_name is None:
|
||||
from app.application.tool_runtime.builtins import builtin_specs
|
||||
|
||||
tool_specs_by_name = builtin_specs()
|
||||
chosen = [
|
||||
tool_specs_by_name[name] for name in _CORE_TOOL_NAMES if name in tool_specs_by_name
|
||||
]
|
||||
else:
|
||||
chosen = list(tool_specs_by_name.values())
|
||||
|
||||
if allowed_tools:
|
||||
allowed = set(allowed_tools)
|
||||
chosen = [spec for spec in chosen if spec.name in allowed]
|
||||
return to_ai_provider_tools(chosen)
|
||||
@@ -0,0 +1,602 @@
|
||||
"""大周天:长期演化循环(architecture §3.2;v1-scope §3.3)。
|
||||
|
||||
双触发:记忆阈值 + 每轮对话后检查。循环阶段化执行(自我评估 → 演化策略 →
|
||||
候选生成),候选生成调神识模型产出统一 ``evolution_candidates``,按 ``level``
|
||||
半自动应用——人格特质/程序记忆等已成熟下游立即派生,L4 核心变更留人工审核
|
||||
(见 [大周天演化候选落地设计] ../../docs/domain/core-domains.md)。
|
||||
单进程 asyncio,多 worker 需任务队列。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import asdict
|
||||
from datetime import UTC
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai_provider import ProviderManager, ProviderManagerFactory
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError
|
||||
from app.core.ids import new_goal_id
|
||||
from app.core.logging import logger
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.candidates import MemoryCandidate
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
from app.models.governance import Goal
|
||||
from app.models.memory import Memory
|
||||
from app.models.world import CharacterWorldEntity
|
||||
from app.modules.big_cycle.extract import parse_evolution_candidates
|
||||
from app.modules.big_cycle.prompts import (
|
||||
EvaluationSnapshot,
|
||||
build_evolution_extraction_messages,
|
||||
)
|
||||
from app.modules.drive.extract import parse_curiosities
|
||||
from app.modules.drive.prompts import build_drive_extraction_messages
|
||||
from app.modules.evolution.policies import should_auto_apply
|
||||
from app.modules.extraction.extract import parse_extraction
|
||||
from app.modules.extraction.prompts import build_extraction_messages
|
||||
from app.modules.runtime.events import Event
|
||||
from app.repositories import big_cycle as big_cycle_repo
|
||||
from app.repositories import characters as character_repo
|
||||
from app.repositories import drive as drive_repo
|
||||
from app.repositories import evolution as evolution_repo
|
||||
from app.repositories import memory as memory_repo
|
||||
from app.repositories import runtime as runtime_repo # 新增:活跃 run 检查
|
||||
from app.repositories import world as world_repo
|
||||
from app.repositories.json_utils import dumps
|
||||
from app.services import evolution as evolution_service
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.event_store import DbEventStore
|
||||
from app.services.personality import build_personality_summary
|
||||
|
||||
_running_big_cycle_tasks: set[asyncio.Task[None]] = set()
|
||||
_RECENT_MEMORY_LIMIT = 10
|
||||
# Token 预算估算(各阶段大致消耗,用于超预算时跳过非关键阶段)
|
||||
_ESTIMATED_EVALUATION_COST = 4000
|
||||
_ESTIMATED_CANDIDATE_COST = 16000
|
||||
_ESTIMATED_MEMORY_COST = 8000
|
||||
|
||||
_provider_manager: ProviderManager | None = None
|
||||
|
||||
|
||||
def _get_provider_manager() -> ProviderManager:
|
||||
global _provider_manager
|
||||
if _provider_manager is None:
|
||||
_provider_manager = ProviderManagerFactory.create()
|
||||
return _provider_manager
|
||||
|
||||
|
||||
def _get_event_store() -> DbEventStore:
|
||||
"""创建临时 EventStore 实例用于推送大周天事件(自管 session 工厂)。"""
|
||||
return DbEventStore(AsyncSessionLocal)
|
||||
|
||||
|
||||
async def _emit_big_cycle_event(
|
||||
event_type: str,
|
||||
character_id: str,
|
||||
cycle_id: str,
|
||||
*,
|
||||
payload: dict | None = None,
|
||||
) -> None:
|
||||
"""推送大周天事件到 EventStore(自管 Session,失败静默)。"""
|
||||
try:
|
||||
store = _get_event_store()
|
||||
await store.append(
|
||||
Event(
|
||||
type=event_type,
|
||||
source="big_cycle",
|
||||
visibility="public",
|
||||
character_id=character_id,
|
||||
payload={"cycle_id": cycle_id, **(payload or {})},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning(f"推送大周天事件失败: {event_type}", cycle_id=cycle_id)
|
||||
|
||||
|
||||
async def drain_pending_big_cycles() -> None:
|
||||
"""等待所有进行中的大周天循环完成(测试断言 / 优雅停机用)。"""
|
||||
if _running_big_cycle_tasks:
|
||||
await asyncio.gather(*_running_big_cycle_tasks, return_exceptions=True)
|
||||
|
||||
|
||||
async def maybe_trigger_big_cycle(character_id: str, trigger: str = "session_turn") -> str | None:
|
||||
"""检查触发条件,满足则创建 cycle 记录并启动后台循环;返回 cycle_id 或 None。"""
|
||||
if not settings.enable_big_cycle:
|
||||
return None
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 任务中断防护:如果有活跃运行中的 run,跳过大周天避免干扰对话
|
||||
if await runtime_repo.has_active_runs(session, character_id):
|
||||
logger.debug(f"大周天跳过:{character_id} 有活跃 run")
|
||||
return None
|
||||
|
||||
memory_count = await character_repo.count_memories(session, character_id)
|
||||
if memory_count < settings.big_cycle_memory_threshold:
|
||||
return None
|
||||
last = await big_cycle_repo.get_last_completed(session, character_id)
|
||||
if last is not None and last.completed_at is not None:
|
||||
last_completed = last.completed_at
|
||||
if last_completed.tzinfo is None:
|
||||
last_completed = last_completed.replace(tzinfo=UTC)
|
||||
if (
|
||||
utc_now() - last_completed
|
||||
).total_seconds() < settings.big_cycle_min_interval_seconds:
|
||||
return None
|
||||
cycle = BigCycle(
|
||||
character_id=character_id,
|
||||
trigger=trigger,
|
||||
status="pending",
|
||||
memory_count_at_trigger=memory_count,
|
||||
)
|
||||
await big_cycle_repo.add_cycle(session, cycle)
|
||||
await session.commit()
|
||||
cycle_id = cycle.id
|
||||
|
||||
start_big_cycle(cycle_id, character_id)
|
||||
return cycle_id
|
||||
|
||||
|
||||
def start_big_cycle(cycle_id: str, character_id: str) -> None:
|
||||
task = asyncio.create_task(_run_big_cycle(cycle_id, character_id))
|
||||
_running_big_cycle_tasks.add(task)
|
||||
task.add_done_callback(_running_big_cycle_tasks.discard)
|
||||
|
||||
|
||||
async def _phase_goal_progress(session: AsyncSession, cycle: BigCycle) -> None:
|
||||
"""大周天目标进度更新:根据演化结果增加关联目标进度。
|
||||
|
||||
当前为简单版本:每完成一轮大周天,character 的活跃 task/session 目标进度 +0.1。
|
||||
后续可改为 LLM 评估更精确的进度。
|
||||
"""
|
||||
from app.repositories import governance as governance_repo
|
||||
|
||||
active_goals = await governance_repo.list_goals_by_character(
|
||||
session, cycle.character_id, status_filter="active"
|
||||
)
|
||||
for goal in active_goals:
|
||||
if goal.level in ("task", "session") and goal.progress < 1.0:
|
||||
new_progress = min(1.0, goal.progress + 0.1)
|
||||
await governance_repo.update_goal_progress(session, goal.id, new_progress)
|
||||
if new_progress >= 1.0 and goal.level == "task":
|
||||
goal.status = "completed"
|
||||
from app.services.governance import emit_goal_event
|
||||
|
||||
await emit_goal_event(
|
||||
goal,
|
||||
"completed",
|
||||
extra_payload={"trigger": "big_cycle"},
|
||||
)
|
||||
|
||||
|
||||
def _score_autonomy(subgoal) -> float:
|
||||
"""按 information_gain / interruption_cost 比值排序。"""
|
||||
gain = getattr(subgoal, "expected_information_gain", None) or 0.0
|
||||
cost = getattr(subgoal, "interruption_cost", None) or 1.0
|
||||
return gain / max(cost, 0.1)
|
||||
|
||||
|
||||
async def _phase_autonomous_exploration(
|
||||
session: AsyncSession, cycle: BigCycle,
|
||||
) -> None:
|
||||
"""自主探索执行:将最高优先级的已批准次生目标标记为待叩心枢执行。
|
||||
|
||||
选择 expected_information_gain / interruption_cost 比值最高的已批准次生目标,
|
||||
标记为 auto_execute_pending,由叩心枢的下一个 tick 拾取。
|
||||
每次大周天最多标记 2 个。
|
||||
"""
|
||||
from app.repositories import drive as drive_repo
|
||||
|
||||
approved = await drive_repo.list_approved_subgoals(
|
||||
session, cycle.character_id, limit=5,
|
||||
)
|
||||
if not approved:
|
||||
return
|
||||
|
||||
sorted_subgoals = sorted(approved, key=_score_autonomy, reverse=True)
|
||||
to_execute = sorted_subgoals[:2]
|
||||
for sub in to_execute:
|
||||
sub.status = "auto_execute_pending"
|
||||
|
||||
await session.flush()
|
||||
logger.info(
|
||||
f"大周天自主探索标记 cycle={cycle.id} "
|
||||
f"subgoals={len(to_execute)}/{len(approved)}"
|
||||
)
|
||||
|
||||
|
||||
async def _run_big_cycle(cycle_id: str, character_id: str) -> None:
|
||||
"""大周天主循环:阶段化执行,超预算时跳过非关键阶段。"""
|
||||
budget_spent = 0
|
||||
|
||||
await _emit_big_cycle_event("big_cycle.started", character_id, cycle_id)
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
|
||||
if cycle is None:
|
||||
return
|
||||
cycle.status = "running"
|
||||
cycle.started_at = utc_now()
|
||||
await session.commit()
|
||||
|
||||
# 阶段 1:自我评估(关键阶段,始终执行)
|
||||
snapshot = await _phase_evaluation(session, cycle)
|
||||
budget_spent += _ESTIMATED_EVALUATION_COST
|
||||
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "evaluation", "status": "completed"})
|
||||
|
||||
# 阶段 2:演化策略(轻量,始终执行)
|
||||
await _phase_evolution(session, cycle)
|
||||
|
||||
# 预算检查:超过 token 预算则跳过候选/记忆/探索阶段
|
||||
if budget_spent >= settings.big_cycle_token_budget:
|
||||
logger.warning(f"大周天 token 预算耗尽 cycle={cycle_id} spent={budget_spent}")
|
||||
cycle.summary = "token 预算耗尽,跳过后续非关键阶段"
|
||||
await session.commit()
|
||||
await _emit_big_cycle_event(
|
||||
"big_cycle.phase", character_id, cycle_id,
|
||||
payload={"phase": "candidate", "status": "skipped", "reason": "budget_exhausted"},
|
||||
)
|
||||
await _emit_big_cycle_event(
|
||||
"big_cycle.phase", character_id, cycle_id,
|
||||
payload={"phase": "memory", "status": "skipped", "reason": "budget_exhausted"},
|
||||
)
|
||||
else:
|
||||
# 阶段 3:候选生成
|
||||
await _phase_candidates(session, cycle, snapshot)
|
||||
budget_spent += _ESTIMATED_CANDIDATE_COST
|
||||
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "candidate", "status": "completed"})
|
||||
|
||||
# 阶段 4:记忆/世界抽取
|
||||
await _phase_memory(session, cycle, snapshot)
|
||||
budget_spent += _ESTIMATED_MEMORY_COST
|
||||
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "memory", "status": "completed"})
|
||||
|
||||
# 阶段 5:目标进度更新
|
||||
await _phase_goal_progress(session, cycle)
|
||||
|
||||
# 阶段 6:自主探索执行(轻量,始终执行)
|
||||
await _phase_autonomous_exploration(session, cycle)
|
||||
|
||||
cycle.status = "completed"
|
||||
cycle.phase = "done"
|
||||
cycle.completed_at = utc_now()
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="big_cycle.completed",
|
||||
character_id=character_id,
|
||||
actor="system",
|
||||
decision="auto",
|
||||
input_summary=(cycle.summary or "大周天循环完成")[:200],
|
||||
resource_type="big_cycle",
|
||||
resource_id=cycle.id,
|
||||
)
|
||||
await session.commit()
|
||||
await _emit_big_cycle_event(
|
||||
"big_cycle.completed", character_id, cycle_id,
|
||||
payload={"summary": cycle.summary},
|
||||
)
|
||||
except Exception as exc:
|
||||
await _fail_cycle(session, cycle_id, exc)
|
||||
await _emit_big_cycle_event(
|
||||
"big_cycle.failed", character_id, cycle_id,
|
||||
payload={"error": str(exc)[:200]},
|
||||
)
|
||||
|
||||
|
||||
async def _phase_evaluation(session: AsyncSession, cycle: BigCycle) -> EvaluationSnapshot:
|
||||
"""自我评估:汇总记忆/人格/世界态势,写入 cycle 快照供候选生成消费。"""
|
||||
cycle.phase = "evaluation"
|
||||
await session.commit()
|
||||
character = await character_repo.get_character(session, cycle.character_id)
|
||||
if character is None:
|
||||
raise AppError("not_found", "人物不存在。")
|
||||
memory_count = await character_repo.count_memories(session, cycle.character_id)
|
||||
recent, _ = await memory_repo.list_memories(
|
||||
session, cycle.character_id, page=1, page_size=_RECENT_MEMORY_LIMIT
|
||||
)
|
||||
personality = await build_personality_summary(session, character)
|
||||
_, world_count = await world_repo.list_entities(
|
||||
session, cycle.character_id, page=1, page_size=1
|
||||
)
|
||||
snapshot = EvaluationSnapshot(
|
||||
character_id=cycle.character_id,
|
||||
memory_count=memory_count,
|
||||
recent_memories=[(m.title, m.content) for m in recent],
|
||||
personality_summary=personality.text or "",
|
||||
world_entity_count=world_count,
|
||||
conclusion=_build_conclusion(memory_count, world_count, personality.text),
|
||||
evidence_ids=[m.id for m in recent],
|
||||
)
|
||||
|
||||
# 人格权重向量:查询最近大周天的累积漂移,注入态势快照供 LLM 感知
|
||||
from app.repositories import evolution as evolution_repo
|
||||
|
||||
recent_candidates, _ = await evolution_repo.list_candidates(
|
||||
session, cycle.character_id, status=None, page=1, page_size=100,
|
||||
)
|
||||
if recent_candidates:
|
||||
recent_drift = sum(c.drift_score or 0.0 for c in recent_candidates)
|
||||
drift_budget_remaining = max(0.0, settings.big_cycle_max_drift - recent_drift)
|
||||
snapshot.drift_budget_remaining = drift_budget_remaining
|
||||
snapshot.cumulative_drift = recent_drift
|
||||
cycle.evaluation_snapshot_json = dumps(asdict(snapshot))
|
||||
await session.commit()
|
||||
logger.info(f"大周天自我评估 cycle={cycle.id} memory={memory_count} world={world_count}")
|
||||
await _generate_drive(session, cycle, snapshot)
|
||||
return snapshot
|
||||
|
||||
|
||||
async def _generate_drive(
|
||||
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
|
||||
) -> None:
|
||||
"""内驱力生成(C 子阶段):基于态势生成好奇问题与探索性次生目标。"""
|
||||
system, user = build_drive_extraction_messages(snapshot)
|
||||
raw = await _call_model(system, user, cycle.character_id)
|
||||
drafts = parse_curiosities(raw)[: settings.big_cycle_max_candidates]
|
||||
curiosities = [
|
||||
CuriosityQuestion(
|
||||
character_id=cycle.character_id,
|
||||
cycle_id=cycle.id,
|
||||
question=draft.question,
|
||||
source=draft.source,
|
||||
related_entities_json=dumps(draft.related_entities),
|
||||
expected_value=draft.expected_value,
|
||||
urgency=draft.urgency,
|
||||
risk_level=draft.risk_level,
|
||||
)
|
||||
for draft in drafts
|
||||
]
|
||||
if curiosities:
|
||||
await drive_repo.add_curiosities(session, curiosities)
|
||||
await session.flush()
|
||||
subgoals: list[ExploratorySubgoal] = []
|
||||
for curiosity, draft in zip(curiosities, drafts, strict=True):
|
||||
for sub in draft.subgoals:
|
||||
subgoals.append(
|
||||
ExploratorySubgoal(
|
||||
character_id=cycle.character_id,
|
||||
cycle_id=cycle.id,
|
||||
curiosity_question_id=curiosity.id,
|
||||
title=sub.title,
|
||||
description=sub.description,
|
||||
expected_information_gain=sub.expected_information_gain,
|
||||
interruption_cost=sub.interruption_cost,
|
||||
risk_level=sub.risk_level,
|
||||
status="candidate",
|
||||
)
|
||||
)
|
||||
if subgoals:
|
||||
await drive_repo.add_subgoals(session, subgoals)
|
||||
await session.flush()
|
||||
# 自动通过低风险次生目标(L0-L2)→ 创建 Goal
|
||||
auto_approve_levels = {"L0", "L1", "L2"}
|
||||
auto_count = 0
|
||||
for sub in subgoals:
|
||||
if sub.risk_level in auto_approve_levels:
|
||||
sub.status = "approved"
|
||||
goal = Goal(
|
||||
id=new_goal_id(),
|
||||
character_id=cycle.character_id,
|
||||
title=sub.title,
|
||||
description=sub.description or "由次生目标自动生成",
|
||||
level="task",
|
||||
type="exploration",
|
||||
status="active",
|
||||
)
|
||||
session.add(goal)
|
||||
auto_count += 1
|
||||
await session.flush()
|
||||
if auto_count:
|
||||
logger.info(f"大周天自动通过 {auto_count}/{len(subgoals)} 个低风险次生目标")
|
||||
logger.info(
|
||||
f"大周天内驱力生成 cycle={cycle.id} curiosities={len(curiosities)} subgoals={len(subgoals)}"
|
||||
)
|
||||
await _emit_big_cycle_event(
|
||||
"big_cycle.drive_generated", cycle.character_id, cycle.id,
|
||||
payload={
|
||||
"curiosity_count": len(curiosities),
|
||||
"subgoal_count": len(subgoals),
|
||||
"auto_approved_subgoals": auto_count,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _phase_evolution(session: AsyncSession, cycle: BigCycle) -> None:
|
||||
"""演化策略阶段(轻量):演化策略融合进候选生成 prompt,此处仅标记阶段。"""
|
||||
cycle.phase = "evolution"
|
||||
await session.commit()
|
||||
logger.info(f"大周天演化策略 cycle={cycle.id}")
|
||||
|
||||
|
||||
async def _phase_candidates(
|
||||
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
|
||||
) -> None:
|
||||
"""候选生成:调神识模型产出演化候选,按 level 半自动应用。"""
|
||||
cycle.phase = "candidate"
|
||||
await session.commit()
|
||||
system, user = build_evolution_extraction_messages(snapshot)
|
||||
raw = await _call_model(system, user, cycle.character_id)
|
||||
drafts = parse_evolution_candidates(raw)[: settings.big_cycle_max_candidates]
|
||||
candidates = [
|
||||
EvolutionCandidate(
|
||||
character_id=cycle.character_id,
|
||||
cycle_id=cycle.id,
|
||||
type=draft.type,
|
||||
source=draft.source,
|
||||
level=draft.level,
|
||||
scope=draft.scope,
|
||||
title=draft.title,
|
||||
proposal=draft.proposal,
|
||||
delta_json=dumps(draft.delta),
|
||||
evidence_ids_json=dumps(_resolve_evidence(snapshot, draft.evidence)),
|
||||
confidence=draft.confidence,
|
||||
benefit_score=draft.benefit_score,
|
||||
risk_score=draft.risk_score,
|
||||
drift_score=draft.drift_score,
|
||||
auto_apply_allowed=draft.auto_apply_allowed,
|
||||
status="pending",
|
||||
)
|
||||
for draft in drafts
|
||||
]
|
||||
await evolution_repo.add_candidates(session, candidates)
|
||||
await session.flush()
|
||||
|
||||
# 反漂移检测:高漂移候选不自动应用 + 累积漂移跟踪
|
||||
cumulative_drift = 0.0
|
||||
drift_blocked = 0
|
||||
for candidate in candidates:
|
||||
if candidate.drift_score >= settings.big_cycle_drift_threshold:
|
||||
# 高漂移候选强制标记为 pending(人工审核),不自动应用
|
||||
candidate.auto_apply_allowed = False
|
||||
candidate.status = "pending"
|
||||
candidate.error_message = f"漂移 {candidate.drift_score} >= 阈值 {settings.big_cycle_drift_threshold},需人工审核"
|
||||
drift_blocked += 1
|
||||
cumulative_drift += candidate.drift_score
|
||||
|
||||
# 累积漂移超过上限 → 全部候选降为 pending
|
||||
if cumulative_drift >= settings.big_cycle_max_drift:
|
||||
logger.warning(f"累积漂移 {cumulative_drift:.2f} >= 上限 {settings.big_cycle_max_drift},全部候选降为人工审核")
|
||||
for candidate in candidates:
|
||||
if candidate.status != "applied":
|
||||
candidate.auto_apply_allowed = False
|
||||
candidate.status = "pending"
|
||||
candidate.error_message = f"累积漂移 {cumulative_drift:.2f} 超限,暂停自动演化"
|
||||
|
||||
for candidate in candidates:
|
||||
if should_auto_apply(candidate):
|
||||
target_id = await evolution_service.apply_evolution_candidate(session, candidate)
|
||||
if target_id is not None:
|
||||
candidate.status = "applied"
|
||||
candidate.resulting_target_id = target_id
|
||||
else:
|
||||
candidate.error_message = "该类型派生待后续阶段,已降级为人工审核"
|
||||
applied = sum(1 for c in candidates if c.status == "applied")
|
||||
pending = sum(1 for c in candidates if c.status == "pending")
|
||||
cycle.summary = (
|
||||
f"大周天生成 {len(candidates)} 个演化候选,"
|
||||
f"{applied} 个已自动应用,{pending} 个待审核"
|
||||
f"{f'({drift_blocked} 个因漂移拦截)' if drift_blocked else ''}"
|
||||
f"{f',累积漂移 {cumulative_drift:.2f}' if cumulative_drift > 0 else ''}"
|
||||
)
|
||||
if drift_blocked:
|
||||
await _emit_big_cycle_event(
|
||||
"big_cycle.drift_blocked", cycle.character_id, cycle.id,
|
||||
payload={
|
||||
"drift_blocked": drift_blocked,
|
||||
"cumulative_drift": round(cumulative_drift, 2),
|
||||
"threshold": settings.big_cycle_drift_threshold,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"大周天候选生成 cycle={cycle.id} count={len(candidates)} applied={applied}")
|
||||
|
||||
|
||||
async def _phase_memory(
|
||||
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
|
||||
) -> None:
|
||||
"""记忆/世界抽取(D 子阶段):从态势抽取记忆候选与世界实体候选。"""
|
||||
cycle.phase = "memory"
|
||||
await session.commit()
|
||||
system, user = build_extraction_messages(snapshot)
|
||||
raw = await _call_model(system, user, cycle.character_id)
|
||||
memory_drafts, entity_drafts = parse_extraction(raw)
|
||||
memory_drafts = memory_drafts[: settings.big_cycle_max_candidates]
|
||||
entity_drafts = entity_drafts[: settings.big_cycle_max_candidates]
|
||||
memory_cands = [
|
||||
MemoryCandidate(
|
||||
character_id=cycle.character_id,
|
||||
cycle_id=cycle.id,
|
||||
type=draft.type,
|
||||
title=draft.title,
|
||||
content=draft.content,
|
||||
confidence=draft.confidence,
|
||||
importance=draft.importance,
|
||||
sensitivity=draft.sensitivity,
|
||||
reason=draft.reason,
|
||||
status="pending",
|
||||
)
|
||||
for draft in memory_drafts
|
||||
]
|
||||
entities = [
|
||||
CharacterWorldEntity(
|
||||
character_id=cycle.character_id,
|
||||
type=draft.type,
|
||||
name=draft.name,
|
||||
attributes_json=dumps(draft.attributes),
|
||||
state_type=draft.state_type,
|
||||
confidence=draft.confidence,
|
||||
status="pending",
|
||||
)
|
||||
for draft in entity_drafts
|
||||
]
|
||||
if memory_cands:
|
||||
session.add_all(memory_cands)
|
||||
if entities:
|
||||
session.add_all(entities)
|
||||
await session.flush()
|
||||
|
||||
# 自动落地高置信度(≥0.7)低敏感度的记忆候选到 memories 表
|
||||
auto_memories = 0
|
||||
for cand in memory_cands:
|
||||
if cand.confidence >= 0.7 and cand.sensitivity in ("low", "none"):
|
||||
mem = Memory(
|
||||
character_id=cycle.character_id,
|
||||
type=cand.type,
|
||||
title=cand.title,
|
||||
content=cand.content,
|
||||
source="big_cycle",
|
||||
importance=cand.importance,
|
||||
sensitivity=cand.sensitivity,
|
||||
confidence=cand.confidence,
|
||||
)
|
||||
session.add(mem)
|
||||
cand.status = "applied"
|
||||
auto_memories += 1
|
||||
# 自动落地高置信度世界实体候选
|
||||
auto_entities = 0
|
||||
for ent in entities:
|
||||
if ent.confidence >= 0.7:
|
||||
ent.status = "active"
|
||||
auto_entities += 1
|
||||
await session.flush()
|
||||
if auto_memories or auto_entities:
|
||||
logger.info(
|
||||
f"大周天记忆/世界自动落地 cycle={cycle.id} "
|
||||
f"memories={auto_memories} entities={auto_entities}"
|
||||
)
|
||||
logger.info(
|
||||
f"大周天记忆/世界抽取 cycle={cycle.id} "
|
||||
f"memories={len(memory_cands)} entities={len(entities)}"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_evidence(snapshot: EvaluationSnapshot, indices: list[int]) -> list[str]:
|
||||
return [snapshot.evidence_ids[i] for i in indices if 0 <= i < len(snapshot.evidence_ids)]
|
||||
|
||||
|
||||
def _build_conclusion(memory_count: int, world_count: int, personality_text: str | None) -> str:
|
||||
parts = [f"记忆 {memory_count} 条", f"世界实体 {world_count} 个"]
|
||||
parts.append("已具备人格基线" if personality_text else "尚无人格基线")
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
async def _call_model(system: str, user: str, _character_id: str) -> str:
|
||||
manager = _get_provider_manager()
|
||||
messages = manager.to_message_list([system], [("user", user)])
|
||||
response = await manager.chat(messages, model_hint=settings.big_cycle_model)
|
||||
return response.content
|
||||
|
||||
|
||||
async def _fail_cycle(session: AsyncSession, cycle_id: str, exc: Exception) -> None:
|
||||
logger.error(f"大周天循环失败 cycle={cycle_id}: {exc}")
|
||||
try:
|
||||
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
|
||||
if cycle is not None:
|
||||
cycle.status = "failed"
|
||||
cycle.error_code = "big_cycle_failed"
|
||||
cycle.error_message = str(exc)[:500]
|
||||
cycle.completed_at = utc_now()
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001 — 失败处理本身不能再抛
|
||||
pass
|
||||
Whitespace-only changes.
@@ -0,0 +1,970 @@
|
||||
"""发送聊天消息用例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai_provider import ProviderManager, ProviderManagerFactory
|
||||
from app.ai_provider.types.chat import ChatChunk
|
||||
from app.application.agent import (
|
||||
Agent,
|
||||
AgentConfig,
|
||||
AgentEvent,
|
||||
AgentRunContext,
|
||||
AgentRunRegistry,
|
||||
ConfirmationRegistry,
|
||||
ExtensionRegistry,
|
||||
PythonSkillCatalog,
|
||||
PythonSkillDefinition,
|
||||
activate_python_skills,
|
||||
compose_system_messages,
|
||||
default_tools,
|
||||
next_step_for_tool,
|
||||
run_agent_turn,
|
||||
step_event_payload,
|
||||
task_from_deliberative_plan,
|
||||
task_plan_payload,
|
||||
)
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.application.tool_runtime.view import RunScopedToolView
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError, current_trace_id
|
||||
from app.core.logging import logger
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.chat import ChatMessage
|
||||
from app.modules.agent import DEFAULT_CHAT_AGENT
|
||||
from app.modules.chat.history import history_pairs
|
||||
from app.modules.chat.policies import validate_text_message
|
||||
from app.modules.goal_orchestrator import extract_session_goal, format_goal_directive
|
||||
from app.modules.memory.context import build_memory_context
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
from app.modules.runtime.events import EventStore
|
||||
from app.modules.safety.confirmation import ConfirmationStore
|
||||
from app.repositories import chat as chat_repo
|
||||
from app.repositories import runtime as runtime_repo
|
||||
from app.repositories.json_utils import dumps, loads_dict
|
||||
from app.schemas.chat import SendMessageRequest, SendMessageResponse
|
||||
from app.services import runtime as runtime_service
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.characters import default_model_hint, resolve_active_character
|
||||
from app.services.chat import recent_chat_messages
|
||||
from app.services.memory import retrieve_memories
|
||||
from app.services.personality import build_personality_summary
|
||||
from app.services.runtime import TERMINAL_RUN_STATUS
|
||||
from app.services.safety import decide_chat_safety
|
||||
|
||||
# 后台流式任务引用:asyncio.create_task 仅保留弱引用,需强引用防止中途被 GC。
|
||||
_running_stream_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
# 流式增量分片阈值:每攒够 N 字符产出一条 delta 事件,配合 sse_poll_interval(0.01s)
|
||||
# 让前端看到逐段平滑增量。值越小流式越细、事件数越多(run_max_events 默认 1000)。
|
||||
_REASONING_FLUSH_CHARS = 8
|
||||
_CONTENT_FLUSH_CHARS = 8
|
||||
|
||||
_provider_manager: ProviderManager | None = None
|
||||
|
||||
|
||||
def _get_provider_manager() -> ProviderManager:
|
||||
global _provider_manager
|
||||
if _provider_manager is None:
|
||||
_provider_manager = ProviderManagerFactory.create()
|
||||
return _provider_manager
|
||||
|
||||
|
||||
async def drain_pending_streams() -> None:
|
||||
"""等待所有进行中的后台流式任务完成。
|
||||
|
||||
``execute`` 在创建 run 后即返回,LLM 流式在后台任务中执行;调用方在需要确保
|
||||
运行终态时(测试断言、优雅停机)await 此函数。
|
||||
"""
|
||||
if _running_stream_tasks:
|
||||
await asyncio.gather(*_running_stream_tasks, return_exceptions=True)
|
||||
|
||||
|
||||
def _contract_system_messages(contract: RuntimeContract) -> list[str]:
|
||||
messages = [*contract.system_instructions]
|
||||
if contract.system_prompt:
|
||||
messages.append(contract.system_prompt) # 卡的 system_prompt:道体增补层(§6)
|
||||
if contract.session_goal:
|
||||
messages.append(format_goal_directive(contract.session_goal))
|
||||
if contract.active_goals:
|
||||
messages.append(contract.active_goals) # 在 session_goal 之后注入
|
||||
if contract.mission:
|
||||
messages.append(f"核心使命:\n{contract.mission}") # 道体恒定层
|
||||
if contract.lorebook_before:
|
||||
messages.append(contract.lorebook_before)
|
||||
if contract.personality_summary:
|
||||
messages.append(f"当前人格:\n{contract.personality_summary}")
|
||||
if contract.lorebook_after:
|
||||
messages.append(contract.lorebook_after)
|
||||
if contract.emotion_state:
|
||||
messages.append(contract.emotion_state)
|
||||
if contract.memory_context:
|
||||
messages.append(contract.memory_context)
|
||||
if contract.temporal_context:
|
||||
messages.append(contract.temporal_context)
|
||||
if contract.safety_guidelines:
|
||||
messages.append("行为边界:\n" + contract.safety_guidelines) # 道体安全(先于司命动态约束)
|
||||
if contract.safety_constraints:
|
||||
messages.append(
|
||||
"安全约束:\n" + "\n".join(f"- {item}" for item in contract.safety_constraints)
|
||||
)
|
||||
# 沙箱工作目录提示:避免模型在 shell 中使用 Linux 特有路径(如 /home/user、/tmp)
|
||||
# 导致 macOS 上沙箱执行时 cd 失败。
|
||||
messages.append(
|
||||
"当前运行在隔离沙箱中,所有文件操作(read_file/write_file/shell)的工作目录"
|
||||
"是沙箱隔离的 workspace。系统并非 Linux,/home/user、/root 等路径不存在。"
|
||||
"不要 cd 到这些目录;直接在 workspace 中对文件进行读写或执行命令。"
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
# 首轮对话自动重命名:用默认模型生成简短标题,失败静默不影响对话主流程。
|
||||
_TITLE_MAX_CHARS = 40
|
||||
_TITLE_STRIP_CHARS = "\"'“”‘’「」『』"
|
||||
|
||||
|
||||
|
||||
|
||||
async def _call_model(system_prompt: str, model_hint: str) -> str:
|
||||
"""调用 LLM 返回纯文本。"""
|
||||
manager = _get_provider_manager()
|
||||
messages = manager.to_message_list([system_prompt], [('user', system_prompt)])
|
||||
response = await manager.chat(messages, model_hint=model_hint)
|
||||
return response.content
|
||||
|
||||
async def _generate_session_title(user_content: str, assistant_content: str) -> str:
|
||||
"""用默认模型根据首轮对话内容生成简短会话标题。"""
|
||||
manager = _get_provider_manager()
|
||||
messages = manager.to_message_list(
|
||||
[
|
||||
"你会根据一段对话生成简短的中文会话标题。"
|
||||
"要求:不超过 16 个字;概括对话主题;直接输出标题文字;"
|
||||
"不要加引号、书名号、句号,不要解释。"
|
||||
],
|
||||
[("user", user_content), ("assistant", assistant_content)],
|
||||
)
|
||||
response = await manager.chat(messages, model_hint=settings.default_model_hint)
|
||||
raw = response.content.strip().split("\n", 1)[0].strip()
|
||||
return raw.strip(_TITLE_STRIP_CHARS)[:_TITLE_MAX_CHARS]
|
||||
|
||||
|
||||
class SendChatMessageUseCase:
|
||||
"""完成消息持久化与运行态记录;LLM 流式回复在后台任务中边产出边推 SSE 事件。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
owner_scope: str = settings.default_owner_scope,
|
||||
tool_runtime: ToolRuntime | None = None,
|
||||
confirmation_registry: ConfirmationRegistry | None = None,
|
||||
confirmation_store: ConfirmationStore | None = None,
|
||||
event_store: EventStore | None = None,
|
||||
agent_run_registry: AgentRunRegistry | None = None,
|
||||
python_skill_catalog: PythonSkillCatalog | None = None,
|
||||
) -> None:
|
||||
self._session = session
|
||||
self._owner_scope = owner_scope
|
||||
self._tool_runtime = tool_runtime
|
||||
self._confirmation_registry = confirmation_registry
|
||||
self._confirmation_store = confirmation_store
|
||||
self._event_store = event_store
|
||||
self._agent_run_registry = agent_run_registry
|
||||
self._python_skill_catalog = python_skill_catalog
|
||||
|
||||
async def execute(self, session_id: str, payload: SendMessageRequest) -> SendMessageResponse:
|
||||
validate_text_message(modality=payload.modality, content=payload.content)
|
||||
|
||||
character = await resolve_active_character(self._session, owner_scope=self._owner_scope)
|
||||
chat_session = await chat_repo.get_session(
|
||||
self._session, session_id, owner_scope=self._owner_scope
|
||||
)
|
||||
if chat_session is None or chat_session.status == "deleted":
|
||||
raise AppError("not_found", "会话不存在。")
|
||||
|
||||
if payload.prior_versions:
|
||||
# 重答:复用原文用户消息,不新建 user 消息,避免刷新后出现重复的消息对。
|
||||
recent = await chat_repo.list_recent_messages(
|
||||
self._session,
|
||||
chat_session.id,
|
||||
limit=10,
|
||||
)
|
||||
existing_user = None
|
||||
for msg in reversed(recent):
|
||||
if msg.role == "user" and msg.deleted_at is None:
|
||||
existing_user = msg
|
||||
break
|
||||
if existing_user is None:
|
||||
raise AppError("not_found", "要重答的原始用户消息不存在。")
|
||||
user_message = existing_user
|
||||
else:
|
||||
user_message = ChatMessage(
|
||||
character_id=character.id,
|
||||
session_id=chat_session.id,
|
||||
role="user",
|
||||
content=payload.content,
|
||||
modality="text",
|
||||
metadata_json=dumps({}),
|
||||
sensitivity="low",
|
||||
)
|
||||
self._session.add(user_message)
|
||||
await self._session.flush()
|
||||
|
||||
model_hint = payload.model_hint or default_model_hint(character)
|
||||
session_goal = extract_session_goal(payload.content)
|
||||
run = await runtime_service.create_run(
|
||||
self._session,
|
||||
character_id=character.id,
|
||||
session_id=chat_session.id,
|
||||
model_hint=model_hint,
|
||||
goal=session_goal or "回复用户消息",
|
||||
)
|
||||
user_message.run_id = run.id
|
||||
|
||||
await runtime_service.append_event(
|
||||
self._session,
|
||||
run_id=run.id,
|
||||
character_id=character.id,
|
||||
event_type="run.started",
|
||||
payload={"run_id": run.id, "session_id": chat_session.id},
|
||||
)
|
||||
# 先提交 run + run.started:HTTP 响应随后返回 run_id,前端据此建立 SSE;
|
||||
# 后台流式在独立 session 中边产出边提交,前端 SSE(DB 轮询)即可实时读到增量。
|
||||
await self._session.commit()
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._stream_assistant_response(
|
||||
run_id=run.id,
|
||||
character_id=character.id,
|
||||
chat_session_id=chat_session.id,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
_running_stream_tasks.add(task)
|
||||
task.add_done_callback(_running_stream_tasks.discard)
|
||||
|
||||
from app.core.config import settings as _settings
|
||||
|
||||
return SendMessageResponse(
|
||||
message_id=user_message.id,
|
||||
assistant_message_id=None,
|
||||
run_id=run.id,
|
||||
session_id=chat_session.id,
|
||||
character_id=character.id,
|
||||
status=run.status,
|
||||
stream_url=f"{_settings.api_prefix}/runs/{run.id}/stream",
|
||||
)
|
||||
|
||||
async def _stream_assistant_response(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
character_id: str,
|
||||
chat_session_id: str,
|
||||
payload: SendMessageRequest,
|
||||
) -> None:
|
||||
"""后台流式:独立 session 完成 LLM 调用与事件推送,异常定稿为 run.failed。"""
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await self._produce_assistant_stream(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
chat_session_id=chat_session_id,
|
||||
payload=payload,
|
||||
)
|
||||
except TimeoutError:
|
||||
await self._fail_stream(
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
code="run_timeout",
|
||||
message="运行超时。",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("chat_stream_failed", run_id=run_id)
|
||||
await self._fail_stream(
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
code="run_failed",
|
||||
message="运行失败,请稍后重试。",
|
||||
)
|
||||
|
||||
async def _produce_assistant_stream(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
run_id: str,
|
||||
character_id: str,
|
||||
chat_session_id: str,
|
||||
payload: SendMessageRequest,
|
||||
) -> None:
|
||||
character = await resolve_active_character(session, owner_scope=self._owner_scope)
|
||||
model_hint = payload.model_hint or default_model_hint(character)
|
||||
|
||||
run = await runtime_repo.get_run(session, run_id)
|
||||
if run is None or run.status in TERMINAL_RUN_STATUS:
|
||||
return
|
||||
session_goal = run.goal or ""
|
||||
|
||||
# 重答场景(prior_versions):在构建上下文前软删除被替换的旧 assistant 消息,
|
||||
# 保留原文用户消息不动——用户消息已在 execute() 中复用,不新建、不删除。
|
||||
if payload.prior_versions:
|
||||
latest = await chat_repo.list_recent_messages(
|
||||
session,
|
||||
chat_session_id,
|
||||
limit=5,
|
||||
)
|
||||
# latest 按 created_at ASC 排列且仅含未删除消息,从末尾往前找最后一个 assistant
|
||||
for i in range(len(latest) - 1, -1, -1):
|
||||
if latest[i].role == "assistant" and latest[i].deleted_at is None:
|
||||
latest[i].deleted_at = utc_now()
|
||||
break
|
||||
await session.commit()
|
||||
|
||||
safety = await decide_chat_safety(
|
||||
session,
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
personality = await build_personality_summary(session, character)
|
||||
memories = await retrieve_memories(
|
||||
session,
|
||||
character_id,
|
||||
limit=settings.memory_injection_limit,
|
||||
)
|
||||
memory_context = build_memory_context(memories)
|
||||
|
||||
# 息壤推送:将检索到的记忆以结构化数据推送到前端 SSE 事件流
|
||||
if memories.memories:
|
||||
memory_items = []
|
||||
for m in memories.memories:
|
||||
memory_items.append(
|
||||
{
|
||||
"id": m.id,
|
||||
"title": m.title,
|
||||
"content": m.content,
|
||||
"source": m.source,
|
||||
"confidence": m.confidence,
|
||||
"importance": m.importance,
|
||||
"type": m.type,
|
||||
}
|
||||
)
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="memory.retrieved",
|
||||
payload={"memories": memory_items},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
recent_messages = await recent_chat_messages(session, chat_session_id)
|
||||
trace_id = current_trace_id()
|
||||
|
||||
# 辰正 temporal_context(设计 §8.1):对话入口算时态快照 + 发 time.* 事件到当前 run。
|
||||
# 降级原则(§12):任何失败 → temporal_context=None,不注入,不阻塞对话。
|
||||
temporal_context: str | None = None
|
||||
if self._event_store is not None:
|
||||
try:
|
||||
from app.modules.time_sense.modulation import ModulationInput
|
||||
from app.services.time_sense import compute_live_snapshot
|
||||
|
||||
time_result = await compute_live_snapshot(
|
||||
session,
|
||||
self._event_store,
|
||||
character_id,
|
||||
ModulationInput(),
|
||||
run_id=run_id,
|
||||
owner_scope=self._owner_scope,
|
||||
)
|
||||
temporal_context = time_result.context_text or None
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("temporal_context 计算失败,已跳过", run_id=run_id)
|
||||
|
||||
# 灵核情绪状态注入(设计 §11.5):拉模式 compute_emotion_prompt。
|
||||
# 降级原则(§12):任何失败 → emotion_state=None,不注入,不阻塞对话。
|
||||
emotion_state: str | None = None
|
||||
try:
|
||||
from app.services.personality_emotion import compute_emotion_prompt
|
||||
|
||||
emotion_state = await compute_emotion_prompt(session, character_id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("emotion_state 计算失败,已跳过", run_id=run_id)
|
||||
|
||||
# 世谱(Lorebook)注入:扫对话历史匹配命中条目,注入 before/after(设计 §7)
|
||||
# 降级原则:任何失败 → 不注入,不阻塞对话。
|
||||
lorebook_before: str | None = None
|
||||
lorebook_after: str | None = None
|
||||
try:
|
||||
from app.services.lorebook import build_lorebook_injection
|
||||
|
||||
scan_parts = [payload.content]
|
||||
scan_parts.extend(
|
||||
m.content for m in recent_messages[-8:] if m.role in ("user", "assistant")
|
||||
)
|
||||
lorebook_before, lorebook_after = await build_lorebook_injection(
|
||||
session,
|
||||
character_id,
|
||||
self._owner_scope,
|
||||
"\n".join(scan_parts),
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("世谱注入失败,已跳过", run_id=run_id)
|
||||
|
||||
profile_data = loads_dict(character.profile_json or "{}")
|
||||
contract = RuntimeContract(
|
||||
character_id=character_id,
|
||||
session_id=chat_session_id,
|
||||
run_id=run_id,
|
||||
model_hint=model_hint,
|
||||
agent=DEFAULT_CHAT_AGENT,
|
||||
system_instructions=[],
|
||||
system_prompt=profile_data.get("system_prompt") or None,
|
||||
session_goal=session_goal or None,
|
||||
personality_summary=personality.text,
|
||||
mission=character.mission,
|
||||
safety_guidelines=character.safety_guidelines,
|
||||
lorebook_before=lorebook_before,
|
||||
lorebook_after=lorebook_after,
|
||||
emotion_state=emotion_state,
|
||||
memory_context=memory_context,
|
||||
temporal_context=temporal_context,
|
||||
recent_messages=recent_messages,
|
||||
safety_constraints=safety.constraints,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
# 道体完整性校验(§6):确认恒定层未被动态注入在数据层覆盖
|
||||
from app.modules.runtime.integrity import validate_daoti_integrity
|
||||
|
||||
validate_daoti_integrity(
|
||||
contract,
|
||||
mission=character.mission,
|
||||
safety_guidelines=character.safety_guidelines,
|
||||
)
|
||||
|
||||
# —— 活跃目标注入 ——
|
||||
try:
|
||||
from app.services.governance import load_active_goals_context
|
||||
|
||||
active_goals = await load_active_goals_context(character.id)
|
||||
if active_goals:
|
||||
contract.active_goals = active_goals
|
||||
except Exception:
|
||||
logger.warning("活跃目标加载失败,跳过注入")
|
||||
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="model.request.started",
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
actor="system",
|
||||
decision="auto",
|
||||
input_summary=model_hint,
|
||||
resource_type="run",
|
||||
resource_id=run_id,
|
||||
)
|
||||
system_messages = compose_system_messages(
|
||||
_contract_system_messages(contract),
|
||||
contract.agent,
|
||||
[],
|
||||
)
|
||||
|
||||
# ── 百工技能执行引擎:匹配 → prompt 注入 → 工具绑定注册 ──
|
||||
from app.application.skill_executor import SkillExecutor
|
||||
|
||||
tool_view = RunScopedToolView(self._tool_runtime) if self._tool_runtime is not None else None
|
||||
skill_exec = SkillExecutor(
|
||||
tool_view=tool_view,
|
||||
event_store=self._event_store,
|
||||
)
|
||||
try:
|
||||
await skill_exec.match_and_activate(
|
||||
payload.content,
|
||||
system_messages,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
skill_refs=contract.agent.skill_refs if contract.agent else (),
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("skill_executor 匹配失败,已跳过")
|
||||
|
||||
agent_task = None
|
||||
completed_plan_step_ids: set[str] = set()
|
||||
|
||||
# ── 洛书 Deliberative 规划 ──
|
||||
if settings.enable_deliberative_planning:
|
||||
from app.application.planning import (
|
||||
build_planning_prompt,
|
||||
format_plan_for_prompt,
|
||||
parse_plan,
|
||||
should_plan,
|
||||
)
|
||||
|
||||
if should_plan(payload.content):
|
||||
try:
|
||||
planner_prompt = build_planning_prompt(
|
||||
payload.content,
|
||||
memory_context=memory_context[:300] if memory_context else '',
|
||||
)
|
||||
planner_raw = await _call_model(planner_prompt, model_hint)
|
||||
plan = parse_plan(planner_raw)
|
||||
if plan:
|
||||
agent_task = task_from_deliberative_plan(
|
||||
plan,
|
||||
run_id=run_id,
|
||||
agent_name=contract.agent.name if contract.agent else "默认 Agent",
|
||||
objective=payload.content,
|
||||
)
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="plan.created",
|
||||
source="planner",
|
||||
payload=task_plan_payload(agent_task),
|
||||
)
|
||||
await session.commit()
|
||||
plan_text = format_plan_for_prompt(plan)
|
||||
system_messages.append(
|
||||
"【当前计划】\n请按以下计划逐步执行。"
|
||||
"每一步完成后标记完成,再进入下一步。"
|
||||
"全部完成后回复总结。\n\n" + plan_text
|
||||
)
|
||||
logger.info(f"洛书生成 deliberative plan: {plan.title} ({len(plan.steps)} 步)")
|
||||
except Exception:
|
||||
logger.warning('deliberative 规划生成失败,回退 ReAct')
|
||||
|
||||
python_skills: tuple[PythonSkillDefinition, ...] = ()
|
||||
if self._python_skill_catalog is not None:
|
||||
python_skills = self._python_skill_catalog.match(
|
||||
payload.content,
|
||||
skill_refs=contract.agent.skill_refs if contract.agent else (),
|
||||
metadata={
|
||||
"run_id": run_id,
|
||||
"session_id": chat_session_id,
|
||||
"character_id": character_id,
|
||||
"model_hint": model_hint,
|
||||
},
|
||||
)
|
||||
for skill in python_skills:
|
||||
if skill.instructions:
|
||||
system_messages.append(f"【Python Skill:{skill.name}】\n{skill.instructions}")
|
||||
|
||||
manager = _get_provider_manager()
|
||||
messages = manager.to_message_list(
|
||||
system_messages,
|
||||
history_pairs(recent_messages),
|
||||
)
|
||||
|
||||
reasoning_buf = ""
|
||||
content_buf = ""
|
||||
full_content = ""
|
||||
had_reasoning = False
|
||||
reasoning_finalized = False
|
||||
last_model = model_hint
|
||||
|
||||
async def flush_reasoning(*, force: bool = False) -> None:
|
||||
nonlocal reasoning_buf
|
||||
while reasoning_buf and (len(reasoning_buf) >= _REASONING_FLUSH_CHARS or force):
|
||||
cut = (
|
||||
_REASONING_FLUSH_CHARS
|
||||
if len(reasoning_buf) >= _REASONING_FLUSH_CHARS
|
||||
else len(reasoning_buf)
|
||||
)
|
||||
delta, reasoning_buf = reasoning_buf[:cut], reasoning_buf[cut:]
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="reasoning.delta",
|
||||
source="model",
|
||||
payload={"delta": delta},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def flush_content(*, force: bool = False) -> None:
|
||||
nonlocal content_buf
|
||||
while content_buf and (len(content_buf) >= _CONTENT_FLUSH_CHARS or force):
|
||||
cut = (
|
||||
_CONTENT_FLUSH_CHARS
|
||||
if len(content_buf) >= _CONTENT_FLUSH_CHARS
|
||||
else len(content_buf)
|
||||
)
|
||||
delta, content_buf = content_buf[:cut], content_buf[cut:]
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="message.delta",
|
||||
source="model",
|
||||
payload={"delta": delta},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def finalize_reasoning() -> None:
|
||||
"""收尾思考阶段:冲刷残余 reasoning 并推送 reasoning.completed。
|
||||
|
||||
在第一段正文出现前调用,确保思考摘要完整先于正文;流结束兜底再调一次。
|
||||
多轮 Agent 场景下,每轮模型输出的 content 出现前都会触发,已被 reasoning_finalized
|
||||
标记短路——即仅第一段 reasoning 完整 flush 后发 completed,后续轮次的 reasoning
|
||||
仍经 on_chunk 实时 flush,但不再发 completed(避免事件流断裂)。
|
||||
|
||||
思考模式关闭时(payload.thinking=False)不处理任何 reasoning 事件,
|
||||
防止模型仍产出 reasoning_content 导致前端 ThoughtBlock 误展示。
|
||||
"""
|
||||
nonlocal reasoning_finalized
|
||||
if reasoning_finalized or not payload.thinking:
|
||||
return
|
||||
reasoning_finalized = True
|
||||
await flush_reasoning(force=True)
|
||||
if had_reasoning:
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="reasoning.completed",
|
||||
source="model",
|
||||
payload={},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def on_chunk(chunk: ChatChunk) -> None:
|
||||
"""把 Agent 流式 chunk 喂给现有 buf/flush/finalize 逻辑,事件行为与单轮流式等价。"""
|
||||
nonlocal reasoning_buf, content_buf, full_content, had_reasoning
|
||||
# 思考模式关闭时不处理 reasoning chunk,避免前端 ThoughtBlock 错误展示
|
||||
if payload.thinking and chunk.delta_reasoning:
|
||||
had_reasoning = True
|
||||
reasoning_buf += chunk.delta_reasoning
|
||||
await flush_reasoning()
|
||||
if chunk.delta_content:
|
||||
# 正文开始前先收尾思考,保证思考摘要完整先于正文
|
||||
await finalize_reasoning()
|
||||
content_buf += chunk.delta_content
|
||||
full_content += chunk.delta_content
|
||||
await flush_content()
|
||||
|
||||
# 收集工具调用信息,最终存入消息 metadata 供前端持久化展示
|
||||
tool_calls: list[dict[str, object]] = []
|
||||
_started_tool_calls: dict[str, dict[str, object]] = {}
|
||||
_plan_steps_by_tool_call = {}
|
||||
|
||||
async def _reset_turn_state() -> None:
|
||||
"""每轮 Agent 开始前重置 reasoning 标记(多轮场景支持)。
|
||||
|
||||
Agent loop 首轮产出 reasoning → content → tool_calls,次轮可能再产出 reasoning。
|
||||
若不重置,reasoning_finalized=True 导致 reasoning.completed 无法再发出。
|
||||
"""
|
||||
nonlocal had_reasoning, reasoning_finalized
|
||||
had_reasoning = False
|
||||
reasoning_finalized = False
|
||||
|
||||
async def on_tool_event(event_type: str, payload_dict: dict[str, object]) -> None:
|
||||
"""工具生命周期事件:作为 agent 事件落库 + 收集 tool_calls 入 metadata。"""
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type=event_type,
|
||||
source="agent",
|
||||
payload=payload_dict,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# 收集工具调用信息供前端持久化展示
|
||||
if event_type == "tool.started":
|
||||
tc_id = str(payload_dict.get("tool_call_id", ""))
|
||||
tool_name = str(payload_dict.get("tool_name", ""))
|
||||
if agent_task is not None:
|
||||
step = next_step_for_tool(agent_task, tool_name, completed_plan_step_ids)
|
||||
if step is not None:
|
||||
_plan_steps_by_tool_call[tc_id] = step
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="plan.step.started",
|
||||
source="planner",
|
||||
payload=step_event_payload(agent_task, step, status="running"),
|
||||
)
|
||||
await session.commit()
|
||||
_started_tool_calls[tc_id] = {
|
||||
"tool_name": str(payload_dict.get("tool_name", "")),
|
||||
"tool_call_id": tc_id,
|
||||
"args": str(payload_dict.get("args", "")),
|
||||
"status": "running",
|
||||
}
|
||||
elif event_type in ("tool.completed", "tool.failed"):
|
||||
tc_id = str(payload_dict.get("tool_call_id", ""))
|
||||
entry = _started_tool_calls.pop(tc_id, None)
|
||||
step = _plan_steps_by_tool_call.pop(tc_id, None)
|
||||
if step is not None and agent_task is not None:
|
||||
tool_status = str(payload_dict.get("status", "error"))
|
||||
step_status = "completed" if tool_status == "success" else "failed"
|
||||
if step_status == "completed":
|
||||
completed_plan_step_ids.add(step.id)
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type=f"plan.step.{step_status}",
|
||||
source="planner",
|
||||
payload=step_event_payload(
|
||||
agent_task,
|
||||
step,
|
||||
status=step_status,
|
||||
result_summary=str(payload_dict.get("result", ""))[:500] or None,
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
if entry is not None:
|
||||
entry["status"] = str(payload_dict.get("status", "error"))
|
||||
entry["result"] = str(payload_dict.get("result", ""))
|
||||
tool_calls.append(entry)
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
python_skill_activation = await activate_python_skills(extensions, python_skills)
|
||||
if python_skills:
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="skill.activated",
|
||||
source="agent",
|
||||
payload={"skills": [skill.name for skill in python_skills], "kind": "python"},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def persist_agent_event(event: AgentEvent, _run_context: AgentRunContext) -> None:
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type=event.type,
|
||||
source="agent",
|
||||
payload=event.payload,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
extensions.add_event_listener(persist_agent_event)
|
||||
on_confirm_timeout = None
|
||||
if self._confirmation_store is not None:
|
||||
confirmation_store = self._confirmation_store
|
||||
|
||||
async def on_confirm_timeout(confirmation_id: str) -> None:
|
||||
await confirmation_store.mark_timed_out(confirmation_id)
|
||||
|
||||
agent = Agent(
|
||||
AgentConfig(
|
||||
tools=tuple(
|
||||
default_tools(
|
||||
tool_view.specs_by_name(),
|
||||
allowed_tools=contract.agent.tool_policy.allowed_tools
|
||||
if contract.agent
|
||||
else (),
|
||||
)
|
||||
if tool_view is not None
|
||||
else ()
|
||||
),
|
||||
tool_runtime=tool_view,
|
||||
model_hint=(contract.agent.model_hint or model_hint)
|
||||
if contract.agent
|
||||
else model_hint,
|
||||
thinking_enabled=payload.thinking,
|
||||
reasoning_effort=payload.reasoning_effort,
|
||||
max_iterations=contract.agent.execution_policy.max_iterations
|
||||
if contract.agent
|
||||
else settings.agent_max_iterations,
|
||||
confirmation_registry=self._confirmation_registry,
|
||||
confirmation_timeout=settings.confirmation_timeout_seconds,
|
||||
on_turn=_reset_turn_state,
|
||||
on_chunk=on_chunk,
|
||||
on_tool_event=on_tool_event,
|
||||
on_confirm_timeout=on_confirm_timeout,
|
||||
),
|
||||
messages=messages,
|
||||
run_context=AgentRunContext(
|
||||
run_id=run_id,
|
||||
session_id=chat_session_id,
|
||||
character_id=character_id,
|
||||
trace_id=trace_id,
|
||||
),
|
||||
extensions=extensions,
|
||||
# 保留模块级符号作为注入点,现有测试与外部集成无需重写。
|
||||
runner=run_agent_turn,
|
||||
)
|
||||
if self._agent_run_registry is not None:
|
||||
await self._agent_run_registry.register(run_id, agent)
|
||||
try:
|
||||
run = await runtime_repo.get_run(session, run_id)
|
||||
if run is None or run.status in TERMINAL_RUN_STATUS:
|
||||
return
|
||||
async with asyncio.timeout(settings.run_timeout_seconds):
|
||||
agent_result = await agent.continue_run()
|
||||
finally:
|
||||
if self._agent_run_registry is not None:
|
||||
await self._agent_run_registry.unregister(run_id, agent)
|
||||
await python_skill_activation.close()
|
||||
|
||||
# 流结束兜底:模型可能只输出思考无正文,或残余 buffer 未达阈值。
|
||||
await finalize_reasoning()
|
||||
await flush_content(force=True)
|
||||
|
||||
# 取消可能发生在模型返回与持久化之间;终态 Run 不再写助手消息或覆盖状态。
|
||||
run = await runtime_repo.get_run(session, run_id)
|
||||
if run is None or run.status in TERMINAL_RUN_STATUS:
|
||||
return
|
||||
|
||||
# 以 agent_result.content 作为权威最终回复正文持久化(应与 full_content 一致;
|
||||
# 不一致时以 agent 循环产出为准,因后者覆盖多轮模型最终输出)。
|
||||
final_content = agent_result.content or full_content
|
||||
|
||||
version_ts = utc_now().isoformat()
|
||||
# 构造版本历史:prior_versions 前置 + 当前回复作为最新版本
|
||||
prior = payload.prior_versions or []
|
||||
all_versions = [*prior, {"content": final_content, "created_at": version_ts}]
|
||||
active_idx = len(all_versions) - 1 # 始终最新
|
||||
|
||||
assistant = ChatMessage(
|
||||
character_id=character_id,
|
||||
session_id=chat_session_id,
|
||||
role="assistant",
|
||||
content=final_content,
|
||||
modality="text",
|
||||
run_id=run_id,
|
||||
metadata_json=dumps(
|
||||
{
|
||||
"model_hint": model_hint,
|
||||
"model": last_model,
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
),
|
||||
sensitivity="low",
|
||||
versions_json=dumps(all_versions),
|
||||
active_version_idx=active_idx,
|
||||
)
|
||||
session.add(assistant)
|
||||
await session.flush()
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="model.response.completed",
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
actor="model",
|
||||
decision="auto",
|
||||
output_summary="模型响应已完成。",
|
||||
resource_type="message",
|
||||
resource_id=assistant.id,
|
||||
)
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="message.completed",
|
||||
source="model",
|
||||
payload={"message_id": assistant.id, "content": final_content},
|
||||
)
|
||||
run = await runtime_repo.get_run(session, run_id)
|
||||
if run is not None and run.status not in TERMINAL_RUN_STATUS:
|
||||
await runtime_service.complete_run(session, run)
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="run.completed",
|
||||
payload={"run_id": run_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# 首轮对话完成后,用默认模型自动生成会话标题(失败不影响主流程)
|
||||
await self._maybe_rename_session(
|
||||
session,
|
||||
chat_session_id=chat_session_id,
|
||||
user_content=payload.content,
|
||||
assistant_content=final_content,
|
||||
)
|
||||
|
||||
# 每轮对话后检查大周天触发条件(失败不影响主流程)
|
||||
try:
|
||||
from app.application.big_cycle import maybe_trigger_big_cycle
|
||||
|
||||
await maybe_trigger_big_cycle(character_id, trigger="session_turn")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("大周天触发检查失败,已跳过")
|
||||
|
||||
# 小周天每轮记忆候选 + 返照反思(后台任务,失败不影响主流程)
|
||||
try:
|
||||
from app.application.reflection import start_reflection_after_turn
|
||||
|
||||
start_reflection_after_turn(
|
||||
character_id=character_id,
|
||||
session_id=chat_session_id,
|
||||
user_content=payload.content,
|
||||
assistant_content=final_content,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("小周天反思触发失败,已跳过")
|
||||
|
||||
# —— 会话目标自动持久化 ——
|
||||
try:
|
||||
from app.db.session import AsyncSessionLocal as GoalSession
|
||||
from app.services.goal_persist import auto_persist_session_goal
|
||||
|
||||
async with GoalSession() as goal_session:
|
||||
await auto_persist_session_goal(goal_session, character.id, payload.content)
|
||||
await goal_session.commit()
|
||||
except Exception:
|
||||
logger.warning("会话目标自动持久化失败,跳过")
|
||||
|
||||
async def _maybe_rename_session(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
chat_session_id: str,
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
) -> None:
|
||||
"""首轮对话完成后用 LLM 生成会话标题;已有标题或非首轮则跳过,失败静默。"""
|
||||
try:
|
||||
existing = await chat_repo.get_session(session, chat_session_id)
|
||||
if existing is None or existing.title:
|
||||
return
|
||||
assistant_count = await chat_repo.count_messages_by_role(
|
||||
session, chat_session_id, role="assistant"
|
||||
)
|
||||
if assistant_count != 1:
|
||||
return
|
||||
title = await _generate_session_title(user_content, assistant_content)
|
||||
if not title:
|
||||
return
|
||||
await chat_repo.update_session_title(session, chat_session_id, title)
|
||||
await session.commit()
|
||||
logger.info("session_auto_renamed", session_id=chat_session_id, title=title)
|
||||
except Exception:
|
||||
logger.warning("session_title_generate_failed", session_id=chat_session_id)
|
||||
|
||||
async def _fail_stream(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
character_id: str,
|
||||
code: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
run = await runtime_repo.get_run(session, run_id)
|
||||
if run is None or run.status in TERMINAL_RUN_STATUS:
|
||||
return
|
||||
await runtime_service.fail_run(session, run, code=code, message=message)
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="run.failed",
|
||||
payload={"code": code, "message": message},
|
||||
)
|
||||
await session.commit()
|
||||
Loaded 100 of 963 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user