Compare commits

..
2 Commits
Author SHA1 Message Date
chenxin efc08dab5e 完善文档 2026-09-22 10:58:39 +08:00
chenxin 192ea96eb4 删掉多余的 2026-09-21 16:43:26 +08:00
747 changed files with 64399 additions and 40643 deletions

No files matched your search

+48 -45
View File
@@ -88,7 +88,7 @@ LOG_ROTATION_SIZE=10 MB
# 本地单用户范围标识,当前阶段不实现多用户、组织、租户、OAuth 或 SSO # 本地单用户范围标识,当前阶段不实现多用户、组织、租户、OAuth 或 SSO
DEFAULT_OWNER_SCOPE=local-user DEFAULT_OWNER_SCOPE=local-user
# 默认逻辑模型名(提示值;实际路由由神识端点目录决定) # 默认逻辑模型名。必须指向 config/models.yaml 中真实远程 provider 的逻辑模型
DEFAULT_MODEL_HINT=deepseek-v4-flash DEFAULT_MODEL_HINT=deepseek-v4-flash
# --- 管理端点和认证 --- # --- 管理端点和认证 ---
@@ -207,7 +207,7 @@ BIG_CYCLE_MEMORY_THRESHOLD=20
# 两次大周天最小间隔(秒),避免频繁触发 # 两次大周天最小间隔(秒),避免频繁触发
BIG_CYCLE_MIN_INTERVAL_SECONDS=300 BIG_CYCLE_MIN_INTERVAL_SECONDS=300
# 大周天候选生成所用模型(逻辑模型名;路由由神识决定) # 大周天候选生成所用模型(config/models.yaml 的逻辑模型名)
BIG_CYCLE_MODEL=deepseek-v4-flash BIG_CYCLE_MODEL=deepseek-v4-flash
# 单次大周天生成的演化候选上限 # 单次大周天生成的演化候选上限
@@ -238,7 +238,7 @@ MCP_STDIO_CALL_TIMEOUT_SECONDS=30
# MCP stdio SIGTERM → SIGKILL 等待秒数 # MCP stdio SIGTERM → SIGKILL 等待秒数
MCP_STDIO_STOP_TIMEOUT_SECONDS=5 MCP_STDIO_STOP_TIMEOUT_SECONDS=5
# Embedding 模型逻辑名(路由由神识决定) # Embedding 模型逻辑名。指向 config/models.yaml 中已注册的 embedding 模型
EMBEDDING_MODEL_HINT=bge-m3 EMBEDDING_MODEL_HINT=bge-m3
# --- 感知与导入 --- # --- 感知与导入 ---
@@ -259,12 +259,53 @@ IMPORT_ALLOWED_AUDIO_MIME_TYPES=audio/wav,audio/mpeg,audio/mp4,audio/flac
# ===================================================================== # =====================================================================
# 模型网关 · 神识(Go 网关统一接管,灵台不经手任何 Provider 密钥) # 模型网关 · 神识(LLM Provider API Keys)
# ===================================================================== # =====================================================================
# 模型端点目录(含 base_url / api_key / 上游模型 / 优先级)统一经神识环境变量 SHENSHI_ENDPOINTS 配置, # 模型名称、provider 映射、能力标签、超时、限流、价格等,统一放在 config/models.yaml 中。
# 详见下方「神识 · 模型网关」段;旧 config/models|providers|pricing.yaml 与灵台侧各 provider # .env 只保存各 provider 的 URL 与 API key,避免模型策略散落在环境变量中。
# API Key 已随 ai_provider 退役(归档于 shenshi/config/ 仅供参考)。
# --- 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
# ===================================================================== # =====================================================================
@@ -624,41 +665,3 @@ SAFETY_POLICY_PATH=config/safety.yaml
# BACKEND_URL=http://localhost:8000 (本地开发,跨公网须 https://) # BACKEND_URL=http://localhost:8000 (本地开发,跨公网须 https://)
# Docker 部署由 docker-compose.app.yml 写死为 http://hunjing-backend:8000 # Docker 部署由 docker-compose.app.yml 写死为 http://hunjing-backend:8000
# 浏览器所有请求同源走 BFF(/api/proxy、/api/sse、/api/auth),客户端不需要后端地址。 # 浏览器所有请求同源走 BFF(/api/proxy、/api/sse、/api/auth),客户端不需要后端地址。
# =====================================================================
# 神识 · 模型网关(Go,rewrite/1.0 M0) — 灵台唯一模型出口
# =====================================================================
# 神识服务地址;为空时灵台 Kernel 使用进程内回声桩(仅骨架验证,不出真实模型流)。
# 本地开发:SHENSHI_BASE_URL=http://127.0.0.1:8080(先 shenshi/ 目录 `go run .`)
# Docker 部署由 docker-compose.app.yml 写死为 http://hunjing-shenshi:8080
SHENSHI_BASE_URL=
# 灵台 → 神识服务令牌(两边一致);为空时神识开发档放行不校验
SHENSHI_SERVICE_TOKEN=
# 神识流式调用超时(秒)
SHENSHI_TIMEOUT_SECONDS=120.0
# 神识服务监听端口(shenshi 服务自身)
SHENSHI_PORT=8080
# 神识自有库(usage_logs/byok_keys/quota_balances;未配置时进程内存储,重启即失)
# 本地开发可留空;正式环境指向 hunjing-postgres(如 postgresql://hunjing:hunjing@hunjing-postgres:5432/hunjing_shenshi)
SHENSHI_DATABASE_URL=
# BYOK 主密钥(32 字节 hex 或 base64;未配置时 BYOK 功能整体禁用 fail-closed)
# 生成示例:python3 -c "import secrets; print(secrets.token_hex(32))"
SHENSHI_BYOK_MASTER_KEY=
# 用户级软配额(token 口径;0 = 不限制,P0/P1 只做预算熔断不做计费)
SHENSHI_USER_BUDGET_TOKENS=0
# 神识出站端点目录(JSON 数组;未配置时回声桩兜底)。
# type: openai_compatible(云厂商兼容面与元炁 http://hunjing-yuanqi:11434 共用)
# 示例:
# SHENSHI_ENDPOINTS=[{"type":"openai_compatible","name":"yuanqi","base_url":"http://hunjing-yuanqi:11434","model":"bge-chat","priority":100}]
SHENSHI_ENDPOINTS=
# =====================================================================
# 照壁 · BFF(Fastify,rewrite/1.0 M0) — 鉴权透传 / SSE 直通
# =====================================================================
# 照壁监听端口
BFF_PORT=3000
# 照壁转发的灵台地址;本地开发默认 http://127.0.0.1:8000
# Docker 部署由 docker-compose.app.yml 写死为 http://hunjing-backend:8000
BACKEND_URL=http://127.0.0.1:8000
+163 -112
View File
@@ -49,150 +49,211 @@ pi :<https://github.com/earendil-works/pi>
向量库:<https://github.com/oceanbase/seekdb> 向量库:<https://github.com/oceanbase/seekdb>
live-2d: <https://github.com/stevenjoezhang/live2d-widget> live-2d: <https://github.com/stevenjoezhang/live2d-widget>
上下文压缩:<https://github.com/headroomlabs-ai/headroom> 上下文压缩:<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/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/MemTensor/MemOS>
成熟的相关的项目:<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> 成熟的相关的项目:<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> 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> 模型网关设计:<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/axonhub>,<https://github.com/dyedd/lens>
## 完整技术栈 ## 项目定位
> 魂镜服务映射(文化名登记见 `docs/contracts/contract-glossary.md` §2):前端=琉璃镜(Next.js);BFF=照壁(Fastify);后端核心=灵台(Python FastAPI,六域业务+插件化运行时);高并发 I/O 层=神识(Go 模型网关)+博闻(Go MCP 搜索);桌面端=镜匣(Tauri 最小壳);乾坤(Rust 沙箱)、天目(OCR)、元炁(本地推理)为独立辅助服务。详细架构见 `docs/architecture/`。 本项目是一个面向长期运行与持续演化的 Agent 平台,核心目标是提供统一的 Agent Runtime、认知能力、工具执行、记忆、RAG、MCP、桌面端与基础设施。
--- 核心认知闭环:
### 前端(TypeScript 主力) ```mermaid
flowchart LR
A["感知"] --> B["上下文"]
B --> C["记忆"]
C --> D["理解"]
D --> E["规划与推理"]
E --> F["决策"]
F --> G["工具 / 行动"]
G --> H["环境反馈"]
H --> I["反思"]
I --> C
```
**定位**:浏览器端 UI、状态管理、实时交互 ## 必读文档
**技术选型**:
Next.js 16 + React 19 + TypeScript + Tailwind CSS v4 + Zustand/Jotai + shadcn/ui + pnpm
#### 核心依赖 开始涉及架构修改前:
- **实时通信**:SSE(主,接收流式文本/事件)/ WebSocket(双向协同场景) 1. `docs/architecture/overview.md`
- **数据请求**:TanStack Query(服务端状态管理与缓存)+ openapi-fetch(类型安全请求,类型由后端 OpenAPI 生成) 2. `docs/agent/overview.md`
- **可视化**:Mermaid(图表/流程图渲染)+ React Flow(流程编排与交互)/ Cytoscape.js(知识图谱交互) 3. `docs/decisions/README.md`
- **Markdown 渲染**:react-markdown + remark/rehype 插件体系 + Shiki(代码高亮,支持流式增量解析)
- **长列表虚拟滚动**:@tanstack/react-virtual(大量数据高性能渲染)
- **表单校验**:React Hook Form + Zod(类型安全的表单验证)
- **测试**:Vitest(单元测试)+ Testing Library(组件测试)+ Playwright(E2E 测试)
- **代码质量**:ESLint(flat config 格式)+ Prettier + Husky + lint-staged + commitlint
--- 涉及具体领域时,必须优先阅读对应领域文档。
### BFF(TypeScript + Node.js) ## 核心工程原则
**定位**:前端聚合层、鉴权透传、响应裁剪、SSE/WebSocket 代理、类型安全桥接 - 能力与基础设施解耦。
**技术选型**: - Agent Runtime 不直接绑定具体数据库、向量库、图数据库或对象存储。
Node.js 22 LTS + NestJS / Fastify + TypeScript + Zod + openapi-fetch - 外部能力通过 Tool Registry / MCP 接入。
- 长期数据进入 PostgreSQL;Redis 不作为核心业务数据最终持久化层。
- VectorStore、GraphStore、FileStorage 必须通过抽象接口访问。
- API 以 OpenAPI 3.1 为契约。
- 重要架构选择必须通过 ADR 记录。
- 不为了技术复杂度而提前引入分布式基础设施。
#### 核心依赖 ## Repository 结构
- **HTTP 框架**:Fastify(高性能)或 NestJS(模块化结构化) - `apps/`:客户端与服务应用。
- **聚合/代理**:http-proxy / undici(请求转发与合并) - `packages/`:共享库与 SDK。
- **鉴权**:JWT + cookie 透传 - `services/`:独立基础服务。
- **日志**:pino(高性能结构化日志) - `docs/`:架构、设计、规范与 ADR。
- **配置管理**:dotenv / convict(环境配置校验) - `tests/`:跨模块测试与评估。
- **测试**:Vitest + supertest(API 集成测试) - `scripts/`:开发和运维脚本。
- **代码质量**:ESLint + Prettier + Husky
--- ## Agent Runtime 规则
### 后端核心(Python + uv) Agent Runtime 是系统核心。
**定位**:业务主力、AI Agent 编排、RAG 检索 所有 Agent Run 应具有稳定的 `run_id`,并可追踪:
**技术选型**:
Python 3.12+(uv 包管理)+ FastAPI + SQLAlchemy 2.0 + Alembic + Pydantic v2 + httpx + loguru + uvicorn
#### 核心依赖 `Run -> Plan -> Model Call -> Memory -> Tool Call -> Observation -> Reflection -> Result`
- **任务队列**:Celery(稳定分布式任务)/ ARQ(轻量级,基于 Redis) Agent 不应把业务逻辑隐含在 Prompt 中;稳定规则、权限和状态应由代码或配置表达。
- **LLM 接入**:OpenAI SDK / LiteLLM(多厂商统一接口)
- **文档解析**:unstructured(多格式文档解析)+ pymupdf(PDF 处理)+ markitdown(Markdown 转换)
- **Agent 引擎**:LangGraph(有状态多 Agent 编排)或 自研轻量引擎
- **MCP 协议**:自研 MCP Client(stdio/sse 传输,接入第三方 MCP Server)
- **记忆系统**:自研三层记忆架构(Redis 短期记忆 + PostgreSQL 长期记忆 + VectorStore 向量记忆)
- **规划与推理**:自研 ReAct 循环 + Plan-and-Execute 模式
- **工具系统**:自研工具注册表(装饰器注册 + Pydantic 参数校验)+ 内置工具集(文件/网页/向量/图/代码沙箱/数据库/HTTP)
- **工具权限控制**:基于 Tauri 能力模型 + RBAC 用户角色
- **流式输出**:SSE 事件驱动(text_delta / tool_call / tool_result / agent_thought / finish)
- **断点续传**:Redis 缓存中间状态(支持重连恢复)
- **向量库抽象层**:自研 VectorStore 接口 → ChromaDB(开发) / PGVector(过渡) / Milvus(生产)
- **图数据库抽象层**:自研 GraphStore 接口 → Neo4j(开发) / NebulaGraph(生产)
- **文件存储抽象层**:自研 FileStorage 接口 → 本地(开发) / MinIO(自建) / S3(生产)
- **安全防护**:LLM Guard(Prompt Injection 检测)+ 自研输出审核(敏感词/隐私脱敏)
- **审计日志**:所有工具调用记录(用户 + 时间 + 工具 + 参数 + 结果)
- **评测框架**:DeepEval / Ragas(RAG 质量评估)
- **调试工具**:自研 Debug Panel(推理链可视化)/ LangSmith(可选)
- **测试**:pytest + pytest-asyncio + respx(HTTP Mock)
- **代码质量**:Ruff(lint + format)+ mypy + pre-commit
--- ## Memory 规则
### 桌面端(Rust + Tauri) 采用六类逻辑记忆:
**定位**:桌面客户端外壳、本地文件索引、沙箱执行、本地向量检索、离线能力 - Working Memory
**技术选型**: - Episodic Memory
Rust(cargo 管理)+ Tauri v2 + tokio + serde + specta(TypeScript 类型导出) - Semantic Memory
- Procedural Memory
- Social Memory
- Identity Memory
#### 核心依赖 统一通过 `Memory Manager` 访问。
- **本地检索**:tantivy(全文检索) 业务代码不得直接依赖 Redis、VectorStore 等具体实现。
- **沙箱执行**:Tauri 进程隔离 + 权限模型(capabilities 映射为 Agent 工具授权)
- **打包分发**:tauri-plugin-updater(自动更新)+ tauri-plugin-store(本地配置存储)
- **前端通信**:Tauri commands(同步/异步调用)+ events(双向事件)+ specta(类型安全)
- **代码质量**:clippy + rustfmt
--- 详细设计:`docs/agent/memory.md`
### 高并发 I/O 层(Go) ## Planning / Reasoning 规则
**定位**:网页抓取集群、API 网关与限流、长连接转发、定时调度、轻量级数据聚合 规划与推理采用多模式架构:
**技术选型**:
Go 1.25+(go mod 管理)+ Gin / Echo + colly + goquery + zap + viper
#### 核心依赖 - ReAct
- Plan-and-Execute
- Reflection
- Self-Correction
- Dynamic Replanning
- **并发控制**:ants(协程池)+ golang.org/x/time/rate(限流器) 详细设计:`docs/agent/planning.md`、`docs/agent/reasoning.md`。
- **无头浏览器**:chromedp / rod(动态页面渲染抓取)
- **配置与服务发现**:viper(配置管理)+ etcd(服务注册与发现,可选)
- **可观测性**:OpenTelemetry-Go + Prometheus client
- **测试**:testing + testify + httptest
- **代码质量**:golangci-lint + gofumpt
--- ## Tool 规则
### 基础设施 所有 Agent Tool 必须:
#### 数据库 - 注册到 Tool Registry。
- 使用结构化参数 Schema。
- 经过权限检查。
- 具有超时和错误处理。
- 记录调用审计。
- 返回统一结果结构。
- **关系数据库**:PostgreSQL 16(主库,支持 pgvector 扩展) 详细设计:`docs/tools/overview.md`
- **缓存/消息队列**:Redis 7+(Session / 任务队列 / 限流计数器 / 短期记忆存储)
#### 向量数据库 ## MCP 规则
- **开发/测试**:ChromaDB(轻量快速) MCP 是外部工具和上下文扩展协议。
- **生产过渡**:PostgreSQL + pgvector(减少运维复杂度)
- **大规模生产**:Milvus(分布式向量检索)
#### 图数据库 MCP Tool 必须经过内部 Tool Adapter 和 Permission Layer,不得绕过统一权限体系。
- **开发/测试**:Neo4j(社区版,易于上手) 详细设计:`docs/mcp/overview.md`
- **大规模生产**:NebulaGraph(分布式图数据库,高性能)
#### 文件/对象存储 ## API 规则
- **开发环境**:本地文件系统 - API 使用 OpenAPI 3.1。
- **自建生产**:MinIO(兼容 S3 协议) - FastAPI 负责生成服务端 Schema。
- **云生产**:AWS S3 / 阿里云 OSS / 腾讯云 COS - 前端优先使用类型安全客户端。
- SSE 用于 Agent 流式事件。
- WebSocket 只用于确有双向实时通信需求的场景。
- API 错误使用统一错误模型。
#### 容器 详细设计:`docs/api/overview.md`
- **容器化**:Docker + Docker Compose(本地开发环境一键编排) ## 数据访问规则
#### API 契约 - PostgreSQL:核心业务与长期结构化数据。
- Redis:缓存、短期状态、运行态数据。
- VectorStore:向量检索。
- GraphStore:关系与知识图谱。
- FileStorage:对象与文件。
- 所有可替换基础设施通过接口隔离。
- **规范**:OpenAPI 3.1(单一真源) ## 安全规则
- **客户端生成**:openapi-generator(生成 TypeScript / Go 客户端)
安全采用纵深防御:
`Authentication -> RBAC -> Tool Permission -> Sandbox -> Audit`
Tauri Capabilities 与服务端 RBAC 属于不同安全边界,不得混为一谈。
必须考虑:
- Prompt Injection
- Secret Leakage
- Sensitive Data
- Tool Abuse
- Sandbox Escape
- Auditability
## 测试规则
新增能力至少考虑:
- Unit Test
- Integration Test
- API Test
- Tool Test
- Agent Workflow Test
RAG / Agent 的关键能力必须增加 Evaluation,而不能只依赖普通单元测试。
## 常用技术约束
### Python
Python 3.12+,使用 `uv` 管理依赖;Ruff + mypy。
### TypeScript
Next.js + React + TypeScript;pnpm;ESLint + Prettier。
### Rust
Tauri v2 + Tokio;rustfmt + clippy。
### Go
Go 1.25+;gofumpt + golangci-lint。
## 文档规则
架构修改必须同步修改相关 `docs/`。
改变技术选择或重要架构决策时创建 ADR。
不要把完整设计复制到 `AGENTS.md`;`AGENTS.md` 只保留必须影响 Agent 行为的规则和导航。
## 文档导航
- 总体架构:`docs/architecture/overview.md`
- Agent:`docs/agent/overview.md`
- Memory:`docs/agent/memory.md`
- Planning:`docs/agent/planning.md`
- Reasoning:`docs/agent/reasoning.md`
- RAG:`docs/rag/overview.md`
- Tools:`docs/tools/overview.md`
- MCP:`docs/mcp/overview.md`
- API:`docs/api/overview.md`
- 数据:`docs/data/database.md`
- 安全:`docs/security/overview.md`
- 基础设施:`docs/infrastructure/development.md`
- 测试:`docs/testing/strategy.md`
- ADR:`docs/decisions/README.md`
## 文档设计 ## 文档设计
@@ -206,7 +267,6 @@ Go 1.25+(go mod 管理)+ Gin / Echo + colly + goquery + zap + viper
Docker 文件放在根目录 `docker/` 下。 Docker 文件放在根目录 `docker/` 下。
容器名称以 `hunjing` 开头。 容器名称以 `hunjing` 开头。
Compose 文件不使用顶部 `version` 字段。 Compose 文件不使用顶部 `version` 字段。
本地开发默认使用 SQLite 文件数据库,正式环境使用 PostgreSQL,并按需启用 Milvus、Neo4j / NebulaGraph、Redis 和对象存储。
## 项目目录 ## 项目目录
@@ -226,15 +286,7 @@ Compose 文件不使用顶部 `version` 字段。
### 本地开发(Makefile 统一入口) ### 本地开发(Makefile 统一入口)
所有本地服务的启动、测试与数据库迁移统一经根目录 Makefile(`make help` 查看全部): 所有本地服务的启动、测试与数据库迁移统一经根目录 Makefile(`make help` 查看全部)
```bash
make dev-core # 核心三服务并行:灵台 + 神识 + 照壁
make dev-backend # 灵台(另有 dev-shenshi / dev-bff / dev-frontend / dev-sandbox / dev-tianmu / dev-yuanqi)
make db-upgrade # Alembic 升级到 head(迁移编号 000/001/002…)
make db-revision m="说明" # 生成下一号迁移(自动编号 + autogenerate,禁手写哈希 id)
make test # 全部测试(backend / shenshi / bff / frontend)
```
### Docker ### Docker
@@ -244,7 +296,6 @@ make test # 全部测试(backend / shenshi / bff / fronten
```bash ```bash
# 先准备配置(只需一次) # 先准备配置(只需一次)
cp .env.example .env # 填写 POSTGRES_PASSWORD 与各 *_API_KEY cp .env.example .env # 填写 POSTGRES_PASSWORD 与各 *_API_KEY
# 模型端点目录经 SHENSHI_ENDPOINTS 配置(见 .env.example 神识段),无需复制 yaml
# 1) 启动基础设施(首次创建 hunjing-net 网络;Milvus 会自动带起 hunjing-etcd / hunjing-minio) # 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
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+39 -131
View File
@@ -1,142 +1,30 @@
# ============================== # Makefile for cleaning Python cache files and empty directories
# 魂镜 · 统一开发入口(rewrite/1.0) # Author: AI Assistant
# 所有本地服务的启动 / 测试 / 数据库迁移统一经本文件; # Usage: make clean or make clean-all
# Docker 编排(基础设施与应用容器)见 AGENTS.md「开发命令」与 docker/README.md。
# ==============================
.PHONY: clean clean-all clean-pyc clean-pytest clean-mypy clean-empty-dirs help
# Colors for pretty output
GREEN := \033[0;32m GREEN := \033[0;32m
YELLOW := \033[0;33m YELLOW := \033[0;33m
NC := \033[0m RED := \033[0;31m
NC := \033[0m # No Color
.PHONY: help \ # Default target
dev-backend dev-shenshi dev-bff dev-frontend dev-sandbox dev-tianmu dev-yuanqi dev-core dev-all \
db-upgrade db-downgrade db-revision db-current \
test test-backend test-shenshi test-bff test-frontend \
vet-shenshi typecheck-bff \
infra-up app-up app-down \
clean clean-all clean-pyc clean-pytest clean-mypy clean-empty-dirs clean-dry-run
# 默认目标
help: help:
@echo "$(GREEN)魂镜统一开发入口$(NC)" @echo "$(GREEN)Available targets:$(NC)"
@echo "" @echo " $(YELLOW)make clean$(NC) - Remove Python cache files, pytest cache, mypy cache, and empty directories"
@echo "$(YELLOW)服务启动(开发)$(NC)" @echo " $(YELLOW)make clean-pyc$(NC) - Remove Python cache files (__pycache__, .pyc, .pyo)"
@echo " make dev-backend 灵台(uvicorn 热重载,:8000)" @echo " $(YELLOW)make clean-pytest$(NC) - Remove pytest cache (.pytest_cache)"
@echo " make dev-shenshi 神识(go run,:8080)" @echo " $(YELLOW)make clean-mypy$(NC) - Remove mypy cache (.mypy_cache)"
@echo " make dev-bff 照壁(tsx watch,:3000)" @echo " $(YELLOW)make clean-empty-dirs$(NC) - Remove empty directories"
@echo " make dev-frontend 琉璃镜(next dev,:3001)" @echo " $(YELLOW)make clean-all$(NC) - Remove everything (cache, logs, coverage, build, dist, etc.)"
@echo " make dev-sandbox 乾坤沙箱(cargo run,:8090)"
@echo " make dev-tianmu 天目 OCR(:8101,可选)"
@echo " make dev-yuanqi 元炁本地推理(:11434,可选)"
@echo " make dev-core 核心三服务并行(灵台+神识+照壁)"
@echo " make dev-all 前述全部并行(不含沙箱/天目/元炁)"
@echo ""
@echo "$(YELLOW)数据库(Alembic,迁移编号 000/001/002…)$(NC)"
@echo " make db-upgrade 升级到最新(head)"
@echo " make db-downgrade 回退一步"
@echo " make db-revision m=\"说明\" 生成下一号迁移(自动编号+autogenerate)"
@echo " make db-current 查看当前版本"
@echo ""
@echo "$(YELLOW)测试与检查$(NC)"
@echo " make test 全部(后端+神识+照壁+前端)"
@echo " make test-backend / test-shenshi / test-bff / test-frontend"
@echo " make vet-shenshi go vet"
@echo " make typecheck-bff tsc --noEmit"
@echo ""
@echo "$(YELLOW)Docker 编排快捷方式$(NC)"
@echo " make infra-up / app-up / app-down"
@echo ""
@echo "$(YELLOW)清理$(NC)"
@echo " make clean / clean-all / clean-dry-run"
# ============================== 服务启动(开发) ==============================
dev-backend:
cd backend && uv run python -m app.main
dev-shenshi:
cd shenshi && go run .
dev-bff:
cd bff && npm run dev
dev-frontend:
cd frontend && pnpm dev
dev-sandbox:
cd sandbox && cargo run
dev-tianmu:
cd tianmu && uv run tianmu
dev-yuanqi:
cd yuanqi && uv run uvicorn src.main:app
# 核心三服务并行:灵台 + 神识 + 照壁(M0 验收链路)
dev-core:
$(MAKE) -j3 dev-backend dev-shenshi dev-bff
dev-all:
$(MAKE) -j4 dev-backend dev-shenshi dev-bff dev-frontend
# ============================== 数据库(Alembic) ==============================
# 迁移约定:revision id 与文件名前缀用三位顺序号(000、001、002…),生成统一走
# `make db-revision`(自动取最大号+1 并传 --rev-id);不要手写哈希 id。
db-upgrade:
cd backend && uv run alembic upgrade head
db-downgrade:
cd backend && uv run alembic downgrade -1
db-revision:
@next=$$(ls backend/alembic/versions 2>/dev/null | grep -E '^[0-9][0-9][0-9]_.*\.py$$' | cut -c1-3 | sort -n | tail -1); \
if [ -z "$$next" ]; then echo "未找到顺序编号迁移,请先确认基线 000 存在"; exit 1; fi; \
next=$$(printf '%03d' $$((10#$$next + 1))); \
echo "$(GREEN)生成迁移 $(NC)$$next:$(m)"; \
cd backend && uv run alembic revision --autogenerate --rev-id $$next -m "$(m)"
db-current:
cd backend && uv run alembic current
# ============================== 测试与检查 ==============================
test-backend:
cd backend && uv run pytest -q
test-shenshi:
cd shenshi && go test ./...
test-bff:
cd bff && npm test --silent
test-frontend:
cd frontend && pnpm test
test: test-backend test-shenshi test-bff test-frontend
vet-shenshi:
cd shenshi && go vet ./...
typecheck-bff:
cd bff && npm run type-check --silent
# ============================== Docker 编排快捷方式 ==============================
infra-up:
docker compose -f docker/docker-compose.infra.yml up -d
app-up:
docker compose -f docker/docker-compose.app.yml up -d --build
app-down:
docker compose -f docker/docker-compose.app.yml down
# ============================== 清理(保留原有能力) ==============================
# Main clean target
clean: clean-pyc clean-pytest clean-mypy clean-empty-dirs clean: clean-pyc clean-pytest clean-mypy clean-empty-dirs
@echo "$(GREEN)✓ Cleanup completed successfully!$(NC)" @echo "$(GREEN)✓ Cleanup completed successfully!$(NC)"
# Clean Python cache files
clean-pyc: clean-pyc:
@echo "$(YELLOW)Removing Python cache files...$(NC)" @echo "$(YELLOW)Removing Python cache files...$(NC)"
@find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
@@ -145,35 +33,55 @@ clean-pyc:
@find . -type f -name "*.pyd" -delete 2>/dev/null || true @find . -type f -name "*.pyd" -delete 2>/dev/null || true
@echo "$(GREEN)✓ Python cache files removed$(NC)" @echo "$(GREEN)✓ Python cache files removed$(NC)"
# Clean pytest cache
clean-pytest: clean-pytest:
@echo "$(YELLOW)Removing pytest cache...$(NC)" @echo "$(YELLOW)Removing pytest cache...$(NC)"
@rm -rf .pytest_cache 2>/dev/null || true @rm -rf .pytest_cache 2>/dev/null || true
@find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true @find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
@echo "$(GREEN)✓ Pytest cache removed$(NC)" @echo "$(GREEN)✓ Pytest cache removed$(NC)"
# Clean mypy cache
clean-mypy: clean-mypy:
@echo "$(YELLOW)Removing mypy cache...$(NC)" @echo "$(YELLOW)Removing mypy cache...$(NC)"
@rm -rf .mypy_cache 2>/dev/null || true @rm -rf .mypy_cache 2>/dev/null || true
@find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true @find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
@echo "$(GREEN)✓ Mypy cache removed$(NC)" @echo "$(GREEN)✓ Mypy cache removed$(NC)"
# Remove empty directories
clean-empty-dirs: clean-empty-dirs:
@echo "$(YELLOW)Removing empty directories...$(NC)" @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 @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 @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)" @echo "$(GREEN)✓ Empty directories removed$(NC)"
# Clean everything (extended cleanup)
clean-all: clean clean-all: clean
@echo "$(YELLOW)Removing additional files...$(NC)" @echo "$(YELLOW)Removing additional files...$(NC)"
@# Coverage reports
@rm -rf htmlcov .coverage coverage.xml .coverage.* 2>/dev/null || true @rm -rf htmlcov .coverage coverage.xml .coverage.* 2>/dev/null || true
@# Build artifacts
@rm -rf build/ dist/ *.egg-info .eggs/ 2>/dev/null || true @rm -rf build/ dist/ *.egg-info .eggs/ 2>/dev/null || true
@# Log files
@find . -type f -name "*.log" -delete 2>/dev/null || true @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 @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)" @echo "$(GREEN)✓ Extended cleanup completed!$(NC)"
# Dry run - show what would be deleted (for safety)
clean-dry-run: clean-dry-run:
@echo "$(YELLOW)Dry run - files that would be deleted (run 'make clean' to delete):$(NC)" @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 d -name "__pycache__" 2>/dev/null || echo "None"
@find . -type f -name "*.pyc" 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" @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" @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)"
+7 -12
View File
@@ -49,17 +49,12 @@
## 技术边界 ## 技术边界
工程架构以根目录 `AGENTS.md` 完整技术栈为基准,按五层组织;服务与文化命名登记见[契约词典](docs/contracts/contract-glossary.md)。 | 服务 | 技术 | 主要职责 |
| --- | --- | --- |
| 服务 | 文化名 | 技术 | 主要职责 | | 灵台 | Python | 人物、镜树、人格、记忆、缘契、三界与认知编排 |
| --- | --- | --- | --- | | 神识 | Go | Provider、Endpoint、模型路由、流式、BYOK、配额与成本 |
| 前端 | 琉璃镜 | Next.js / React / TS | 全部 Web 界面、镜树阅读、治理呈现、Live2D 与浏览器通感 | | 乾坤 | Rust | 沙箱、高风险执行、设备与具身安全 |
| BFF | 照壁 | Node.js / Fastify | 鉴权透传、SSE 代理、响应裁剪、类型桥接 | | 琉璃镜 | TypeScript / Next.js | 全部 Web 用户界面、Live2D 与浏览器通感 |
| 后端核心 | 灵台 | Python / FastAPI | 六域业务、插件化认知运行时(九枢)、事件溯源、司命裁决、百工注册 |
| 模型网关 | 神识 | Go | Provider、路由、流式、BYOK、配额与成本计量 |
| 桌面壳 | 镜匣 | Rust / Tauri | 最小桌面壳、本地能力桥、乾坤本地执行通道 |
| 隔离执行 | 乾坤 | Rust / axum | 沙箱、高风险执行、设备与具身安全 |
| 辅助服务 | 博闻 / 天目 / 元炁 | Go / Python | MCP 联网搜索 / 本地 OCR / 本地推理 |
神识、百工、司命和灵台遵循:**神识通协议,百工掌能力,司命决边界,灵台承结果。** 神识、百工、司命和灵台遵循:**神识通协议,百工掌能力,司命决边界,灵台承结果。**
@@ -74,7 +69,7 @@ flowchart LR
E --> F[代码与测试] E --> F[代码与测试]
``` ```
现有代码是迁移与审计对象,不反向覆盖已确认的产品定义。当前处于 rewrite/1.0 全量重写期,架构与契约层已按五层栈重建,范围与节奏见重写执行计划(已收口删除)。 现有代码是迁移与审计对象,不反向覆盖已确认的产品定义。
## 文档入口 ## 文档入口
-16
View File
@@ -1,16 +0,0 @@
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/
+64
View File
@@ -0,0 +1,64 @@
# 后端开发
## 技术栈
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、数据库密码)必须通过环境变量或配置文件管理,禁止硬编码到代码中
## 代码审查
开发完成后必须使用以下命令进行代码检查:
```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` 加载)
-4
View File
@@ -15,10 +15,6 @@ script_location = %(here)s/alembic
# Or organize into date-based subdirectories (requires recursive_version_locations = true) # 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 # file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# 魂镜迁移编号约定(rewrite/1.0):revision id 与文件名前缀使用三位顺序号(000、001、002…),
# 生成统一走根目录 `make db-revision m="说明"`(自动取最大号+1 并传 --rev-id),不手写哈希 id。
file_template = %%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present. # sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator # defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below. # is defined by "path_separator" below.
@@ -1,889 +0,0 @@
"""rewrite-1.0 基线:合并既有模式为单一起点
Revision ID: 843abf719fde
Revises:
Create Date: 2026-08-21 21:57:55.890597
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '000'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('agent_roster',
sa.Column('role', sa.String(length=80), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('responsibility', sa.Text(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('last_active_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('role')
)
op.create_table('characters',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('display_name', sa.String(length=120), nullable=True),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('language', sa.String(length=20), nullable=False),
sa.Column('profile_json', sa.Text(), nullable=False),
sa.Column('model_policy_json', sa.Text(), nullable=False),
sa.Column('safety_policy_id', sa.String(length=80), nullable=True),
sa.Column('current_personality_version_id', sa.String(length=80), nullable=True),
sa.Column('personality_summary', sa.Text(), nullable=True),
sa.Column('mission', sa.Text(), nullable=True),
sa.Column('safety_guidelines', sa.Text(), nullable=True),
sa.Column('current_mood_id', sa.String(length=80), nullable=True),
sa.Column('privacy_level', sa.String(length=40), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('is_system_template', sa.Boolean(), nullable=False),
sa.Column('source_template_id', sa.String(length=80), nullable=True),
sa.Column('template_key', 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.Column('activated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_characters_is_system_template'), 'characters', ['is_system_template'], unique=False)
op.create_index(op.f('ix_characters_owner_scope'), 'characters', ['owner_scope'], unique=False)
op.create_index(op.f('ix_characters_source_template_id'), 'characters', ['source_template_id'], unique=False)
op.create_index(op.f('ix_characters_status'), 'characters', ['status'], unique=False)
op.create_index(op.f('ix_characters_template_key'), 'characters', ['template_key'], unique=False)
op.create_table('generation_preferences',
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('default_model_hint', sa.String(length=120), nullable=False),
sa.Column('temperature', sa.Float(), nullable=False),
sa.Column('top_p', sa.Float(), nullable=False),
sa.Column('max_output_tokens', sa.Integer(), nullable=False),
sa.Column('streaming', sa.Boolean(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('owner_scope')
)
op.create_table('idempotency_keys',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('key', sa.String(length=200), nullable=False),
sa.Column('scope', sa.String(length=120), nullable=False),
sa.Column('method', sa.String(length=20), nullable=False),
sa.Column('path', sa.String(length=500), nullable=False),
sa.Column('request_hash', sa.String(length=128), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('response_status_code', sa.Integer(), nullable=False),
sa.Column('response_body_json', sa.Text(), nullable=False),
sa.Column('resource_type', sa.String(length=80), nullable=True),
sa.Column('resource_id', sa.String(length=120), nullable=True),
sa.Column('trace_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.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scope', 'key', name='uq_idempotency_scope_key')
)
op.create_index(op.f('ix_idempotency_keys_expires_at'), 'idempotency_keys', ['expires_at'], unique=False)
op.create_index(op.f('ix_idempotency_keys_key'), 'idempotency_keys', ['key'], unique=False)
op.create_index(op.f('ix_idempotency_keys_scope'), 'idempotency_keys', ['scope'], unique=False)
op.create_index(op.f('ix_idempotency_keys_trace_id'), 'idempotency_keys', ['trace_id'], unique=False)
op.create_table('mcp_servers',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('transport', sa.String(length=40), nullable=False),
sa.Column('endpoint_url', sa.String(length=500), nullable=True),
sa.Column('auth_token', sa.String(length=500), nullable=True),
sa.Column('command', sa.String(length=500), nullable=True),
sa.Column('args_json', sa.Text(), nullable=True),
sa.Column('env_json', sa.Text(), nullable=True),
sa.Column('cwd', sa.String(length=500), nullable=True),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('tools_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_index(op.f('ix_mcp_servers_status'), 'mcp_servers', ['status'], unique=False)
op.create_table('model_endpoint_overrides',
sa.Column('logical_model_name', sa.String(length=120), nullable=False),
sa.Column('endpoint_name', sa.String(length=120), nullable=False),
sa.Column('base_url', sa.String(length=500), nullable=True),
sa.Column('api_key', sa.String(length=500), nullable=True),
sa.Column('status', sa.String(length=40), nullable=True),
sa.Column('weight', sa.Integer(), nullable=True),
sa.Column('priority', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('logical_model_name', 'endpoint_name')
)
op.create_table('pending_confirmations',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('tool', sa.String(length=120), nullable=False),
sa.Column('input_json', sa.Text(), nullable=False),
sa.Column('risk_level', sa.String(length=40), nullable=False),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('run_id', sa.String(length=80), nullable=True),
sa.Column('character_id', sa.String(length=80), nullable=True),
sa.Column('trace_id', sa.String(length=80), nullable=True),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_pending_confirmations_character_id'), 'pending_confirmations', ['character_id'], unique=False)
op.create_index(op.f('ix_pending_confirmations_run_id'), 'pending_confirmations', ['run_id'], unique=False)
op.create_index(op.f('ix_pending_confirmations_status'), 'pending_confirmations', ['status'], unique=False)
op.create_table('perception_channels',
sa.Column('channel', sa.String(length=80), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('consent_status', sa.String(length=40), nullable=False),
sa.Column('privacy_level', sa.String(length=40), nullable=False),
sa.Column('risk_level', sa.String(length=20), nullable=False),
sa.Column('last_active_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('channel')
)
op.create_table('permissions',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('resource', sa.String(length=80), nullable=False),
sa.Column('action', sa.String(length=80), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_permissions_resource'), 'permissions', ['resource'], unique=False)
op.create_table('roles',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('name', sa.String(length=80), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('is_system', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True)
op.create_table('skills',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('trigger', sa.Text(), nullable=False),
sa.Column('prompt_fragment', sa.Text(), nullable=False),
sa.Column('tool_bindings_json', sa.Text(), nullable=False),
sa.Column('risk_level', sa.String(length=8), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_index(op.f('ix_skills_status'), 'skills', ['status'], unique=False)
op.create_table('tool_policies',
sa.Column('name', sa.String(length=80), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('risk_level', sa.String(length=20), nullable=False),
sa.Column('permission_policy', sa.String(length=20), nullable=False),
sa.Column('side_effect', sa.Boolean(), nullable=False),
sa.Column('audit_required', sa.Boolean(), nullable=False),
sa.Column('source', sa.String(length=20), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('name')
)
op.create_table('users',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('username', sa.String(length=80), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_admin', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('world_entities',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('type', sa.String(length=40), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('attributes_json', sa.Text(), nullable=False),
sa.Column('state_type', sa.String(length=40), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('sources_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_world_entities_type'), 'world_entities', ['type'], unique=False)
op.create_table('big_cycles',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('trigger', sa.String(length=40), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('phase', sa.String(length=40), nullable=True),
sa.Column('memory_count_at_trigger', sa.Integer(), nullable=False),
sa.Column('evaluation_snapshot_json', sa.Text(), nullable=True),
sa.Column('summary', sa.Text(), nullable=True),
sa.Column('error_code', sa.String(length=80), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
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_big_cycles_character_id'), 'big_cycles', ['character_id'], unique=False)
op.create_index(op.f('ix_big_cycles_completed_at'), 'big_cycles', ['completed_at'], unique=False)
op.create_index(op.f('ix_big_cycles_status'), 'big_cycles', ['status'], unique=False)
op.create_table('character_emotion_states',
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('mood', sa.String(length=40), nullable=False),
sa.Column('intensity', sa.Float(), nullable=False),
sa.Column('baseline_mood', sa.String(length=40), nullable=False),
sa.Column('baseline_intensity', sa.Float(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('last_trigger_event_id', sa.String(length=80), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('character_id')
)
op.create_table('character_import_jobs',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('material_type', sa.String(length=40), nullable=False),
sa.Column('source_type', sa.String(length=40), nullable=False),
sa.Column('title', sa.String(length=200), nullable=True),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('format', sa.String(length=40), nullable=False),
sa.Column('media_type', sa.String(length=40), nullable=True),
sa.Column('mime_type', sa.String(length=80), nullable=True),
sa.Column('source_description', sa.Text(), nullable=True),
sa.Column('speaker_label', sa.String(length=120), nullable=True),
sa.Column('consent_status', sa.String(length=40), nullable=False),
sa.Column('usage_scope', sa.String(length=40), nullable=False),
sa.Column('checksum', sa.String(length=120), nullable=True),
sa.Column('item_count', sa.Integer(), nullable=False),
sa.Column('storage_path', sa.String(length=500), nullable=True),
sa.Column('raw_content', sa.Text(), nullable=True),
sa.Column('error_code', sa.String(length=80), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
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_character_import_jobs_character_id'), 'character_import_jobs', ['character_id'], unique=False)
op.create_index(op.f('ix_character_import_jobs_checksum'), 'character_import_jobs', ['checksum'], unique=False)
op.create_index(op.f('ix_character_import_jobs_material_type'), 'character_import_jobs', ['material_type'], unique=False)
op.create_index(op.f('ix_character_import_jobs_status'), 'character_import_jobs', ['status'], unique=False)
op.create_table('character_world_entities',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('type', sa.String(length=40), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('attributes_json', sa.Text(), nullable=False),
sa.Column('state_type', sa.String(length=40), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('evidence_ids_json', sa.Text(), nullable=False),
sa.Column('sources_json', sa.Text(), 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_character_world_entities_character_id'), 'character_world_entities', ['character_id'], unique=False)
op.create_index(op.f('ix_character_world_entities_status'), 'character_world_entities', ['status'], unique=False)
op.create_index(op.f('ix_character_world_entities_type'), 'character_world_entities', ['type'], unique=False)
op.create_table('chat_sessions',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('title', sa.String(length=200), nullable=True),
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_chat_sessions_character_id'), 'chat_sessions', ['character_id'], unique=False)
op.create_index(op.f('ix_chat_sessions_owner_scope'), 'chat_sessions', ['owner_scope'], unique=False)
op.create_index(op.f('ix_chat_sessions_status'), 'chat_sessions', ['status'], unique=False)
op.create_table('goals',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=True),
sa.Column('title', sa.String(length=300), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('level', sa.String(length=40), nullable=False),
sa.Column('type', sa.String(length=40), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('priority', sa.Integer(), nullable=False),
sa.Column('progress', sa.Float(), nullable=False),
sa.Column('constraints_json', sa.Text(), nullable=False),
sa.Column('parent_id', sa.String(length=80), nullable=True),
sa.Column('success_criteria_json', sa.Text(), 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.ForeignKeyConstraint(['parent_id'], ['goals.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_goals_character_id'), 'goals', ['character_id'], unique=False)
op.create_index(op.f('ix_goals_level'), 'goals', ['level'], unique=False)
op.create_index(op.f('ix_goals_status'), 'goals', ['status'], unique=False)
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('memories',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('type', sa.String(length=60), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('source', sa.String(length=80), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('importance', sa.Float(), nullable=False),
sa.Column('sensitivity', sa.String(length=40), nullable=False),
sa.Column('evidence_ids_json', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('temporal_context_json', sa.Text(), server_default='{}', nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_memories_character_id'), 'memories', ['character_id'], unique=False)
op.create_index(op.f('ix_memories_status'), 'memories', ['status'], unique=False)
op.create_table('personality_versions',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('identity_profile', sa.Text(), nullable=False),
sa.Column('role_destiny', sa.Text(), nullable=False),
sa.Column('personality_seed', sa.Text(), nullable=False),
sa.Column('traits', sa.Text(), nullable=False),
sa.Column('expression_style', sa.Text(), nullable=False),
sa.Column('values', sa.Text(), nullable=False),
sa.Column('relationship_pattern', sa.Text(), nullable=False),
sa.Column('emotion_baseline', sa.Text(), nullable=False),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('evidence_ids_json', sa.Text(), nullable=False),
sa.Column('created_by', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('rolled_back_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_personality_versions_character_id'), 'personality_versions', ['character_id'], unique=False)
op.create_table('role_permissions',
sa.Column('role_id', sa.String(length=40), nullable=False),
sa.Column('permission_id', sa.String(length=40), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('role_id', 'permission_id')
)
op.create_table('time_anniversaries',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('calendar', sa.String(length=20), nullable=False),
sa.Column('date', sa.String(length=20), nullable=False),
sa.Column('recurs_yearly', sa.Boolean(), nullable=False),
sa.Column('kind', sa.String(length=40), nullable=False),
sa.Column('subject_type', sa.String(length=40), nullable=False),
sa.Column('source', sa.String(length=40), nullable=False),
sa.Column('metadata_json', sa.Text(), 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_time_anniversaries_character_id'), 'time_anniversaries', ['character_id'], unique=False)
op.create_table('user_roles',
sa.Column('user_id', sa.String(length=40), nullable=False),
sa.Column('role_id', sa.String(length=40), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('user_id', 'role_id')
)
op.create_table('character_profile_candidates',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('import_job_id', sa.String(length=80), nullable=True),
sa.Column('field', sa.String(length=60), nullable=False),
sa.Column('value', sa.Text(), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('evidence_ids_json', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('reason', sa.Text(), nullable=True),
sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True),
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.ForeignKeyConstraint(['import_job_id'], ['character_import_jobs.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_character_profile_candidates_character_id'), 'character_profile_candidates', ['character_id'], unique=False)
op.create_index(op.f('ix_character_profile_candidates_import_job_id'), 'character_profile_candidates', ['import_job_id'], unique=False)
op.create_index(op.f('ix_character_profile_candidates_status'), 'character_profile_candidates', ['status'], unique=False)
op.create_table('curiosity_questions',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('cycle_id', sa.String(length=80), nullable=False),
sa.Column('question', sa.Text(), nullable=False),
sa.Column('source', sa.String(length=60), nullable=False),
sa.Column('related_entities_json', sa.Text(), nullable=False),
sa.Column('expected_value', sa.Float(), nullable=False),
sa.Column('urgency', sa.Float(), nullable=False),
sa.Column('risk_level', sa.String(length=8), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['cycle_id'], ['big_cycles.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_curiosity_questions_character_id'), 'curiosity_questions', ['character_id'], unique=False)
op.create_index(op.f('ix_curiosity_questions_cycle_id'), 'curiosity_questions', ['cycle_id'], unique=False)
op.create_table('evolution_candidates',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('cycle_id', sa.String(length=80), nullable=False),
sa.Column('type', sa.String(length=60), nullable=False),
sa.Column('source', sa.String(length=60), nullable=False),
sa.Column('level', sa.String(length=8), nullable=False),
sa.Column('scope', sa.String(length=40), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('proposal', sa.Text(), nullable=False),
sa.Column('delta_json', sa.Text(), nullable=False),
sa.Column('evidence_ids_json', sa.Text(), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('benefit_score', sa.Float(), nullable=False),
sa.Column('risk_score', sa.Float(), nullable=False),
sa.Column('drift_score', sa.Float(), nullable=False),
sa.Column('auto_apply_allowed', sa.Boolean(), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('resulting_target_id', sa.String(length=80), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('reviewed_by', 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.ForeignKeyConstraint(['cycle_id'], ['big_cycles.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_evolution_candidates_character_id'), 'evolution_candidates', ['character_id'], unique=False)
op.create_index(op.f('ix_evolution_candidates_cycle_id'), 'evolution_candidates', ['cycle_id'], unique=False)
op.create_index(op.f('ix_evolution_candidates_status'), 'evolution_candidates', ['status'], unique=False)
op.create_table('import_items',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('import_job_id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('item_type', sa.String(length=40), nullable=False),
sa.Column('role', sa.String(length=40), nullable=False),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('metadata_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['import_job_id'], ['character_import_jobs.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_import_items_character_id'), 'import_items', ['character_id'], unique=False)
op.create_index(op.f('ix_import_items_import_job_id'), 'import_items', ['import_job_id'], 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)
op.create_table('memory_candidates',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('import_job_id', sa.String(length=80), nullable=True),
sa.Column('cycle_id', sa.String(length=80), nullable=True),
sa.Column('type', sa.String(length=60), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('importance', sa.Float(), nullable=False),
sa.Column('sensitivity', sa.String(length=40), nullable=False),
sa.Column('evidence_ids_json', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('reason', sa.Text(), nullable=True),
sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True),
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.ForeignKeyConstraint(['cycle_id'], ['big_cycles.id'], ),
sa.ForeignKeyConstraint(['import_job_id'], ['character_import_jobs.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_memory_candidates_character_id'), 'memory_candidates', ['character_id'], unique=False)
op.create_index(op.f('ix_memory_candidates_cycle_id'), 'memory_candidates', ['cycle_id'], unique=False)
op.create_index(op.f('ix_memory_candidates_import_job_id'), 'memory_candidates', ['import_job_id'], unique=False)
op.create_index(op.f('ix_memory_candidates_status'), 'memory_candidates', ['status'], unique=False)
op.create_table('memory_links',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('source_memory_id', sa.String(length=80), nullable=False),
sa.Column('target_memory_id', sa.String(length=80), nullable=False),
sa.Column('relation', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['source_memory_id'], ['memories.id'], ),
sa.ForeignKeyConstraint(['target_memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_memory_links_character_id'), 'memory_links', ['character_id'], unique=False)
op.create_index(op.f('ix_memory_links_source_memory_id'), 'memory_links', ['source_memory_id'], unique=False)
op.create_index(op.f('ix_memory_links_target_memory_id'), 'memory_links', ['target_memory_id'], unique=False)
op.create_table('personality_proposals',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('fields_json', sa.Text(), nullable=False),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('resulting_version_id', sa.String(length=80), nullable=True),
sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('reviewed_by', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['resulting_version_id'], ['personality_versions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_personality_proposals_character_id'), 'personality_proposals', ['character_id'], unique=False)
op.create_index(op.f('ix_personality_proposals_status'), 'personality_proposals', ['status'], unique=False)
op.create_table('plans',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('goal_id', sa.String(length=80), nullable=True),
sa.Column('title', sa.String(length=300), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('assumptions_json', sa.Text(), nullable=False),
sa.Column('risks_json', sa.Text(), nullable=False),
sa.Column('required_tools_json', sa.Text(), nullable=False),
sa.Column('success_criteria_json', sa.Text(), nullable=False),
sa.Column('step_count', sa.Integer(), nullable=False),
sa.Column('completed_steps', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['goal_id'], ['goals.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_plans_goal_id'), 'plans', ['goal_id'], unique=False)
op.create_index(op.f('ix_plans_status'), 'plans', ['status'], unique=False)
op.create_table('runs',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('session_id', sa.String(length=80), nullable=True),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('goal', sa.String(length=300), nullable=True),
sa.Column('origin', sa.String(length=40), nullable=False),
sa.Column('model_hint', sa.String(length=120), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('error_code', sa.String(length=80), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
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.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_runs_character_id'), 'runs', ['character_id'], unique=False)
op.create_index(op.f('ix_runs_session_id'), 'runs', ['session_id'], unique=False)
op.create_index(op.f('ix_runs_status'), 'runs', ['status'], unique=False)
op.create_table('world_relations',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('subject_id', sa.String(length=80), nullable=False),
sa.Column('predicate', sa.String(length=120), nullable=False),
sa.Column('object_id', sa.String(length=80), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('evidence_ids_json', sa.Text(), 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.ForeignKeyConstraint(['object_id'], ['character_world_entities.id'], ),
sa.ForeignKeyConstraint(['subject_id'], ['character_world_entities.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_world_relations_character_id'), 'world_relations', ['character_id'], unique=False)
op.create_index(op.f('ix_world_relations_object_id'), 'world_relations', ['object_id'], unique=False)
op.create_index(op.f('ix_world_relations_subject_id'), 'world_relations', ['subject_id'], unique=False)
op.create_table('audit_events',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('pivot', sa.String(length=40), nullable=True),
sa.Column('character_id', sa.String(length=80), nullable=True),
sa.Column('run_id', sa.String(length=80), nullable=True),
sa.Column('action', sa.String(length=120), nullable=False),
sa.Column('actor', sa.String(length=80), nullable=False),
sa.Column('risk_level', sa.String(length=20), nullable=False),
sa.Column('decision', sa.String(length=40), nullable=False),
sa.Column('reason', sa.Text(), nullable=True),
sa.Column('input_summary', sa.Text(), nullable=True),
sa.Column('output_summary', sa.Text(), nullable=True),
sa.Column('trace_id', sa.String(length=80), nullable=True),
sa.Column('resource_type', sa.String(length=80), nullable=True),
sa.Column('resource_id', sa.String(length=120), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_events_character_id'), 'audit_events', ['character_id'], unique=False)
op.create_index(op.f('ix_audit_events_pivot'), 'audit_events', ['pivot'], unique=False)
op.create_index(op.f('ix_audit_events_run_id'), 'audit_events', ['run_id'], unique=False)
op.create_index(op.f('ix_audit_events_trace_id'), 'audit_events', ['trace_id'], unique=False)
op.create_table('chat_messages',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('session_id', sa.String(length=80), nullable=False),
sa.Column('role', sa.String(length=40), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('modality', sa.String(length=40), nullable=False),
sa.Column('run_id', sa.String(length=80), nullable=True),
sa.Column('metadata_json', sa.Text(), nullable=False),
sa.Column('sensitivity', sa.String(length=40), nullable=False),
sa.Column('versions_json', sa.Text(), nullable=False, comment='版本历史 JSON 数组:[{content, created_at}],按生成顺序排列,末尾最新'),
sa.Column('active_version_idx', sa.Integer(), nullable=False, comment='当前活跃版本在 versions_json 中的索引(0-based)'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ),
sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chat_messages_character_id'), 'chat_messages', ['character_id'], unique=False)
op.create_index(op.f('ix_chat_messages_run_id'), 'chat_messages', ['run_id'], unique=False)
op.create_index(op.f('ix_chat_messages_session_id'), 'chat_messages', ['session_id'], unique=False)
op.create_table('events',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=True),
sa.Column('run_id', sa.String(length=80), nullable=True),
sa.Column('type', sa.String(length=80), nullable=False),
sa.Column('source', sa.String(length=40), nullable=False),
sa.Column('payload_json', sa.Text(), nullable=False),
sa.Column('visibility', sa.String(length=40), nullable=False),
sa.Column('sensitivity', sa.String(length=40), nullable=False),
sa.Column('trace_id', sa.String(length=80), nullable=True),
sa.Column('parent_id', sa.String(length=80), nullable=True),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('evidence_ids_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('run_id', 'sequence', name='uq_events_run_sequence')
)
op.create_index(op.f('ix_events_character_id'), 'events', ['character_id'], unique=False)
op.create_index(op.f('ix_events_run_id'), 'events', ['run_id'], unique=False)
op.create_index(op.f('ix_events_trace_id'), 'events', ['trace_id'], unique=False)
op.create_index(op.f('ix_events_type'), 'events', ['type'], unique=False)
op.create_table('exploratory_subgoals',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('cycle_id', sa.String(length=80), nullable=False),
sa.Column('parent_goal_id', sa.String(length=80), nullable=True),
sa.Column('curiosity_question_id', sa.String(length=80), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('expected_information_gain', sa.Float(), nullable=False),
sa.Column('interruption_cost', sa.Float(), nullable=False),
sa.Column('risk_level', sa.String(length=8), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('reviewed_by', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['curiosity_question_id'], ['curiosity_questions.id'], ),
sa.ForeignKeyConstraint(['cycle_id'], ['big_cycles.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_exploratory_subgoals_character_id'), 'exploratory_subgoals', ['character_id'], unique=False)
op.create_index(op.f('ix_exploratory_subgoals_curiosity_question_id'), 'exploratory_subgoals', ['curiosity_question_id'], unique=False)
op.create_index(op.f('ix_exploratory_subgoals_cycle_id'), 'exploratory_subgoals', ['cycle_id'], unique=False)
op.create_index(op.f('ix_exploratory_subgoals_status'), 'exploratory_subgoals', ['status'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_exploratory_subgoals_status'), table_name='exploratory_subgoals')
op.drop_index(op.f('ix_exploratory_subgoals_cycle_id'), table_name='exploratory_subgoals')
op.drop_index(op.f('ix_exploratory_subgoals_curiosity_question_id'), table_name='exploratory_subgoals')
op.drop_index(op.f('ix_exploratory_subgoals_character_id'), table_name='exploratory_subgoals')
op.drop_table('exploratory_subgoals')
op.drop_index(op.f('ix_events_type'), table_name='events')
op.drop_index(op.f('ix_events_trace_id'), table_name='events')
op.drop_index(op.f('ix_events_run_id'), table_name='events')
op.drop_index(op.f('ix_events_character_id'), table_name='events')
op.drop_table('events')
op.drop_index(op.f('ix_chat_messages_session_id'), table_name='chat_messages')
op.drop_index(op.f('ix_chat_messages_run_id'), table_name='chat_messages')
op.drop_index(op.f('ix_chat_messages_character_id'), table_name='chat_messages')
op.drop_table('chat_messages')
op.drop_index(op.f('ix_audit_events_trace_id'), table_name='audit_events')
op.drop_index(op.f('ix_audit_events_run_id'), table_name='audit_events')
op.drop_index(op.f('ix_audit_events_pivot'), table_name='audit_events')
op.drop_index(op.f('ix_audit_events_character_id'), table_name='audit_events')
op.drop_table('audit_events')
op.drop_index(op.f('ix_world_relations_subject_id'), table_name='world_relations')
op.drop_index(op.f('ix_world_relations_object_id'), table_name='world_relations')
op.drop_index(op.f('ix_world_relations_character_id'), table_name='world_relations')
op.drop_table('world_relations')
op.drop_index(op.f('ix_runs_status'), table_name='runs')
op.drop_index(op.f('ix_runs_session_id'), table_name='runs')
op.drop_index(op.f('ix_runs_character_id'), table_name='runs')
op.drop_table('runs')
op.drop_index(op.f('ix_plans_status'), table_name='plans')
op.drop_index(op.f('ix_plans_goal_id'), table_name='plans')
op.drop_table('plans')
op.drop_index(op.f('ix_personality_proposals_status'), table_name='personality_proposals')
op.drop_index(op.f('ix_personality_proposals_character_id'), table_name='personality_proposals')
op.drop_table('personality_proposals')
op.drop_index(op.f('ix_memory_links_target_memory_id'), table_name='memory_links')
op.drop_index(op.f('ix_memory_links_source_memory_id'), table_name='memory_links')
op.drop_index(op.f('ix_memory_links_character_id'), table_name='memory_links')
op.drop_table('memory_links')
op.drop_index(op.f('ix_memory_candidates_status'), table_name='memory_candidates')
op.drop_index(op.f('ix_memory_candidates_import_job_id'), table_name='memory_candidates')
op.drop_index(op.f('ix_memory_candidates_cycle_id'), table_name='memory_candidates')
op.drop_index(op.f('ix_memory_candidates_character_id'), table_name='memory_candidates')
op.drop_table('memory_candidates')
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_import_items_import_job_id'), table_name='import_items')
op.drop_index(op.f('ix_import_items_character_id'), table_name='import_items')
op.drop_table('import_items')
op.drop_index(op.f('ix_evolution_candidates_status'), table_name='evolution_candidates')
op.drop_index(op.f('ix_evolution_candidates_cycle_id'), table_name='evolution_candidates')
op.drop_index(op.f('ix_evolution_candidates_character_id'), table_name='evolution_candidates')
op.drop_table('evolution_candidates')
op.drop_index(op.f('ix_curiosity_questions_cycle_id'), table_name='curiosity_questions')
op.drop_index(op.f('ix_curiosity_questions_character_id'), table_name='curiosity_questions')
op.drop_table('curiosity_questions')
op.drop_index(op.f('ix_character_profile_candidates_status'), table_name='character_profile_candidates')
op.drop_index(op.f('ix_character_profile_candidates_import_job_id'), table_name='character_profile_candidates')
op.drop_index(op.f('ix_character_profile_candidates_character_id'), table_name='character_profile_candidates')
op.drop_table('character_profile_candidates')
op.drop_table('user_roles')
op.drop_index(op.f('ix_time_anniversaries_character_id'), table_name='time_anniversaries')
op.drop_table('time_anniversaries')
op.drop_table('role_permissions')
op.drop_index(op.f('ix_personality_versions_character_id'), table_name='personality_versions')
op.drop_table('personality_versions')
op.drop_index(op.f('ix_memories_status'), table_name='memories')
op.drop_index(op.f('ix_memories_character_id'), table_name='memories')
op.drop_table('memories')
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')
op.drop_index(op.f('ix_goals_status'), table_name='goals')
op.drop_index(op.f('ix_goals_level'), table_name='goals')
op.drop_index(op.f('ix_goals_character_id'), table_name='goals')
op.drop_table('goals')
op.drop_index(op.f('ix_chat_sessions_status'), table_name='chat_sessions')
op.drop_index(op.f('ix_chat_sessions_owner_scope'), table_name='chat_sessions')
op.drop_index(op.f('ix_chat_sessions_character_id'), table_name='chat_sessions')
op.drop_table('chat_sessions')
op.drop_index(op.f('ix_character_world_entities_type'), table_name='character_world_entities')
op.drop_index(op.f('ix_character_world_entities_status'), table_name='character_world_entities')
op.drop_index(op.f('ix_character_world_entities_character_id'), table_name='character_world_entities')
op.drop_table('character_world_entities')
op.drop_index(op.f('ix_character_import_jobs_status'), table_name='character_import_jobs')
op.drop_index(op.f('ix_character_import_jobs_material_type'), table_name='character_import_jobs')
op.drop_index(op.f('ix_character_import_jobs_checksum'), table_name='character_import_jobs')
op.drop_index(op.f('ix_character_import_jobs_character_id'), table_name='character_import_jobs')
op.drop_table('character_import_jobs')
op.drop_table('character_emotion_states')
op.drop_index(op.f('ix_big_cycles_status'), table_name='big_cycles')
op.drop_index(op.f('ix_big_cycles_completed_at'), table_name='big_cycles')
op.drop_index(op.f('ix_big_cycles_character_id'), table_name='big_cycles')
op.drop_table('big_cycles')
op.drop_index(op.f('ix_world_entities_type'), table_name='world_entities')
op.drop_table('world_entities')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_table('users')
op.drop_table('tool_policies')
op.drop_index(op.f('ix_skills_status'), table_name='skills')
op.drop_table('skills')
op.drop_index(op.f('ix_roles_name'), table_name='roles')
op.drop_table('roles')
op.drop_index(op.f('ix_permissions_resource'), table_name='permissions')
op.drop_table('permissions')
op.drop_table('perception_channels')
op.drop_index(op.f('ix_pending_confirmations_status'), table_name='pending_confirmations')
op.drop_index(op.f('ix_pending_confirmations_run_id'), table_name='pending_confirmations')
op.drop_index(op.f('ix_pending_confirmations_character_id'), table_name='pending_confirmations')
op.drop_table('pending_confirmations')
op.drop_table('model_endpoint_overrides')
op.drop_index(op.f('ix_mcp_servers_status'), table_name='mcp_servers')
op.drop_table('mcp_servers')
op.drop_index(op.f('ix_idempotency_keys_trace_id'), table_name='idempotency_keys')
op.drop_index(op.f('ix_idempotency_keys_scope'), table_name='idempotency_keys')
op.drop_index(op.f('ix_idempotency_keys_key'), table_name='idempotency_keys')
op.drop_index(op.f('ix_idempotency_keys_expires_at'), table_name='idempotency_keys')
op.drop_table('idempotency_keys')
op.drop_table('generation_preferences')
op.drop_index(op.f('ix_characters_template_key'), table_name='characters')
op.drop_index(op.f('ix_characters_status'), table_name='characters')
op.drop_index(op.f('ix_characters_source_template_id'), table_name='characters')
op.drop_index(op.f('ix_characters_owner_scope'), table_name='characters')
op.drop_index(op.f('ix_characters_is_system_template'), table_name='characters')
op.drop_table('characters')
op.drop_table('agent_roster')
# ### end Alembic commands ###
File diff suppressed because it is too large. Load diff
@@ -1,108 +0,0 @@
"""镜生域:灵胚、执镜关系、命台、魂籍表与人物增量列
Revision ID: 001
Revises: 000
Create Date: 2026-08-21 22:23:35.864890
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '001'
down_revision: Union[str, Sequence[str], None] = '000'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('embryo_drafts',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('source', sa.String(length=40), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('identity_baseline_json', sa.Text(), nullable=False),
sa.Column('rehearsals_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_embryo_drafts_owner_scope'), 'embryo_drafts', ['owner_scope'], unique=False)
op.create_index(op.f('ix_embryo_drafts_status'), 'embryo_drafts', ['status'], unique=False)
op.create_table('custodianships',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('custodian_scope', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_custodianships_character_id'), 'custodianships', ['character_id'], unique=True)
op.create_index(op.f('ix_custodianships_custodian_scope'), 'custodianships', ['custodian_scope'], unique=False)
op.create_table('identity_records',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('payload_json', sa.Text(), nullable=False),
sa.Column('digest', sa.String(length=64), nullable=False),
sa.Column('signature', sa.String(length=128), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('character_id', 'version', name='uq_identity_char_version')
)
op.create_index(op.f('ix_identity_records_character_id'), 'identity_records', ['character_id'], unique=False)
op.create_table('runtime_seats',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('epoch', sa.Integer(), nullable=False),
sa.Column('holder', sa.String(length=120), nullable=True),
sa.Column('lease_expires_at', sa.DateTime(timezone=True), nullable=True),
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_runtime_seats_character_id'), 'runtime_seats', ['character_id'], unique=True)
op.add_column('characters', sa.Column('source', sa.String(length=40), server_default='original', nullable=False))
op.add_column('characters', sa.Column('identity_baseline_json', sa.Text(), server_default='{}', nullable=False))
op.add_column('characters', sa.Column('death_requested_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('characters', sa.Column('calm_period_until', sa.DateTime(timezone=True), nullable=True))
op.add_column('characters', sa.Column('died_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_characters_source'), 'characters', ['source'], unique=False)
op.add_column('runs', sa.Column('kind', sa.String(length=40), server_default='foreground', nullable=False))
# SQLite 不支持 ALTER COLUMN,用 batch 模式跨方言改 runs.character_id 可空(试演 Run 无人物)
with op.batch_alter_table('runs') as batch_op:
batch_op.alter_column('character_id', existing_type=sa.String(length=80), nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('runs') as batch_op:
batch_op.alter_column('character_id', existing_type=sa.String(length=80), nullable=False)
op.drop_column('runs', 'kind')
op.drop_index(op.f('ix_characters_source'), table_name='characters')
op.drop_column('characters', 'died_at')
op.drop_column('characters', 'calm_period_until')
op.drop_column('characters', 'death_requested_at')
op.drop_column('characters', 'identity_baseline_json')
op.drop_column('characters', 'source')
op.drop_index(op.f('ix_runtime_seats_character_id'), table_name='runtime_seats')
op.drop_table('runtime_seats')
op.drop_index(op.f('ix_identity_records_character_id'), table_name='identity_records')
op.drop_table('identity_records')
op.drop_index(op.f('ix_custodianships_custodian_scope'), table_name='custodianships')
op.drop_index(op.f('ix_custodianships_character_id'), table_name='custodianships')
op.drop_table('custodianships')
op.drop_index(op.f('ix_embryo_drafts_status'), table_name='embryo_drafts')
op.drop_index(op.f('ix_embryo_drafts_owner_scope'), table_name='embryo_drafts')
op.drop_table('embryo_drafts')
# ### end Alembic commands ###
@@ -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")
@@ -1,125 +0,0 @@
"""镜树域:主题树、节点、引文锚、归真记录、移栽谱系
Revision ID: 002
Revises: 001
Create Date: 2026-08-21 22:49:03.731149
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '002'
down_revision: Union[str, Sequence[str], None] = '001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tree_transplants',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('source_tree_id', sa.String(length=80), nullable=False),
sa.Column('source_node_id', sa.String(length=80), nullable=False),
sa.Column('new_tree_id', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tree_transplants_new_tree_id'), 'tree_transplants', ['new_tree_id'], unique=False)
op.create_index(op.f('ix_tree_transplants_source_tree_id'), 'tree_transplants', ['source_tree_id'], unique=False)
op.create_table('mirror_trees',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('title', sa.String(length=300), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('default_realm', sa.String(length=40), nullable=False),
sa.Column('working_entry_node_id', sa.String(length=80), nullable=True),
sa.Column('transplanted_from_json', sa.Text(), 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_mirror_trees_character_id'), 'mirror_trees', ['character_id'], unique=False)
op.create_index(op.f('ix_mirror_trees_status'), 'mirror_trees', ['status'], unique=False)
op.create_table('canonizations',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('tree_id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('scope', sa.String(length=40), nullable=False),
sa.Column('world_id', sa.String(length=80), nullable=True),
sa.Column('endpoint_node_id', sa.String(length=80), nullable=False),
sa.Column('path_nodes_json', sa.Text(), nullable=False),
sa.Column('confirmed_by', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tree_id'], ['mirror_trees.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_canonizations_character_id'), 'canonizations', ['character_id'], unique=False)
op.create_index(op.f('ix_canonizations_endpoint_node_id'), 'canonizations', ['endpoint_node_id'], unique=False)
op.create_index(op.f('ix_canonizations_scope'), 'canonizations', ['scope'], unique=False)
op.create_index(op.f('ix_canonizations_tree_id'), 'canonizations', ['tree_id'], unique=False)
op.create_table('tree_nodes',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('tree_id', sa.String(length=80), nullable=False),
sa.Column('parent_node_id', sa.String(length=80), nullable=True),
sa.Column('node_type', sa.String(length=40), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('author', sa.String(length=40), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('realm', sa.String(length=40), nullable=False),
sa.Column('generation_meta_json', sa.Text(), nullable=False),
sa.Column('supersedes_node_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(['parent_node_id'], ['tree_nodes.id'], ),
sa.ForeignKeyConstraint(['tree_id'], ['mirror_trees.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tree_id', 'supersedes_node_id', name='uq_tree_node_supersedes')
)
op.create_index(op.f('ix_tree_nodes_node_type'), 'tree_nodes', ['node_type'], unique=False)
op.create_index(op.f('ix_tree_nodes_parent_node_id'), 'tree_nodes', ['parent_node_id'], unique=False)
op.create_index(op.f('ix_tree_nodes_status'), 'tree_nodes', ['status'], unique=False)
op.create_index(op.f('ix_tree_nodes_tree_id'), 'tree_nodes', ['tree_id'], unique=False)
op.create_table('citations',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('node_id', sa.String(length=80), nullable=False),
sa.Column('target_type', sa.String(length=40), nullable=False),
sa.Column('locator_json', sa.Text(), nullable=False),
sa.Column('snapshot', sa.Text(), nullable=False),
sa.Column('source_id', sa.String(length=200), nullable=False),
sa.Column('source_version', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['node_id'], ['tree_nodes.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_citations_node_id'), 'citations', ['node_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_citations_node_id'), table_name='citations')
op.drop_table('citations')
op.drop_index(op.f('ix_tree_nodes_tree_id'), table_name='tree_nodes')
op.drop_index(op.f('ix_tree_nodes_status'), table_name='tree_nodes')
op.drop_index(op.f('ix_tree_nodes_parent_node_id'), table_name='tree_nodes')
op.drop_index(op.f('ix_tree_nodes_node_type'), table_name='tree_nodes')
op.drop_table('tree_nodes')
op.drop_index(op.f('ix_canonizations_tree_id'), table_name='canonizations')
op.drop_index(op.f('ix_canonizations_scope'), table_name='canonizations')
op.drop_index(op.f('ix_canonizations_endpoint_node_id'), table_name='canonizations')
op.drop_index(op.f('ix_canonizations_character_id'), table_name='canonizations')
op.drop_table('canonizations')
op.drop_index(op.f('ix_mirror_trees_status'), table_name='mirror_trees')
op.drop_index(op.f('ix_mirror_trees_character_id'), table_name='mirror_trees')
op.drop_table('mirror_trees')
op.drop_index(op.f('ix_tree_transplants_source_tree_id'), table_name='tree_transplants')
op.drop_index(op.f('ix_tree_transplants_new_tree_id'), table_name='tree_transplants')
op.drop_table('tree_transplants')
# ### end Alembic commands ###
@@ -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 ###
@@ -1,114 +0,0 @@
"""退役旧会话模型:删除 chat/情绪/纪念日表与 runs 旧会话外键
Revision ID: 003
Revises: 002
Create Date: 2026-08-21 23:17:17.055812
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '003'
down_revision: Union[str, Sequence[str], None] = '002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 顺序约束:先去掉 runs.session_id(batch 反射 runs 外键时 chat_sessions 须仍存在),
# 再删除旧会话相关表;SQLite 下 batch 自动处理索引与无名外键。
op.drop_index('ix_runs_session_id', table_name='runs')
with op.batch_alter_table('runs') as batch_op:
batch_op.drop_column('session_id')
op.drop_table('character_emotion_states')
op.drop_index(op.f('ix_chat_sessions_character_id'), table_name='chat_sessions')
op.drop_index(op.f('ix_chat_sessions_owner_scope'), table_name='chat_sessions')
op.drop_index(op.f('ix_chat_sessions_status'), table_name='chat_sessions')
op.drop_table('chat_sessions')
op.drop_index(op.f('ix_chat_messages_character_id'), table_name='chat_messages')
op.drop_index(op.f('ix_chat_messages_run_id'), table_name='chat_messages')
op.drop_index(op.f('ix_chat_messages_session_id'), table_name='chat_messages')
op.drop_table('chat_messages')
op.drop_index(op.f('ix_time_anniversaries_character_id'), table_name='time_anniversaries')
op.drop_table('time_anniversaries')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# 先重建 chat 表,再经 batch 恢复 runs.session_id 列与外键(SQLite 安全)
op.create_table('time_anniversaries',
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('name', sa.VARCHAR(length=200), nullable=False),
sa.Column('calendar', sa.VARCHAR(length=20), nullable=False),
sa.Column('date', sa.VARCHAR(length=20), nullable=False),
sa.Column('recurs_yearly', sa.BOOLEAN(), nullable=False),
sa.Column('kind', sa.VARCHAR(length=40), nullable=False),
sa.Column('subject_type', sa.VARCHAR(length=40), nullable=False),
sa.Column('source', sa.VARCHAR(length=40), nullable=False),
sa.Column('metadata_json', sa.TEXT(), nullable=False),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_time_anniversaries_character_id'), 'time_anniversaries', ['character_id'], unique=False)
op.create_table('chat_messages',
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('session_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('role', sa.VARCHAR(length=40), nullable=False),
sa.Column('content', sa.TEXT(), nullable=False),
sa.Column('modality', sa.VARCHAR(length=40), nullable=False),
sa.Column('run_id', sa.VARCHAR(length=80), nullable=True),
sa.Column('metadata_json', sa.TEXT(), nullable=False),
sa.Column('sensitivity', sa.VARCHAR(length=40), nullable=False),
sa.Column('versions_json', sa.TEXT(), nullable=False),
sa.Column('active_version_idx', sa.INTEGER(), nullable=False),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('deleted_at', sa.DATETIME(), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ),
sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chat_messages_session_id'), 'chat_messages', ['session_id'], unique=False)
op.create_index(op.f('ix_chat_messages_run_id'), 'chat_messages', ['run_id'], unique=False)
op.create_index(op.f('ix_chat_messages_character_id'), 'chat_messages', ['character_id'], unique=False)
op.create_table('chat_sessions',
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('title', sa.VARCHAR(length=200), nullable=True),
sa.Column('status', sa.VARCHAR(length=40), nullable=False),
sa.Column('owner_scope', sa.VARCHAR(length=80), nullable=False),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chat_sessions_status'), 'chat_sessions', ['status'], unique=False)
op.create_index(op.f('ix_chat_sessions_owner_scope'), 'chat_sessions', ['owner_scope'], unique=False)
op.create_index(op.f('ix_chat_sessions_character_id'), 'chat_sessions', ['character_id'], unique=False)
op.create_table('character_emotion_states',
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('mood', sa.VARCHAR(length=40), nullable=False),
sa.Column('intensity', sa.FLOAT(), nullable=False),
sa.Column('baseline_mood', sa.VARCHAR(length=40), nullable=False),
sa.Column('baseline_intensity', sa.FLOAT(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.Column('last_trigger_event_id', sa.VARCHAR(length=80), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('character_id')
)
with op.batch_alter_table('runs') as batch_op:
batch_op.add_column(sa.Column('session_id', sa.String(length=80), nullable=True))
batch_op.create_foreign_key('fk_runs_session_id', 'chat_sessions', ['session_id'], ['id'])
batch_op.create_index('ix_runs_session_id', ['session_id'])
# ### 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 ###
@@ -1,102 +0,0 @@
"""息壤记忆域:三级记忆表重建与触发/实录,退役旧记忆表
Revision ID: 004
Revises: 003
Create Date: 2026-08-22
旧 memories/memory_links 随旧记忆管线退役整体删除(开发数据丢弃语义,data-model §9),
新表按 app/models/xirang.py 重建。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '004'
down_revision: Union[str, Sequence[str], None] = '003'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_table('memory_links')
op.drop_table('memories')
op.create_table('memories',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('tier', sa.String(length=20), nullable=False),
sa.Column('type', sa.String(length=40), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('source', sa.String(length=20), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('source_refs_json', sa.Text(), nullable=False),
sa.Column('sensitivity', sa.String(length=20), nullable=False),
sa.Column('priority', sa.Integer(), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('vitality', sa.Float(), nullable=False),
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('realm', sa.String(length=40), nullable=False),
sa.Column('prev_version_id', sa.String(length=80), nullable=True),
sa.Column('recall_count', sa.Integer(), nullable=False),
sa.Column('last_recalled_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_memories_character_id'), 'memories', ['character_id'], unique=False)
op.create_index(op.f('ix_memories_tier'), 'memories', ['tier'], unique=False)
op.create_index(op.f('ix_memories_type'), 'memories', ['type'], unique=False)
op.create_index(op.f('ix_memories_status'), 'memories', ['status'], unique=False)
op.create_index(op.f('ix_memories_prev_version_id'), 'memories', ['prev_version_id'], unique=False)
op.create_table('memory_triggers',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('memory_id', sa.String(length=80), nullable=False),
sa.Column('kind', sa.String(length=20), nullable=False),
sa.Column('pattern', sa.String(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('factual_records',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('canonization_id', sa.String(length=80), nullable=False),
sa.Column('evidence_json', sa.Text(), nullable=False),
sa.Column('summary', sa.Text(), nullable=False),
sa.Column('occurred_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
def downgrade() -> None:
op.drop_table('factual_records')
op.drop_table('memory_triggers')
op.drop_table('memories')
op.create_table('memories',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('title', sa.String(length=200), nullable=True),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('source', sa.String(length=40), nullable=False),
sa.Column('importance', sa.Float(), nullable=False),
sa.Column('sensitivity', sa.String(length=20), nullable=False),
sa.Column('temporal_context_json', sa.Text(), nullable=False),
sa.Column('evidence_ids_json', sa.Text(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
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_table('memory_links',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('from_memory_id', sa.String(length=80), nullable=False),
sa.Column('to_memory_id', sa.String(length=80), nullable=False),
sa.Column('relation', sa.String(length=40), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['from_memory_id'], ['memories.id'], ),
sa.ForeignKeyConstraint(['to_memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('id')
)
@@ -1,40 +0,0 @@
"""运行归属用户列_runs_owner_id_用量计量透传神识
Revision ID: 005
Revises: 004
Create Date: 2026-08-27 10:31:10.225717
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '005'
down_revision: Union[str, Sequence[str], None] = '004'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f('ix_factual_records_canonization_id'), 'factual_records', ['canonization_id'], unique=False)
op.create_index(op.f('ix_factual_records_character_id'), 'factual_records', ['character_id'], unique=False)
op.create_index(op.f('ix_memory_triggers_memory_id'), 'memory_triggers', ['memory_id'], unique=False)
op.add_column('runs', sa.Column('owner_id', sa.String(length=80), server_default='', nullable=False))
op.create_index(op.f('ix_runs_owner_id'), 'runs', ['owner_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_runs_owner_id'), table_name='runs')
op.drop_column('runs', 'owner_id')
op.drop_index(op.f('ix_memory_triggers_memory_id'), table_name='memory_triggers')
op.drop_index(op.f('ix_factual_records_character_id'), table_name='factual_records')
op.drop_index(op.f('ix_factual_records_canonization_id'), table_name='factual_records')
# ### end Alembic commands ###
@@ -1,153 +0,0 @@
"""百工域_能力注册表_授契行契_调用记录_请命超时改造_退役旧策略与端点覆盖表
Revision ID: 006
Revises: 005
Create Date: 2026-08-27 18:52:47.720303
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '006'
down_revision: Union[str, Sequence[str], None] = '005'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('capabilities',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('executor_type', sa.String(length=20), nullable=False),
sa.Column('executor_ref', sa.String(length=200), nullable=False),
sa.Column('params_schema_json', sa.Text(), nullable=False),
sa.Column('risk_dims_json', sa.Text(), nullable=False),
sa.Column('data_scope', sa.Text(), nullable=False),
sa.Column('destinations', sa.Text(), nullable=False),
sa.Column('source', sa.String(length=20), nullable=False),
sa.Column('version', sa.String(length=40), nullable=False),
sa.Column('consent_mode', sa.String(length=10), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_capabilities_owner_scope'), 'capabilities', ['owner_scope'], unique=False)
op.create_index('uq_capabilities_owner_name', 'capabilities', ['owner_scope', 'name'], unique=True)
op.create_table('mandates',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=True),
sa.Column('capability_id', sa.String(length=80), nullable=False),
sa.Column('kind', sa.String(length=10), nullable=False),
sa.Column('scope_json', sa.JSON(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('budget_total', sa.Integer(), nullable=False),
sa.Column('budget_used', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('granted_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['capability_id'], ['capabilities.id'], ),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mandates_capability_id'), 'mandates', ['capability_id'], unique=False)
op.create_index(op.f('ix_mandates_character_id'), 'mandates', ['character_id'], unique=False)
op.create_index(op.f('ix_mandates_owner_scope'), 'mandates', ['owner_scope'], unique=False)
op.create_index(op.f('ix_mandates_status'), 'mandates', ['status'], unique=False)
op.create_table('tool_calls',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('run_id', sa.String(length=80), nullable=True),
sa.Column('character_id', sa.String(length=80), nullable=True),
sa.Column('capability_id', sa.String(length=80), nullable=False),
sa.Column('trace_id', sa.String(length=80), nullable=True),
sa.Column('decision', sa.String(length=40), nullable=False),
sa.Column('mandate_id', sa.String(length=80), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('error_code', sa.String(length=40), nullable=True),
sa.Column('input_digest', sa.Text(), nullable=False),
sa.Column('output_digest', sa.Text(), nullable=False),
sa.Column('evidence_json', sa.JSON(), nullable=False),
sa.Column('duration_ms', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tool_calls_capability_id'), 'tool_calls', ['capability_id'], unique=False)
op.create_index(op.f('ix_tool_calls_character_id'), 'tool_calls', ['character_id'], unique=False)
op.create_index(op.f('ix_tool_calls_created_at'), 'tool_calls', ['created_at'], unique=False)
op.create_index(op.f('ix_tool_calls_run_id'), 'tool_calls', ['run_id'], unique=False)
op.create_index(op.f('ix_tool_calls_trace_id'), 'tool_calls', ['trace_id'], unique=False)
op.drop_index(op.f('ix_skills_status'), table_name='skills')
op.drop_table('skills')
op.drop_table('tool_policies')
op.drop_table('model_endpoint_overrides')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('model_endpoint_overrides',
sa.Column('logical_model_name', sa.VARCHAR(length=120), nullable=False),
sa.Column('endpoint_name', sa.VARCHAR(length=120), nullable=False),
sa.Column('base_url', sa.VARCHAR(length=500), nullable=True),
sa.Column('api_key', sa.VARCHAR(length=500), nullable=True),
sa.Column('status', sa.VARCHAR(length=40), nullable=True),
sa.Column('weight', sa.INTEGER(), nullable=True),
sa.Column('priority', sa.INTEGER(), nullable=True),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.PrimaryKeyConstraint('logical_model_name', 'endpoint_name')
)
op.create_table('tool_policies',
sa.Column('name', sa.VARCHAR(length=80), nullable=False),
sa.Column('description', sa.TEXT(), nullable=False),
sa.Column('risk_level', sa.VARCHAR(length=20), nullable=False),
sa.Column('permission_policy', sa.VARCHAR(length=20), nullable=False),
sa.Column('side_effect', sa.BOOLEAN(), nullable=False),
sa.Column('audit_required', sa.BOOLEAN(), nullable=False),
sa.Column('source', sa.VARCHAR(length=20), nullable=False),
sa.Column('enabled', sa.BOOLEAN(), nullable=False),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.PrimaryKeyConstraint('name')
)
op.create_table('skills',
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
sa.Column('name', sa.VARCHAR(length=120), nullable=False),
sa.Column('description', sa.TEXT(), nullable=False),
sa.Column('trigger', sa.TEXT(), nullable=False),
sa.Column('prompt_fragment', sa.TEXT(), nullable=False),
sa.Column('tool_bindings_json', sa.TEXT(), nullable=False),
sa.Column('risk_level', sa.VARCHAR(length=8), nullable=False),
sa.Column('status', sa.VARCHAR(length=40), nullable=False),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_index(op.f('ix_skills_status'), 'skills', ['status'], unique=False)
op.drop_index(op.f('ix_tool_calls_trace_id'), table_name='tool_calls')
op.drop_index(op.f('ix_tool_calls_run_id'), table_name='tool_calls')
op.drop_index(op.f('ix_tool_calls_created_at'), table_name='tool_calls')
op.drop_index(op.f('ix_tool_calls_character_id'), table_name='tool_calls')
op.drop_index(op.f('ix_tool_calls_capability_id'), table_name='tool_calls')
op.drop_table('tool_calls')
op.drop_index(op.f('ix_mandates_status'), table_name='mandates')
op.drop_index(op.f('ix_mandates_owner_scope'), table_name='mandates')
op.drop_index(op.f('ix_mandates_character_id'), table_name='mandates')
op.drop_index(op.f('ix_mandates_capability_id'), table_name='mandates')
op.drop_table('mandates')
op.drop_index('uq_capabilities_owner_name', table_name='capabilities')
op.drop_index(op.f('ix_capabilities_owner_scope'), table_name='capabilities')
op.drop_table('capabilities')
# ### end Alembic commands ###
@@ -1,170 +0,0 @@
"""缘契域_契约与事件_铸魂分灵_河图境界与实体_实录作用域_退役旧人格驱动演化世界表
Revision ID: 007
Revises: 006
Create Date: 2026-08-27 19:32:08.478484
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '007'
down_revision: Union[str, Sequence[str], None] = '006'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 旧世界表退役重建(世界权威移交河图域 hetu;开发数据丢弃)
op.drop_table('world_entities')
op.drop_table('character_world_entities')
op.drop_table('world_relations')
# 缘契域
op.create_table('covenants',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('dimensions_json', sa.Text(), nullable=False),
sa.Column('primary_title', sa.String(length=120), nullable=True),
sa.Column('boundaries_json', sa.Text(), nullable=False),
sa.Column('adult_consent', sa.Boolean(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('version', sa.Integer(), 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_covenants_character_id'), 'covenants', ['character_id'], unique=False)
op.create_index(op.f('ix_covenants_owner_scope'), 'covenants', ['owner_scope'], unique=False)
op.create_index(op.f('ix_covenants_status'), 'covenants', ['status'], unique=False)
op.create_table('covenant_events',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('covenant_id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('type', sa.String(length=20), nullable=False),
sa.Column('payload_json', sa.Text(), nullable=False),
sa.Column('actor', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['covenant_id'], ['covenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_covenant_events_character_id'), 'covenant_events', ['character_id'], unique=False)
op.create_index(op.f('ix_covenant_events_covenant_id'), 'covenant_events', ['covenant_id'], unique=False)
op.create_index(op.f('ix_covenant_events_created_at'), 'covenant_events', ['created_at'], unique=False)
op.create_index(op.f('ix_covenant_events_type'), 'covenant_events', ['type'], unique=False)
# 铸魂
op.create_table('soul_forgings',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('proposed_anchor_json', sa.Text(), nullable=False),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_soul_forgings_character_id'), 'soul_forgings', ['character_id'], unique=False)
op.create_index(op.f('ix_soul_forgings_status'), 'soul_forgings', ['status'], unique=False)
# 河图境界
op.create_table('persistent_worlds',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('rules_json', sa.Text(), nullable=False),
sa.Column('lorebook_id', sa.String(length=80), nullable=True),
sa.Column('canon_count', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_persistent_worlds_lorebook_id'), 'persistent_worlds', ['lorebook_id'], unique=False)
op.create_index(op.f('ix_persistent_worlds_owner_scope'), 'persistent_worlds', ['owner_scope'], unique=False)
op.create_table('world_entities',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('world_id', sa.String(length=80), nullable=False),
sa.Column('kind', sa.String(length=30), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('attrs_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['world_id'], ['persistent_worlds.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_world_entities_world_id'), 'world_entities', ['world_id'], unique=False)
op.create_table('world_relations',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('world_id', sa.String(length=80), nullable=False),
sa.Column('source_entity_id', sa.String(length=80), nullable=False),
sa.Column('target_entity_id', sa.String(length=80), nullable=False),
sa.Column('relation', sa.String(length=80), nullable=False),
sa.Column('note', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['source_entity_id'], ['world_entities.id'], ),
sa.ForeignKeyConstraint(['target_entity_id'], ['world_entities.id'], ),
sa.ForeignKeyConstraint(['world_id'], ['persistent_worlds.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_world_relations_source_entity_id'), 'world_relations', ['source_entity_id'], unique=False)
op.create_index(op.f('ix_world_relations_target_entity_id'), 'world_relations', ['target_entity_id'], unique=False)
op.create_index(op.f('ix_world_relations_world_id'), 'world_relations', ['world_id'], unique=False)
# 实录作用域
with op.batch_alter_table('factual_records') as batch:
batch.add_column(sa.Column('scope', sa.String(length=40), nullable=False, server_default='reality'))
batch.create_index('ix_factual_records_scope', ['scope'])
# 旧人格/驱动/演化域退役(人格基线=道体锚点 identity_baseline_json + 魂籍版本链)
with op.batch_alter_table('characters') as batch:
batch.drop_column('current_personality_version_id')
op.drop_table('personality_versions')
op.drop_table('personality_proposals')
op.drop_table('curiosity_questions')
op.drop_table('exploratory_subgoals')
op.drop_table('evolution_candidates')
def downgrade() -> None:
"""Downgrade schema."""
op.drop_table('evolution_candidates')
op.drop_table('exploratory_subgoals')
op.drop_table('curiosity_questions')
op.drop_table('personality_proposals')
op.drop_table('personality_versions')
with op.batch_alter_table('characters') as batch:
batch.add_column(sa.Column('current_personality_version_id', sa.String(length=80), nullable=True))
with op.batch_alter_table('factual_records') as batch:
batch.drop_index('ix_factual_records_scope')
batch.drop_column('scope')
op.drop_index(op.f('ix_world_relations_world_id'), table_name='world_relations')
op.drop_index(op.f('ix_world_relations_target_entity_id'), table_name='world_relations')
op.drop_index(op.f('ix_world_relations_source_entity_id'), table_name='world_relations')
op.drop_table('world_relations')
op.drop_index(op.f('ix_world_entities_world_id'), table_name='world_entities')
op.drop_table('world_entities')
op.drop_index(op.f('ix_persistent_worlds_owner_scope'), table_name='persistent_worlds')
op.drop_index(op.f('ix_persistent_worlds_lorebook_id'), table_name='persistent_worlds')
op.drop_table('persistent_worlds')
op.drop_index(op.f('ix_soul_forgings_status'), table_name='soul_forgings')
op.drop_index(op.f('ix_soul_forgings_character_id'), table_name='soul_forgings')
op.drop_table('soul_forgings')
op.drop_index(op.f('ix_covenant_events_type'), table_name='covenant_events')
op.drop_index(op.f('ix_covenant_events_created_at'), table_name='covenant_events')
op.drop_index(op.f('ix_covenant_events_covenant_id'), table_name='covenant_events')
op.drop_index(op.f('ix_covenant_events_character_id'), table_name='covenant_events')
op.drop_table('covenant_events')
op.drop_index(op.f('ix_covenants_status'), table_name='covenants')
op.drop_index(op.f('ix_covenants_owner_scope'), table_name='covenants')
op.drop_index(op.f('ix_covenants_character_id'), table_name='covenants')
op.drop_table('covenants')
@@ -1,90 +0,0 @@
"""形仪域_真形锚点_主仪化仪定相_声音档案与克隆授权
Revision ID: 008
Revises: 007
Create Date: 2026-08-27 20:15:34.665285
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '008'
down_revision: Union[str, Sequence[str], None] = '007'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('form_anchors',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('features_json', sa.Text(), nullable=False),
sa.Column('age_presentation', sa.String(length=40), nullable=False),
sa.Column('permissions_json', sa.Text(), nullable=False),
sa.Column('mutable_scope_json', sa.Text(), 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_form_anchors_character_id'), 'form_anchors', ['character_id'], unique=False)
op.create_table('forms',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('kind', sa.String(length=20), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('source', sa.String(length=20), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('media_ref', sa.String(length=500), nullable=True),
sa.Column('live2d_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('adopted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_forms_character_id'), 'forms', ['character_id'], unique=False)
op.create_index(op.f('ix_forms_kind'), 'forms', ['kind'], unique=False)
op.create_index(op.f('ix_forms_status'), 'forms', ['status'], unique=False)
op.create_table('voice_profiles',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('source', sa.String(length=30), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('params_json', sa.Text(), nullable=False),
sa.Column('grant_purpose', sa.String(length=200), nullable=True),
sa.Column('grant_expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('grant_distribution', sa.String(length=200), nullable=True),
sa.Column('grant_revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_voice_profiles_character_id'), 'voice_profiles', ['character_id'], unique=False)
op.create_index(op.f('ix_voice_profiles_is_active'), 'voice_profiles', ['is_active'], unique=False)
op.add_column('pending_confirmations', sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('pending_confirmations', sa.Column('brief_json', sa.Text(), server_default='{}', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('pending_confirmations', 'brief_json')
op.drop_column('pending_confirmations', 'expires_at')
op.drop_index(op.f('ix_voice_profiles_is_active'), table_name='voice_profiles')
op.drop_index(op.f('ix_voice_profiles_character_id'), table_name='voice_profiles')
op.drop_table('voice_profiles')
op.drop_index(op.f('ix_forms_status'), table_name='forms')
op.drop_index(op.f('ix_forms_kind'), table_name='forms')
op.drop_index(op.f('ix_forms_character_id'), table_name='forms')
op.drop_table('forms')
op.drop_index(op.f('ix_form_anchors_character_id'), table_name='form_anchors')
op.drop_table('form_anchors')
# ### end Alembic commands ###
@@ -1,87 +0,0 @@
"""天工阁_受托任务_叩心候选与约束_退役旧目标计划感知大周天模块
Revision ID: 009
Revises: 008
Create Date: 2026-08-27 20:31:23.297111
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '009'
down_revision: Union[str, Sequence[str], None] = '008'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('backstage_tasks',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=True),
sa.Column('parent_task_id', sa.String(length=80), nullable=True),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('brief', sa.Text(), nullable=False),
sa.Column('acceptance_criteria', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=30), nullable=False),
sa.Column('result_digest', sa.Text(), nullable=False),
sa.Column('evidence_json', sa.Text(), nullable=False),
sa.Column('run_id', sa.String(length=80), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['parent_task_id'], ['backstage_tasks.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_backstage_tasks_character_id'), 'backstage_tasks', ['character_id'], unique=False)
op.create_index(op.f('ix_backstage_tasks_owner_scope'), 'backstage_tasks', ['owner_scope'], unique=False)
op.create_index(op.f('ix_backstage_tasks_parent_task_id'), 'backstage_tasks', ['parent_task_id'], unique=False)
op.create_index(op.f('ix_backstage_tasks_run_id'), 'backstage_tasks', ['run_id'], unique=False)
op.create_index(op.f('ix_backstage_tasks_status'), 'backstage_tasks', ['status'], unique=False)
op.create_table('knocking_candidates',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('trigger', sa.String(length=20), nullable=False),
sa.Column('draft', sa.Text(), nullable=False),
sa.Column('channel', sa.String(length=20), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('ruling_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('dispatched_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_knocking_candidates_character_id'), 'knocking_candidates', ['character_id'], unique=False)
op.create_index(op.f('ix_knocking_candidates_owner_scope'), 'knocking_candidates', ['owner_scope'], unique=False)
op.create_index(op.f('ix_knocking_candidates_status'), 'knocking_candidates', ['status'], unique=False)
# ### end Alembic commands ###
# 旧域退役:太一(goals)/洛书(plans)/天工阁旧 roster/感知/大周天
op.drop_table('goals')
op.drop_table('plans')
op.drop_table('agent_roster')
op.drop_table('perception_channels')
op.drop_table('big_cycles')
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_knocking_candidates_status'), table_name='knocking_candidates')
op.drop_index(op.f('ix_knocking_candidates_owner_scope'), table_name='knocking_candidates')
op.drop_index(op.f('ix_knocking_candidates_character_id'), table_name='knocking_candidates')
op.drop_table('knocking_candidates')
op.drop_index(op.f('ix_backstage_tasks_status'), table_name='backstage_tasks')
op.drop_index(op.f('ix_backstage_tasks_run_id'), table_name='backstage_tasks')
op.drop_index(op.f('ix_backstage_tasks_parent_task_id'), table_name='backstage_tasks')
op.drop_index(op.f('ix_backstage_tasks_owner_scope'), table_name='backstage_tasks')
op.drop_index(op.f('ix_backstage_tasks_character_id'), table_name='backstage_tasks')
op.drop_table('backstage_tasks')
# ### end Alembic commands ###
@@ -1,121 +0,0 @@
"""器谱_设备配对与逐项授契
Revision ID: 010
Revises: 009
Create Date: 2026-08-28 09:25:57.705846
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '010'
down_revision: Union[str, Sequence[str], None] = '009'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('device_grants',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('device_id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('capability', sa.String(length=60), nullable=False),
sa.Column('scope_json', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('granted_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_device_grants_capability'), 'device_grants', ['capability'], unique=False)
op.create_index(op.f('ix_device_grants_device_id'), 'device_grants', ['device_id'], unique=False)
op.create_index(op.f('ix_device_grants_owner_scope'), 'device_grants', ['owner_scope'], unique=False)
op.create_index(op.f('ix_device_grants_status'), 'device_grants', ['status'], unique=False)
op.create_table('devices',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('kind', sa.String(length=30), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('key_fingerprint', sa.String(length=128), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_devices_owner_scope'), 'devices', ['owner_scope'], unique=False)
op.create_index(op.f('ix_devices_status'), 'devices', ['status'], unique=False)
op.drop_index(op.f('ix_character_profile_candidates_character_id'), table_name='character_profile_candidates')
op.drop_index(op.f('ix_character_profile_candidates_import_job_id'), table_name='character_profile_candidates')
op.drop_index(op.f('ix_character_profile_candidates_status'), table_name='character_profile_candidates')
op.drop_table('character_profile_candidates')
op.drop_index(op.f('ix_memory_candidates_character_id'), table_name='memory_candidates')
op.drop_index(op.f('ix_memory_candidates_cycle_id'), table_name='memory_candidates')
op.drop_index(op.f('ix_memory_candidates_import_job_id'), table_name='memory_candidates')
op.drop_index(op.f('ix_memory_candidates_status'), table_name='memory_candidates')
op.drop_table('memory_candidates')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('memory_candidates',
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('import_job_id', sa.VARCHAR(length=80), nullable=True),
sa.Column('cycle_id', sa.VARCHAR(length=80), nullable=True),
sa.Column('type', sa.VARCHAR(length=60), nullable=False),
sa.Column('title', sa.VARCHAR(length=200), nullable=False),
sa.Column('content', sa.TEXT(), nullable=False),
sa.Column('confidence', sa.FLOAT(), nullable=False),
sa.Column('importance', sa.FLOAT(), nullable=False),
sa.Column('sensitivity', sa.VARCHAR(length=40), nullable=False),
sa.Column('evidence_ids_json', sa.TEXT(), nullable=False),
sa.Column('status', sa.VARCHAR(length=40), nullable=False),
sa.Column('reason', sa.TEXT(), nullable=True),
sa.Column('reviewed_at', sa.DATETIME(), nullable=True),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['cycle_id'], ['big_cycles.id'], ),
sa.ForeignKeyConstraint(['import_job_id'], ['character_import_jobs.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_memory_candidates_status'), 'memory_candidates', ['status'], unique=False)
op.create_index(op.f('ix_memory_candidates_import_job_id'), 'memory_candidates', ['import_job_id'], unique=False)
op.create_index(op.f('ix_memory_candidates_cycle_id'), 'memory_candidates', ['cycle_id'], unique=False)
op.create_index(op.f('ix_memory_candidates_character_id'), 'memory_candidates', ['character_id'], unique=False)
op.create_table('character_profile_candidates',
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('import_job_id', sa.VARCHAR(length=80), nullable=True),
sa.Column('field', sa.VARCHAR(length=60), nullable=False),
sa.Column('value', sa.TEXT(), nullable=False),
sa.Column('confidence', sa.FLOAT(), nullable=False),
sa.Column('evidence_ids_json', sa.TEXT(), nullable=False),
sa.Column('status', sa.VARCHAR(length=40), nullable=False),
sa.Column('reason', sa.TEXT(), nullable=True),
sa.Column('reviewed_at', sa.DATETIME(), nullable=True),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['import_job_id'], ['character_import_jobs.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_character_profile_candidates_status'), 'character_profile_candidates', ['status'], unique=False)
op.create_index(op.f('ix_character_profile_candidates_import_job_id'), 'character_profile_candidates', ['import_job_id'], unique=False)
op.create_index(op.f('ix_character_profile_candidates_character_id'), 'character_profile_candidates', ['character_id'], unique=False)
op.drop_index(op.f('ix_devices_status'), table_name='devices')
op.drop_index(op.f('ix_devices_owner_scope'), table_name='devices')
op.drop_table('devices')
op.drop_index(op.f('ix_device_grants_status'), table_name='device_grants')
op.drop_index(op.f('ix_device_grants_owner_scope'), table_name='device_grants')
op.drop_index(op.f('ix_device_grants_device_id'), table_name='device_grants')
op.drop_index(op.f('ix_device_grants_capability'), table_name='device_grants')
op.drop_table('device_grants')
# ### end Alembic commands ###
+63
View File
@@ -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",
]
+197
View File
@@ -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",
]
+263
View File
@@ -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
+245
View File
@@ -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
+325
View File
@@ -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"
+231
View File
@@ -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",
]
+80
View File
@@ -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
+158
View File
@@ -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
+17
View File
@@ -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",
]
+253
View File
@@ -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)
+164
View File
@@ -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()
+133
View File
@@ -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",
]
+144
View File
@@ -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
+18
View File
@@ -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)
+164
View File
@@ -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)
+115
View File
@@ -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()
+463
View File
@@ -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,
)
+172
View File
@@ -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",
]
+147
View File
@@ -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)}
+111
View File
@@ -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)
+21
View File
@@ -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",
]
+41
View File
@@ -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 = ""
+33
View File
@@ -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
+81
View File
@@ -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
+24 -33
View File
@@ -4,36 +4,31 @@ from fastapi import APIRouter
from app.api.v1.endpoints import ( from app.api.v1.endpoints import (
admin, admin,
audio,
auth, auth,
avatar,
character_import, character_import,
characters, characters,
chat,
drive,
events, events,
evolution,
extraction,
goals,
health, health,
imports, imports,
embryo_drafts,
jingsheng_characters,
kernel_runs,
lorebooks, lorebooks,
me,
baigong,
hetu,
soul,
tianzai,
kouxin,
tiangong,
xingyi,
yuanqi,
mirror_trees,
mcp, mcp,
memory,
personality,
runs, runs,
safety, safety,
sandbox_files, sandbox_files,
settings, settings,
skills,
time,
tools, tools,
world,
xirang,
) )
api_router = APIRouter() api_router = APIRouter()
@@ -41,29 +36,25 @@ api_router.include_router(health.router)
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(characters.router) api_router.include_router(characters.router)
api_router.include_router(character_import.router) api_router.include_router(character_import.router)
api_router.include_router(chat.router)
api_router.include_router(runs.router) api_router.include_router(runs.router)
api_router.include_router(kernel_runs.router) api_router.include_router(memory.router)
api_router.include_router(me.router)
api_router.include_router(hetu.world_router)
api_router.include_router(soul.soul_router)
api_router.include_router(tiangong.task_router)
api_router.include_router(kouxin.knock_router)
api_router.include_router(tianzai.device_router)
api_router.include_router(xingyi.form_router)
api_router.include_router(yuanqi.cov_router)
for _baigong_r in baigong.routers:
api_router.include_router(_baigong_r)
api_router.include_router(embryo_drafts.router)
api_router.include_router(jingsheng_characters.router)
api_router.include_router(mirror_trees.router)
api_router.include_router(xirang.router)
api_router.include_router(mirror_trees.node_router)
api_router.include_router(lorebooks.router) api_router.include_router(lorebooks.router)
api_router.include_router(personality.router)
api_router.include_router(tools.router) api_router.include_router(tools.router)
api_router.include_router(skills.router)
api_router.include_router(mcp.router) api_router.include_router(mcp.router)
api_router.include_router(safety.router) api_router.include_router(safety.router)
api_router.include_router(sandbox_files.router) api_router.include_router(sandbox_files.router)
api_router.include_router(settings.router) api_router.include_router(settings.router)
api_router.include_router(world.router)
api_router.include_router(imports.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(events.router)
api_router.include_router(extraction.router)
api_router.include_router(goals.router)
api_router.include_router(admin.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)
+28 -7
View File
@@ -1,6 +1,6 @@
"""API 层依赖注入。 """API 层依赖注入。
从 ``app.state`` 取 lifespan 装配的共享运行时(乾坤沙箱客户端 / EventStore)。 从 ``app.state`` 取 lifespan 装配的共享运行时(ToolRuntime / ConfirmationStore / EventStore)。
CRUD 类路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不在此注入 session。 CRUD 类路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不在此注入 session。
""" """
@@ -11,10 +11,12 @@ import hmac
from fastapi import Depends, HTTPException, Request from fastapi import Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession 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.core.config import settings
from app.db.session import get_session from app.db.session import get_session
from app.integrations.sandbox.client import SandboxClient
from app.modules.runtime.events import EventStore from app.modules.runtime.events import EventStore
from app.modules.safety.confirmation import ConfirmationStore
from app.schemas.auth import CurrentUser as CurrentUserSchema from app.schemas.auth import CurrentUser as CurrentUserSchema
from app.services.auth import decode_token, get_user_by_id, get_user_permissions from app.services.auth import decode_token, get_user_by_id, get_user_permissions
@@ -26,11 +28,30 @@ def _unavailable(message: str) -> HTTPException:
) )
def get_sandbox_client(request: Request) -> SandboxClient: def get_tool_runtime(request: Request) -> ToolRuntime:
sandbox: SandboxClient | None = getattr(request.app.state, "baigong_sandbox", None) runtime: ToolRuntime | None = getattr(request.app.state, "tool_runtime", None)
if sandbox is None: if runtime is None:
raise _unavailable("乾坤沙箱未就绪(SANDBOX 未配置或未启动)") raise _unavailable("工具运行时未就绪(沙箱未配置或未启动)")
return sandbox 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: def get_event_store(request: Request) -> EventStore:
+156 -18
View File
@@ -4,7 +4,7 @@
+ 五组治理对象 CRUD(§19.4)。治理写操作由 service 层记审计并入起居注。 + 五组治理对象 CRUD(§19.4)。治理写操作由 service 层记审计并入起居注。
路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不持有 session; 路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不持有 session;
聚合视图自持查询(百工域常驻),无注入运行时。 ``ToolRuntime`` / ``ConfirmationStore`` 等运行时对象经 ``deps`` 从 ``app.state`` 注入。
""" """
import json import json
@@ -13,29 +13,49 @@ from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
from app.api.v1.deps import ( from app.api.v1.deps import (
get_confirmation_store,
get_current_user, get_current_user,
get_optional_tool_runtime,
require_admin, require_admin,
) )
from app.application.tool_runtime.runtime import ToolRuntime
from app.models.runtime import Event 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 ( from app.schemas.admin_character import (
AdminCreateTemplateRequest, AdminCreateTemplateRequest,
AdminTemplateSummaryResponse, AdminTemplateSummaryResponse,
AdminUpdateTemplateRequest, AdminUpdateTemplateRequest,
) )
from app.schemas.admin import (
AdminCreateUserRequest,
AdminUpdateUserRequest,
AdminUserResponse,
AuditEntry,
PendingApproval,
PivotHealthReport,
TimeEventListResponse,
TimeEventResponse,
TraceDetail,
TraceListResponse,
)
from app.schemas.auth import CurrentUser from app.schemas.auth import CurrentUser
from app.schemas.common import Page from app.schemas.common import Page
from app.services import governance as governance_service
from app.services.admin import ( from app.services.admin import (
get_audit_timeline, get_audit_timeline,
get_pending_approvals, get_pending_approvals,
@@ -60,9 +80,13 @@ router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(requir
@router.get("/pivots/health", response_model=PivotHealthReport) @router.get("/pivots/health", response_model=PivotHealthReport)
async def pivots_health( 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), current_user: CurrentUser = Depends(get_current_user),
) -> PivotHealthReport: ) -> PivotHealthReport:
return await get_pivots_health( return await get_pivots_health(
confirmation_store=confirmation_store,
tool_runtime=tool_runtime,
owner_scope=current_user.owner_scope, owner_scope=current_user.owner_scope,
) )
@@ -72,8 +96,12 @@ async def pivots_health(
@router.get("/safety/pending", response_model=Page[PendingApproval]) @router.get("/safety/pending", response_model=Page[PendingApproval])
async def safety_pending( async def safety_pending(
confirmation_store: ConfirmationStore = Depends(get_confirmation_store),
tool_runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> Page[PendingApproval]: ) -> Page[PendingApproval]:
items = await get_pending_approvals() 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)) return Page(items=items, page=1, page_size=len(items), total=len(items))
@@ -92,29 +120,139 @@ async def audit_timeline(
# —— §19.4 太一枢 · 目标 —— # —— §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 烛照枢 · 感知通道 —— # —— §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 洛书枢 · 计划 —— # —— §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 天工阁枢 · 角色在岗 —— # —— §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 链路追踪 —— # —— §19.5 链路追踪 ——
@@ -249,7 +387,7 @@ async def admin_tenants_overview() -> list[_TenantUserOverview]:
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.models.character import Character from app.models.character import Character
from app.models.xirang import Memory from app.models.memory import Memory
from app.models.user import User from app.models.user import User
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
@@ -268,7 +406,7 @@ async def admin_tenants_overview() -> list[_TenantUserOverview]:
mem_count = await session.scalar( mem_count = await session.scalar(
select(func.count()) select(func.count())
.select_from(Memory) .select_from(Memory)
.join(Character, Memory.character_id == Character.id).where(Memory.status != "tombstoned") .join(Character, Memory.character_id == Character.id)
.where( .where(
Character.owner_scope == user.id, Character.owner_scope == user.id,
Character.is_system_template.is_(False), Character.is_system_template.is_(False),
@@ -316,13 +454,13 @@ async def admin_tenant_memories(user_id: str) -> list[dict]:
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.models.character import Character from app.models.character import Character
from app.models.xirang import Memory from app.models.memory import Memory
from app.services.serializers import memory_to_response from app.services.serializers import memory_to_response
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
result = await session.execute( result = await session.execute(
select(Memory) select(Memory)
.join(Character, Memory.character_id == Character.id).where(Memory.status != "tombstoned") .join(Character, Memory.character_id == Character.id)
.where( .where(
Character.owner_scope == user_id, Character.owner_scope == user_id,
Character.is_system_template.is_(False), Character.is_system_template.is_(False),
+275
View File
@@ -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)
+47
View File
@@ -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)
-5
View File
@@ -1,5 +0,0 @@
"""百工与司命路由(域实现见 app/domains/baigong/api.py)。"""
from app.domains.baigong.api import routers
__all__ = ["routers"]
@@ -1,49 +1,29 @@
"""角色卡导入 / 导出 API。 """角色卡导入 / 导出 API。"""
rewrite/1.0 M2b:导入产出**可审阅灵胚**(含 character_book 暂存),不再直接建人物;
经照心编辑与启镜(POST /embryo-drafts/{id}/activate)后才物化人物与世谱。
"""
import json import json
from fastapi import APIRouter, Depends, File, Response, UploadFile from fastapi import APIRouter, Depends, File, Response, UploadFile
from app.api.v1.deps import get_current_user from app.api.v1.deps import get_current_user
from app.domains import jingsheng
from app.schemas.auth import CurrentUser from app.schemas.auth import CurrentUser
from app.services.card_import import card_to_embryo_payload, export_character_card 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 = APIRouter(prefix="/characters", tags=["characters"])
@router.post("/import-card", status_code=201) @router.post("/import-card", response_model=ImportCardResponse)
async def import_character_card_api( async def import_character_card_api(
file: UploadFile = File(..., description="角色卡文件,支持 .png / .json / .charx。"), file: UploadFile = File(..., description="角色卡文件,支持 .png / .json / .charx。"),
current_user: CurrentUser = Depends(get_current_user), current_user: CurrentUser = Depends(get_current_user),
) -> dict: ) -> ImportCardResponse:
"""导入酒馆角色卡(V1/V2/V3)→ 生成可审阅灵胚。 """导入酒馆角色卡(V1/V2/V3),建立人物 + 世谱(character_book)。
卡内容整体进入灵胚道体草稿(identity_baseline.import_card), ``personality`` 写入道体 ``personality_summary`` 作初始值;
``character_book`` 在启镜时物化为该人物的世谱书与条目。 ``character_book`` 转为该角色的世谱书 + 条目。
""" """
raw = await file.read() raw = await file.read()
payload = card_to_embryo_payload(raw) return await import_character_card_response(raw, owner_scope=current_user.owner_scope)
book_entry_count = payload.pop("book_entry_count")
embryo = await jingsheng.create_embryo(
owner_scope=current_user.owner_scope,
source="imported",
name=payload["name"],
description=payload["description"],
identity_baseline=payload["identity_baseline"],
)
return {
"embryo_id": embryo.id,
"status": embryo.status,
"name": embryo.name,
"source": embryo.source,
"book_entry_count": book_entry_count,
"next": "照心编辑 PATCH /embryo-drafts/{id},启镜 POST /embryo-drafts/{id}/activate",
}
@router.get("/{character_id}/export-card") @router.get("/{character_id}/export-card")
+137
View File
@@ -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,
)
+73
View File
@@ -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)
@@ -1,142 +0,0 @@
"""灵胚 API(镜生域,rewrite/1.0 M1)。对应 api-contracts.md §3.2。"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains import jingsheng
from app.kernel import Kernel
from app.schemas.auth import CurrentUser
router = APIRouter(prefix="/embryo-drafts", tags=["jingsheng"])
class EmbryoCreate(BaseModel):
source: str = Field(default="original", description="original/adopted/imported/serendipity")
name: str = Field(min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=8000)
identity_baseline: dict = Field(default_factory=dict, description="道体锚点草稿")
class EmbryoPatch(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=8000)
identity_baseline: dict | None = None
class RehearseRequest(BaseModel):
prompt: str = Field(min_length=1, max_length=8000)
def _embryo_view(embryo) -> dict: # noqa: ANN001 — ORM 行
return {
"id": embryo.id,
"status": embryo.status,
"source": embryo.source,
"name": embryo.name,
"description": embryo.description,
"identity_baseline": json.loads(embryo.identity_baseline_json or "{}"),
"rehearsals": json.loads(embryo.rehearsals_json or "[]"),
"created_at": embryo.created_at.isoformat(),
"updated_at": embryo.updated_at.isoformat(),
}
@router.get("")
async def list_embryo_drafts(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return [_embryo_view(e) for e in await jingsheng.list_embryos(user.owner_scope)]
@router.post("", status_code=201)
async def create_embryo_draft(
payload: EmbryoCreate, user: CurrentUser = Depends(get_current_user)
) -> dict:
embryo = await jingsheng.create_embryo(
owner_scope=user.owner_scope,
source=payload.source,
name=payload.name,
description=payload.description,
identity_baseline=payload.identity_baseline,
)
return _embryo_view(embryo)
@router.get("/{embryo_id}")
async def get_embryo_draft(
embryo_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return _embryo_view(await jingsheng.get_embryo(embryo_id, user.owner_scope))
@router.patch("/{embryo_id}")
async def patch_embryo_draft(
embryo_id: str, payload: EmbryoPatch, user: CurrentUser = Depends(get_current_user)
) -> dict:
embryo = await jingsheng.patch_embryo(
embryo_id,
user.owner_scope,
name=payload.name,
description=payload.description,
identity_baseline=payload.identity_baseline,
)
return _embryo_view(embryo)
@router.post("/{embryo_id}/rehearse")
async def rehearse_embryo(
embryo_id: str,
payload: RehearseRequest,
request: Request,
user: CurrentUser = Depends(get_current_user),
) -> dict:
"""照心试演:幻境 Run,不产生人物经历;事件流经 /runs/{run_id}/stream。"""
embryo = await jingsheng.get_embryo(embryo_id, user.owner_scope)
if embryo.status == "activated":
from app.core.errors import AppError
raise AppError("HJ-1103", "灵胚已启镜,无需再试演。", status_code=409)
kernel: Kernel | None = getattr(request.app.state, "kernel", None)
if kernel is None:
from app.core.errors import AppError
raise AppError("service_unavailable", "Kernel 运行时未就绪。", status_code=503)
baseline = json.loads(embryo.identity_baseline_json or "{}")
system_contract = (
f"你是一位尚未降生的灵胚「{embryo.name}」,正在幻境中试演。"
f"身份锚点:{json.dumps(baseline, ensure_ascii=False)}"
)
run = await kernel.start_rehearsal_run(
embryo_id=embryo_id, prompt=payload.prompt, system_contract=system_contract,
owner_id=user.owner_scope,
)
await jingsheng.record_rehearsal(
embryo_id, user.owner_scope, run_id=run.id, prompt=payload.prompt
)
return {"run_id": run.id, "kind": "rehearsal"}
@router.post("/{embryo_id}/activate")
async def activate_embryo_draft(
embryo_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
"""启镜:签发正式人物 ID、魂籍、执镜关系与命台。"""
character = await jingsheng.activate_embryo(embryo_id, user.owner_scope)
return {
"character_id": character.id,
"name": character.name,
"status": character.status,
"source": character.source,
"identity_record_version": 1,
}
@router.post("/{embryo_id}/abandon", status_code=204)
async def abandon_embryo_draft(
embryo_id: str, user: CurrentUser = Depends(get_current_user)
) -> None:
await jingsheng.abandon_embryo(embryo_id, user.owner_scope)
+60
View File
@@ -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)
+21
View File
@@ -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)
-5
View File
@@ -1,5 +0,0 @@
"""河图世界路由(域实现见 app/domains/hetu/api.py)。"""
from app.domains.hetu.api import world_router
__all__ = ["world_router"]
+119
View File
@@ -10,6 +10,12 @@ from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from app.api.v1.deps import get_current_user from app.api.v1.deps import get_current_user
from app.schemas.auth import CurrentUser from app.schemas.auth import CurrentUser
from app.schemas.candidates import (
BatchReviewRequest,
BatchReviewResult,
MemoryCandidateResponse,
ProfileCandidateResponse,
)
from app.schemas.common import Page from app.schemas.common import Page
from app.schemas.imports import ( from app.schemas.imports import (
CreateImportRequest, CreateImportRequest,
@@ -17,11 +23,18 @@ from app.schemas.imports import (
ImportJobResponse, ImportJobResponse,
) )
from app.services.imports import ( from app.services.imports import (
batch_review_memory_candidates,
batch_review_profile_candidates,
create_import_from_json, create_import_from_json,
create_import_from_upload, create_import_from_upload,
get_import, get_import,
list_import_items, list_import_items,
list_jobs_page, list_jobs_page,
list_memory_candidates,
list_profile_candidates,
trigger_memory_extraction,
trigger_personality_extraction,
trigger_profile_extraction,
) )
router = APIRouter(tags=["imports"]) router = APIRouter(tags=["imports"])
@@ -103,3 +116,109 @@ async def read_import_items(
page_size=page_size, page_size=page_size,
owner_scope=current_user.owner_scope, 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
)
@@ -1,220 +0,0 @@
"""镜生域人物端点:魂籍导出与校验、死亡请求/撤销/落定、命台租约(api-contracts.md §3.2)。"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import select
from app.api.v1.deps import get_current_user
from app.core.config import settings
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.domains import jingsheng
from app.domains.jingsheng.identity import IdentityVerificationError, verify_identity_record
from app.domains.jingsheng.seat import SeatConflict, acquire_lease, emit_seat_event, release_lease
from app.models.character import Character
from app.models.jingsheng import IdentityRecord
from app.schemas.auth import CurrentUser
router = APIRouter(prefix="/characters", tags=["jingsheng"])
class DeathFinalizeRequest(BaseModel):
confirm_phrase: str = Field(description="须填入人物名以强确认")
class SeatLeaseRequest(BaseModel):
holder: str = Field(min_length=1, max_length=120, description="租约持有者标识(设备/实例)")
async def _owned_character(character_id: str, owner_scope: str) -> Character:
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
return character
# ── 魂籍 ──────────────────────────────────────────────
@router.get("/{character_id}/identity-records")
async def list_identity_records(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
result = await session.execute(
select(IdentityRecord)
.where(IdentityRecord.character_id == character_id)
.order_by(IdentityRecord.version.desc())
)
records = result.scalars().all()
return [
{
"id": r.id,
"version": r.version,
"digest": r.digest,
"created_at": r.created_at.isoformat(),
}
for r in records
]
@router.get("/{character_id}/identity-records/{record_id}")
async def get_identity_record(
character_id: str, record_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
record = await session.get(IdentityRecord, record_id)
if record is None or record.character_id != character_id:
raise AppError("not_found", "魂籍记录不存在。")
return {
"id": record.id,
"version": record.version,
"digest": record.digest,
"payload": json.loads(record.payload_json),
"created_at": record.created_at.isoformat(),
}
@router.post("/{character_id}/identity-records")
async def export_identity_record(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
"""导出新版本魂籍:身份/数据/运行状态三段分离。"""
character = await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
latest = await session.scalar(
select(IdentityRecord.version)
.where(IdentityRecord.character_id == character_id)
.order_by(IdentityRecord.version.desc())
.limit(1)
)
next_version = (latest or 0) + 1
record = jingsheng.build_identity_record(
character_id=character_id,
version=next_version,
identity={
"name": character.name,
"source": character.source,
"identity_baseline": json.loads(character.identity_baseline_json or "{}"),
"activated_at": character.activated_at.isoformat() if character.activated_at else None,
},
runtime_state={"status": character.status},
)
session.add(record)
await session.commit()
await session.refresh(record)
return {
"id": record.id,
"version": record.version,
"digest": record.digest,
"created_at": record.created_at.isoformat(),
}
@router.post("/{character_id}/identity-records/{record_id}/verify")
async def verify_record(
character_id: str, record_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
record = await session.get(IdentityRecord, record_id)
if record is None or record.character_id != character_id:
raise AppError("not_found", "魂籍记录不存在。")
try:
verify_identity_record(record)
except IdentityVerificationError as exc:
raise AppError("HJ-9000", f"魂籍校验失败:{exc.reason}", status_code=422) from exc
return {"valid": True, "version": record.version, "digest": record.digest}
# ── 死亡与冷静期 ──────────────────────────────────────────────
@router.post("/{character_id}/death-request")
async def request_death(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
character = await jingsheng.death_request(
character_id, user.owner_scope, calm_period_hours=settings.death_calm_period_hours
)
return {
"character_id": character.id,
"status": character.status,
"calm_period_until": character.calm_period_until.isoformat(),
}
@router.post("/{character_id}/death-cancel")
async def cancel_death(character_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
character = await jingsheng.death_cancel(character_id, user.owner_scope)
return {"character_id": character.id, "status": character.status}
@router.post("/{character_id}/death-finalize")
async def finalize_death(
character_id: str,
payload: DeathFinalizeRequest,
user: CurrentUser = Depends(get_current_user),
) -> dict:
character = await _owned_character(character_id, user.owner_scope)
if payload.confirm_phrase != character.name:
raise AppError("invalid_request", "确认短语与人物名不一致,拒绝落定。")
deceased = await jingsheng.death_finalize(character_id, user.owner_scope)
return {
"character_id": deceased.id,
"status": deceased.status,
"died_at": deceased.died_at.isoformat() if deceased.died_at else None,
}
# ── 命台租约 ──────────────────────────────────────────────
@router.post("/{character_id}/seat/acquire")
async def acquire_seat(
character_id: str, payload: SeatLeaseRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
try:
seat = await acquire_lease(
session,
character_id,
payload.holder,
ttl_seconds=settings.seat_lease_ttl_seconds,
)
except SeatConflict as exc:
current_holder = exc.seat.holder
await session.rollback()
# 留痕在事务回滚后发射(避免 SQLite 跨连接写锁冲突)
await emit_seat_event(
"seat.contested",
character_id,
current_holder,
None,
requester=payload.holder,
)
raise AppError("HJ-1101", f"命台被占用:{exc}", status_code=429) from exc
epoch, holder = seat.epoch, seat.holder
await session.commit()
await emit_seat_event("seat.acquired", character_id, holder, epoch)
return {"epoch": epoch, "holder": holder}
@router.post("/{character_id}/seat/release", status_code=204)
async def release_seat(
character_id: str, payload: SeatLeaseRequest, user: CurrentUser = Depends(get_current_user)
) -> None:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
released = await release_lease(session, character_id, payload.holder)
await session.commit()
if released:
await emit_seat_event("seat.released", character_id, payload.holder, None)
@@ -1,55 +0,0 @@
"""Kernel Run API(rewrite/1.0 新契约首个端点)。
POST /api/v1/runs:创建占位前台 Run(M0 骨架),事件流经既有
GET /api/v1/runs/{run_id}/stream 订阅(SSE + Last-Event-ID 断线续传)。
与旧 runs 路由共存:本模块只新增 POST,不改动旧读端点(M2 重建时收敛)。
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.deps import get_current_user
from app.db.session import get_session
from app.kernel import Kernel
from app.schemas.auth import CurrentUser
from app.schemas.runtime import RunResponse
from app.services.characters import resolve_active_character
from app.services.runtime import get_run_response
router = APIRouter(prefix="/runs", tags=["kernel"])
class KernelRunCreate(BaseModel):
"""占位 Run 创建请求:M0 无镜树挂载,prompt 直接作为用户输入。"""
prompt: str = Field(default="你好,请介绍一下你自己。", min_length=1, max_length=8000)
character_id: str | None = Field(default=None, description="缺省用当前激活人物")
def _get_kernel(request: Request) -> Kernel:
kernel: Kernel | None = getattr(request.app.state, "kernel", None)
if kernel is None:
from app.core.errors import AppError
raise AppError("service_unavailable", "Kernel 运行时未就绪。", status_code=503)
return kernel
@router.post("", response_model=RunResponse, status_code=200)
async def create_kernel_run(
payload: KernelRunCreate,
request: Request,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_session),
) -> RunResponse:
kernel = _get_kernel(request)
character = await resolve_active_character(
db, payload.character_id, owner_scope=user.owner_scope
)
run = await kernel.start_foreground_run(
character_id=character.id, prompt=payload.prompt, owner_id=user.owner_scope
)
return await get_run_response(run.id)
-5
View File
@@ -1,5 +0,0 @@
"""叩心路由(域实现见 app/domains/kouxin/api.py)。"""
from app.domains.kouxin.api import knock_router
__all__ = ["knock_router"]
+50 -17
View File
@@ -7,12 +7,15 @@ stdio 模式通过 /start、/stop、/restart 控制子进程生命周期。
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi import status as http_status 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.integrations.mcp.process_manager import get_process_manager
from app.schemas.mcp import ( from app.schemas.mcp import (
CreateMcpServerRequest, CreateMcpServerRequest,
McpServerResponse, McpServerResponse,
McpServerStatusResponse, McpServerStatusResponse,
UpdateMcpServerRequest) UpdateMcpServerRequest,
)
from app.services import mcp as mcp_service from app.services import mcp as mcp_service
router = APIRouter(prefix="/tools/mcp-servers", tags=["mcp"]) router = APIRouter(prefix="/tools/mcp-servers", tags=["mcp"])
@@ -21,9 +24,12 @@ router = APIRouter(prefix="/tools/mcp-servers", tags=["mcp"])
@router.post( @router.post(
"", "",
response_model=McpServerResponse, response_model=McpServerResponse,
status_code=http_status.HTTP_201_CREATED) status_code=http_status.HTTP_201_CREATED,
)
async def register_mcp_server( async def register_mcp_server(
payload: CreateMcpServerRequest) -> McpServerResponse: payload: CreateMcpServerRequest,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> McpServerResponse:
"""注册 MCP server。 """注册 MCP server。
streamable-http:同步拉取工具清单并注册到运行时。 streamable-http:同步拉取工具清单并注册到运行时。
@@ -39,23 +45,28 @@ async def register_mcp_server(
env_json=payload.env_json, env_json=payload.env_json,
cwd=payload.cwd, cwd=payload.cwd,
status=payload.status, status=payload.status,
runtime=runtime,
process_manager=get_process_manager(), process_manager=get_process_manager(),
) )
@router.get("", response_model=list[McpServerResponse]) @router.get("", response_model=list[McpServerResponse])
async def list_mcp_servers( async def list_mcp_servers(
status: str | None = Query(default=None, description="按状态过滤:enabled/disabled")) -> list[McpServerResponse]: status: str | None = Query(default=None, description="按状态过滤:enabled/disabled"),
) -> list[McpServerResponse]:
"""列举 MCP server(含工具清单摘要)。""" """列举 MCP server(含工具清单摘要)。"""
return await mcp_service.list_mcp_servers(status=status) return await mcp_service.list_mcp_servers(status=status)
@router.patch( @router.patch(
"/{server_id}", "/{server_id}",
response_model=McpServerResponse) response_model=McpServerResponse,
)
async def update_mcp_server( async def update_mcp_server(
server_id: str, server_id: str,
payload: UpdateMcpServerRequest) -> McpServerResponse: payload: UpdateMcpServerRequest,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> McpServerResponse:
"""更新 MCP server 字段(部分更新)。""" """更新 MCP server 字段(部分更新)。"""
return await mcp_service.update_mcp_server( return await mcp_service.update_mcp_server(
server_id, server_id,
@@ -66,61 +77,79 @@ async def update_mcp_server(
args_json=payload.args_json, args_json=payload.args_json,
env_json=payload.env_json, env_json=payload.env_json,
cwd=payload.cwd, cwd=payload.cwd,
runtime=runtime,
process_manager=get_process_manager(), process_manager=get_process_manager(),
) )
@router.delete("/{server_id}", status_code=http_status.HTTP_204_NO_CONTENT) @router.delete("/{server_id}", status_code=http_status.HTTP_204_NO_CONTENT)
async def delete_mcp_server( async def delete_mcp_server(
server_id: str) -> None: server_id: str,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> None:
"""删除 MCP server(先 stop 子进程再删)。""" """删除 MCP server(先 stop 子进程再删)。"""
await mcp_service.delete_mcp_server( await mcp_service.delete_mcp_server(
server_id, server_id,
runtime=runtime,
process_manager=get_process_manager(), process_manager=get_process_manager(),
) )
@router.post( @router.post(
"/{server_id}/start", "/{server_id}/start",
response_model=McpServerStatusResponse) response_model=McpServerStatusResponse,
)
async def start_mcp_server( async def start_mcp_server(
server_id: str) -> McpServerStatusResponse: server_id: str,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> McpServerStatusResponse:
"""启动 stdio MCP server 子进程,拉取工具并注册。""" """启动 stdio MCP server 子进程,拉取工具并注册。"""
return await mcp_service.start_mcp_server( return await mcp_service.start_mcp_server(
server_id, server_id,
runtime=runtime,
process_manager=get_process_manager(), process_manager=get_process_manager(),
) )
@router.post( @router.post(
"/{server_id}/stop", "/{server_id}/stop",
response_model=McpServerStatusResponse) response_model=McpServerStatusResponse,
)
async def stop_mcp_server( async def stop_mcp_server(
server_id: str) -> McpServerStatusResponse: server_id: str,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> McpServerStatusResponse:
"""停止 stdio MCP server 子进程,注销工具。""" """停止 stdio MCP server 子进程,注销工具。"""
return await mcp_service.stop_mcp_server( return await mcp_service.stop_mcp_server(
server_id, server_id,
runtime=runtime,
process_manager=get_process_manager(), process_manager=get_process_manager(),
) )
@router.post( @router.post(
"/{server_id}/restart", "/{server_id}/restart",
response_model=McpServerStatusResponse) response_model=McpServerStatusResponse,
)
async def restart_mcp_server( async def restart_mcp_server(
server_id: str) -> McpServerStatusResponse: server_id: str,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> McpServerStatusResponse:
"""重启 stdio MCP server。""" """重启 stdio MCP server。"""
return await mcp_service.restart_mcp_server( return await mcp_service.restart_mcp_server(
server_id, server_id,
runtime=runtime,
process_manager=get_process_manager(), process_manager=get_process_manager(),
) )
@router.get( @router.get(
"/{server_id}/status", "/{server_id}/status",
response_model=McpServerStatusResponse) response_model=McpServerStatusResponse,
)
async def get_mcp_server_status( async def get_mcp_server_status(
server_id: str) -> McpServerStatusResponse: server_id: str,
) -> McpServerStatusResponse:
"""查询 MCP server 进程状态。""" """查询 MCP server 进程状态。"""
return await mcp_service.get_mcp_server_status( return await mcp_service.get_mcp_server_status(
server_id, server_id,
@@ -130,11 +159,15 @@ async def get_mcp_server_status(
@router.post( @router.post(
"/{server_id}/refresh", "/{server_id}/refresh",
response_model=McpServerResponse) response_model=McpServerResponse,
)
async def refresh_mcp_server_tools( async def refresh_mcp_server_tools(
server_id: str) -> McpServerResponse: server_id: str,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> McpServerResponse:
"""重新拉取 MCP server 工具清单并注册到运行时。""" """重新拉取 MCP server 工具清单并注册到运行时。"""
return await mcp_service.refresh_mcp_server_tools( return await mcp_service.refresh_mcp_server_tools(
server_id, server_id,
runtime=runtime,
process_manager=get_process_manager(), process_manager=get_process_manager(),
) )
-71
View File
@@ -1,71 +0,0 @@
"""用户面 API(神识阁数据源):BYOK 密钥管理与用量查询。
灵台是神识唯一调用方:本端点鉴权后以 ``X-Hunjing-User-Id`` 代理转发;
密钥明文只进不出(登记请求体 → 神识加密落库,响应永远脱敏)。
"""
from fastapi import APIRouter, Depends, Request, status
from app.api.v1.deps import get_current_user
from app.core.errors import AppError
from app.kernel import Kernel
from app.schemas.auth import CurrentUser
from app.schemas.me import (
ByokKeyListResponse,
MyUsageResponse,
RegisterByokKeyRequest,
)
from app.shenshi_client.client import ShenshiClient
from app.shenshi_client.types import ShenshiError
router = APIRouter(prefix="/me", tags=["me"])
def _shenshi(request: Request) -> ShenshiClient:
kernel: Kernel | None = getattr(request.app.state, "kernel", None)
if kernel is None:
raise AppError("service_unavailable", "Kernel 未就绪。", status_code=503)
return kernel.shenshi
@router.get("/byok-keys", response_model=ByokKeyListResponse)
async def list_byok_keys(
request: Request, user: CurrentUser = Depends(get_current_user)
) -> ByokKeyListResponse:
keys = await _shenshi(request).list_byok_keys(user.owner_scope)
return ByokKeyListResponse(keys=keys)
@router.post("/byok-keys", status_code=status.HTTP_201_CREATED)
async def register_byok_key(
payload: RegisterByokKeyRequest,
request: Request,
user: CurrentUser = Depends(get_current_user),
) -> dict:
key = await _shenshi(request).register_byok_key(
user.owner_scope,
name=payload.name,
type=payload.type,
base_url=payload.base_url,
api_key=payload.api_key,
model=payload.model,
)
return key
@router.delete("/byok-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_byok_key(
key_id: str, request: Request, user: CurrentUser = Depends(get_current_user)
) -> None:
try:
await _shenshi(request).revoke_byok_key(user.owner_scope, key_id)
except ShenshiError as exc:
raise AppError(exc.code, exc.message, status_code=404) from None
@router.get("/usage", response_model=MyUsageResponse)
async def my_usage(
request: Request, user: CurrentUser = Depends(get_current_user)
) -> MyUsageResponse:
data = await _shenshi(request).get_my_usage(user.owner_scope)
return MyUsageResponse(rows=data.get("rows", []), quota=data.get("quota"))
+73
View File
@@ -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)
@@ -1,240 +0,0 @@
"""镜树 API(api-contracts.md §3.3)。树级操作挂 /trees,节点级挂 /nodes 与 /citations。"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.core.errors import AppError
from app.domains import mirror_tree
from app.kernel import Kernel
from app.schemas.auth import CurrentUser
router = APIRouter(prefix="/trees", tags=["mirror-tree"])
node_router = APIRouter(prefix="/nodes", tags=["mirror-tree"])
class TreeCreate(BaseModel):
character_id: str
title: str = Field(min_length=1, max_length=300)
default_realm: str = "reality"
class TreePatch(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=300)
status: str | None = Field(default=None, description="active / archived")
class QuestionCreate(BaseModel):
content: str = Field(min_length=1, max_length=20_000)
realm: str | None = None
parent_answer_id: str | None = None
citations: list[dict] = Field(default_factory=list)
class AnswerRequest(BaseModel):
instructions: str | None = Field(default=None, max_length=8000)
class AdoptRequest(BaseModel):
node_id: str
class CanonizeRequest(BaseModel):
endpoint_node_id: str
canonical_scope: str
world_id: str | None = None
class EditForkRequest(BaseModel):
new_content: str = Field(min_length=1, max_length=20_000)
class TransplantRequest(BaseModel):
node_id: str
new_title: str = Field(min_length=1, max_length=300)
class CitationCreate(BaseModel):
target_type: str = "text_range"
locator: dict = Field(default_factory=dict)
snapshot: str = ""
source_id: str = Field(min_length=1, max_length=200)
source_version: int = 1
def _get_kernel(request: Request) -> Kernel:
kernel: Kernel | None = getattr(request.app.state, "kernel", None)
if kernel is None:
raise AppError("service_unavailable", "Kernel 运行时未就绪。", status_code=503)
return kernel
@router.get("")
async def list_trees_api(
character_id: str | None = Query(default=None),
user: CurrentUser = Depends(get_current_user),
) -> list[dict]:
return [mirror_tree.tree_view(t) for t in await mirror_tree.list_trees(user.owner_scope, character_id)]
@router.post("", status_code=201)
async def create_tree_api(
payload: TreeCreate, user: CurrentUser = Depends(get_current_user)
) -> dict:
tree = await mirror_tree.create_tree(
character_id=payload.character_id, owner_scope=user.owner_scope, title=payload.title,
default_realm=payload.default_realm,
)
return mirror_tree.tree_view(tree)
@router.get("/{tree_id}")
async def get_tree_api(tree_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
return await mirror_tree.get_tree_view(tree_id, user.owner_scope)
@router.patch("/{tree_id}")
async def patch_tree_api(
tree_id: str, payload: TreePatch, user: CurrentUser = Depends(get_current_user)
) -> dict:
if payload.status == "archived":
return await mirror_tree.archive_tree(tree_id, user.owner_scope)
if payload.title is not None:
return await mirror_tree.rename_tree(tree_id, user.owner_scope, payload.title)
return await mirror_tree.get_tree_view(tree_id, user.owner_scope)
@router.get("/{tree_id}/path")
async def tree_path_api(
tree_id: str,
entry: str | None = Query(default=None),
user: CurrentUser = Depends(get_current_user),
) -> dict:
return await mirror_tree.path_view(tree_id, user.owner_scope, entry)
@router.get("/{tree_id}/local-graph")
async def local_graph_api(
tree_id: str,
node: str | None = Query(default=None),
depth: int = Query(default=2, ge=0, le=6),
user: CurrentUser = Depends(get_current_user),
) -> dict:
return await mirror_tree.local_graph(tree_id, user.owner_scope, node, depth)
@router.post("/{tree_id}/nodes", status_code=201)
async def create_question_api(
tree_id: str, payload: QuestionCreate, user: CurrentUser = Depends(get_current_user)
) -> dict:
node = await mirror_tree.create_question(
tree_id=tree_id,
owner_scope=user.owner_scope,
content=payload.content,
realm=payload.realm,
parent_answer_id=payload.parent_answer_id,
citations=payload.citations,
)
return mirror_tree.node_view(node)
@router.post("/{tree_id}/adopt")
async def adopt_api(
tree_id: str, payload: AdoptRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await mirror_tree.adopt(tree_id=tree_id, owner_scope=user.owner_scope, node_id=payload.node_id)
@router.post("/{tree_id}/canonize")
async def canonize_api(
tree_id: str, payload: CanonizeRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await mirror_tree.canonize(
tree_id=tree_id,
owner_scope=user.owner_scope,
endpoint_node_id=payload.endpoint_node_id,
scope=payload.canonical_scope,
world_id=payload.world_id,
)
@router.post("/{tree_id}/transplant", status_code=201)
async def transplant_api(
tree_id: str, payload: TransplantRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await mirror_tree.transplant(
tree_id=tree_id, owner_scope=user.owner_scope, node_id=payload.node_id,
new_title=payload.new_title,
)
# ── 节点级 ──────────────────────────────────────────────
@node_router.post("/{node_id}/answers", status_code=202)
async def spawn_answer_api(
node_id: str,
payload: AnswerRequest,
request: Request,
user: CurrentUser = Depends(get_current_user),
) -> dict:
result = await mirror_tree.spawn_answer(
question_id=node_id,
owner_scope=user.owner_scope,
kernel=_get_kernel(request),
instructions=payload.instructions,
)
return result
@node_router.post("/{node_id}/continue", status_code=202)
async def continue_api(
node_id: str,
payload: AnswerRequest,
request: Request,
user: CurrentUser = Depends(get_current_user),
) -> dict:
return await mirror_tree.continue_node(
node_id=node_id, owner_scope=user.owner_scope, kernel=_get_kernel(request),
instructions=payload.instructions,
)
@node_router.post("/{node_id}/resume", status_code=202)
async def resume_api(
node_id: str,
payload: AnswerRequest,
request: Request,
user: CurrentUser = Depends(get_current_user),
) -> dict:
return await mirror_tree.resume_node(
node_id=node_id, owner_scope=user.owner_scope, kernel=_get_kernel(request),
instructions=payload.instructions,
)
@node_router.post("/{node_id}/edit-fork", status_code=201)
async def edit_fork_api(
node_id: str, payload: EditForkRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await mirror_tree.edit_fork(
node_id=node_id, owner_scope=user.owner_scope, new_content=payload.new_content
)
@node_router.post("/{node_id}/citations", status_code=201)
async def create_citation_api(
node_id: str, payload: CitationCreate, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await mirror_tree.create_citation(
node_id=node_id, owner_scope=user.owner_scope, data=payload.model_dump()
)
@node_router.get("/{node_id}/citations")
async def list_citations_api(
node_id: str, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
return await mirror_tree.list_citations(node_id, user.owner_scope)
+122
View File
@@ -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
)
+111 -4
View File
@@ -1,23 +1,32 @@
"""Run 与 SSE API(Run 的启动契约见 kernel_runs.py)。""" """Run 与 SSE API。"""
import asyncio import asyncio
import json import json
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Any from typing import Any
from fastapi import APIRouter, Header, Query from fastapi import APIRouter, Header, Query, Request
from sse_starlette.sse import EventSourceResponse 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.config import settings
from app.core.errors import AppError from app.core.errors import AppError
from app.core.time import to_iso, utc_now from app.core.time import to_iso, utc_now
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.repositories import runtime as runtime_repo from app.repositories import runtime as runtime_repo
from app.schemas.common import Page from app.schemas.common import Page
from app.schemas.runtime import EventResponse, RunResponse from app.schemas.runtime import (
AgentControlRequest,
AgentControlResponse,
ConfirmationResolutionResponse,
EventResponse,
RunResponse,
)
from app.services.event_bus import bus from app.services.event_bus import bus
from app.services.runtime import ( from app.services.runtime import (
cancel_run, cancel_run,
confirmation_exists_in_run,
get_run_response, get_run_response,
list_run_event_responses, list_run_event_responses,
list_runs_response, list_runs_response,
@@ -29,6 +38,7 @@ router = APIRouter(prefix="/runs", tags=["runs"])
@router.get("", response_model=Page[RunResponse]) @router.get("", response_model=Page[RunResponse])
async def list_runs( async def list_runs(
character_id: str | None = Query(default=None, description="按人物过滤。"), character_id: str | None = Query(default=None, description="按人物过滤。"),
session_id: str | None = Query(default=None, description="按会话过滤。"),
status: str | None = Query(default=None, description="按状态过滤。"), status: str | None = Query(default=None, description="按状态过滤。"),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100), page_size: int = Query(default=20, ge=1, le=100),
@@ -36,6 +46,7 @@ async def list_runs(
"""Run 列表(按创建时间倒序);归档页运行时间河数据源。""" """Run 列表(按创建时间倒序);归档页运行时间河数据源。"""
return await list_runs_response( return await list_runs_response(
character_id=character_id, character_id=character_id,
session_id=session_id,
status=status, status=status,
page=page, page=page,
page_size=page_size, page_size=page_size,
@@ -57,11 +68,107 @@ async def list_run_events(
return await list_run_event_responses(run_id, limit=limit, offset=offset) 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) @router.post("/{run_id}/cancel", response_model=RunResponse)
async def cancel_run_api(run_id: str) -> 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) 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") @router.get("/{run_id}/stream")
async def stream_run( async def stream_run(
run_id: str, run_id: str,
+139 -7
View File
@@ -1,30 +1,162 @@
"""安全 API(§16):审计时间线查询。 """安全 API(api-spec §16)。
工具策略治理与确认流已由百工域承接(/capabilities /mandates /petitions); audit-events 按人物/枢过滤查询审计时间线;confirmations 的 approve/deny 负责
本路由保留 audit-events(行动账时间线)。 高风险工具确认:通过 ConfirmationRegistry 更新工具确认状态唤醒 Agent 循环。
""" """
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from app.api.v1.deps import get_current_user 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.admin import AuditEntry
from app.schemas.auth import CurrentUser
from app.schemas.common import Page 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 audit as audit_service
from app.services import governance as governance_service
router = APIRouter(prefix="/safety", tags=["safety"]) router = APIRouter(prefix="/safety", tags=["safety"])
# —— §16 审计事件查询 ——
@router.get("/audit-events", response_model=Page[AuditEntry]) @router.get("/audit-events", response_model=Page[AuditEntry])
async def list_audit_events( async def list_audit_events(
character_id: str | None = Query(default=None, description="按人物过滤。"), character_id: str | None = Query(default=None, description="按人物过滤。"),
pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"), pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200), page_size: int = Query(default=50, ge=1, le=200),
_user: CurrentUser = Depends(get_current_user),
) -> Page[AuditEntry]: ) -> Page[AuditEntry]:
return await audit_service.query_audit_events( return await audit_service.query_audit_events(
character_id=character_id, pivot=pivot, page=page, page_size=page_size 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}
+8 -11
View File
@@ -22,9 +22,8 @@ import mimetypes
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from starlette.responses import Response from starlette.responses import Response
from app.api.v1.deps import get_current_user, get_sandbox_client from app.api.v1.deps import get_current_user, get_tool_runtime
from app.domains.baigong import files as baigong_files from app.application.tool_runtime.runtime import ToolRuntime
from app.integrations.sandbox.client import SandboxClient
from app.integrations.sandbox.exceptions import SandboxError, SandboxNotFound from app.integrations.sandbox.exceptions import SandboxError, SandboxNotFound
from app.schemas.auth import CurrentUser from app.schemas.auth import CurrentUser
@@ -55,7 +54,7 @@ async def read_sandbox_file(
"write_file 写入的文件在 workspace/<character_id>/ 下," "write_file 写入的文件在 workspace/<character_id>/ 下,"
"传递 write_file 的 path 时保持 True 即可。", "传递 write_file 的 path 时保持 True 即可。",
), ),
sandbox: SandboxClient = Depends(get_sandbox_client), runtime: ToolRuntime = Depends(get_tool_runtime),
_current_user: CurrentUser = Depends(get_current_user), _current_user: CurrentUser = Depends(get_current_user),
) -> Response: ) -> Response:
"""读取沙箱内文件,以原始字节返回(MIME 类型由路径扩展名推导)。 """读取沙箱内文件,以原始字节返回(MIME 类型由路径扩展名推导)。
@@ -77,9 +76,8 @@ async def read_sandbox_file(
# 2) 从沙箱读取 # 2) 从沙箱读取
try: try:
result = await baigong_files.read_sandbox_file( result = await runtime.read_sandbox_file(
sandbox, path=path,
path,
run_id=run_id, run_id=run_id,
character_id=character_id, character_id=character_id,
isolated=isolated, isolated=isolated,
@@ -119,7 +117,7 @@ async def list_sandbox_files(
description="人物 ID。提供时自动应用 isolated_workspace 隔离前缀。", description="人物 ID。提供时自动应用 isolated_workspace 隔离前缀。",
), ),
isolated: bool = Query(True, description="是否自动补全隔离前缀"), isolated: bool = Query(True, description="是否自动补全隔离前缀"),
sandbox: SandboxClient = Depends(get_sandbox_client), runtime: ToolRuntime = Depends(get_tool_runtime),
_current_user: CurrentUser = Depends(get_current_user), _current_user: CurrentUser = Depends(get_current_user),
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""列出沙箱工作区目录内容。 """列出沙箱工作区目录内容。
@@ -128,9 +126,8 @@ async def list_sandbox_files(
适用于前端文件浏览器导航。 适用于前端文件浏览器导航。
""" """
try: try:
entries = await baigong_files.list_sandbox_dir( entries = await runtime.list_sandbox_dir(
sandbox, path=path,
path,
run_id=run_id, run_id=run_id,
character_id=character_id, character_id=character_id,
isolated=isolated, isolated=isolated,
+38 -3
View File
@@ -1,25 +1,60 @@
"""设置 API(生成偏好)。 """设置 API(模型网关状态 + 生成偏好)。
生成偏好可读可写;模型网关状态由神识承接(用户面见神识阁)。 对应前端 settingsApi。模型网关为只读总览,生成偏好可读可写;
业务逻辑在 services/preferences(与系统配置 core/config/settings 区分)。 真实密钥不进入响应。业务逻辑在 services/preferences(与系统配置 core/config/settings 区分)。
""" """
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from app.api.v1.deps import get_current_user from app.api.v1.deps import get_current_user
from app.core.errors import AppError
from app.schemas.auth import CurrentUser from app.schemas.auth import CurrentUser
from app.schemas.preferences import ( from app.schemas.preferences import (
GenerationPreferencesResponse, GenerationPreferencesResponse,
ModelEndpointStatus,
ModelGatewayStatus,
UpdateEndpointOverrideRequest,
UpdateGenerationPreferencesRequest, UpdateGenerationPreferencesRequest,
) )
from app.services.ai_provider_admin import update_endpoint_override
from app.services.preferences import ( from app.services.preferences import (
get_generation_preferences, get_generation_preferences,
get_provider_gateway,
update_generation_preferences, update_generation_preferences,
) )
router = APIRouter(prefix="/settings", tags=["settings"]) 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) @router.get("/preferences", response_model=GenerationPreferencesResponse)
async def read_generation_preferences( async def read_generation_preferences(
current_user: CurrentUser = Depends(get_current_user), current_user: CurrentUser = Depends(get_current_user),
+54
View File
@@ -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)
-72
View File
@@ -1,72 +0,0 @@
"""道体铸魂与分灵 API(/characters/{id}/soul)。"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains.jingsheng import soul
from app.schemas.auth import CurrentUser
soul_router = APIRouter(prefix="/characters/{character_id}/soul", tags=["jingsheng"])
class ForgingRequest(BaseModel):
anchor_changes: dict = Field(description="道体锚点变更(键:identity_baseline_json)")
reason: str = Field(min_length=1, max_length=2000)
class SplitRequest(BaseModel):
reason: str = Field(min_length=1, max_length=2000)
initial_anchor: dict = Field(default_factory=dict)
@soul_router.get("/forgings")
async def list_forgings(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
return await soul.list_forgings(character_id, user.owner_scope)
@soul_router.post("/forgings", status_code=201)
async def propose_forging(
character_id: str, payload: ForgingRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await soul.propose_forging(
character_id=character_id,
owner_scope=user.owner_scope,
anchor_changes=payload.anchor_changes,
reason=payload.reason,
)
@soul_router.post("/forgings/{forging_id}/confirm")
async def confirm_forging(
character_id: str, forging_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await soul.confirm_forging(forging_id, user.owner_scope)
@soul_router.post("/forgings/{forging_id}/reject")
async def reject_forging(
character_id: str, forging_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await soul.reject_forging(forging_id, user.owner_scope)
@soul_router.post("/split", status_code=201)
async def split_soul(
character_id: str, payload: SplitRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await soul.split_soul(
character_id=character_id,
owner_scope=user.owner_scope,
reason=payload.reason,
initial_anchor=payload.initial_anchor,
)
@soul_router.get("/lineage")
async def get_lineage(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
return await soul.get_lineage(character_id, user.owner_scope)
-5
View File
@@ -1,5 +0,0 @@
"""天工阁路由(域实现见 app/domains/tiangong/api.py)。"""
from app.domains.tiangong.api import task_router
__all__ = ["task_router"]
-5
View File
@@ -1,5 +0,0 @@
"""器谱路由(域实现见 app/domains/tianzai/api.py)。"""
from app.domains.tianzai.api import device_router
__all__ = ["device_router"]
+113
View File
@@ -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,
)
+241 -26
View File
@@ -1,37 +1,252 @@
"""工具 API(§15 兼容面):清单走百工能力目录,调用走 /capabilities/{id}/invoke。 """工具调用 API(api-spec §15)。
旧 ToolRuntime/策略治理端点已随 M5 百工重写退役;本路由只保留 - GET /tools:列出已注册工具策略元数据(从 tool_policies 表,含 executable 派生)。
`/tools`(能力目录投影)与 `/tools/calls/{id}`(一炁事件详情)。 - 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 fastapi import APIRouter, Depends from __future__ import annotations
from app.api.v1.deps import get_current_user import json
from app.domains.baigong import service as baigong from typing import Any
from app.schemas.auth import CurrentUser
from app.schemas.safety import ToolCallDetail from fastapi import APIRouter, Depends, Response, status
from app.services.runtime import get_tool_call_detail 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"]) router = APIRouter(prefix="/tools", tags=["tools"])
@router.get("") class InvokeRequest(BaseModel):
async def list_tools(user: CurrentUser = Depends(get_current_user)) -> list[dict]: input: dict[str, Any]
"""能力目录投影(新链路以 /capabilities 为准;此处给旧消费方过渡读取)。""" run_id: str | None = None
caps = await baigong.list_capabilities(user.owner_scope) character_id: str | None = None
return [ trace_id: str | None = None
{ confirmed: bool = False
"name": c["name"],
"description": c["description"],
"risk_level": max(c["risk_dims"].values(), default="L1"), class ToolResultPayload(BaseModel):
"permission_policy": "ask" if c["consent_mode"] == "ask" else "allow", ok: bool
"enabled": c["enabled"], exit_code: int | None = None
"source": c["source"], stdout: str = ""
} stderr: str = ""
for c in caps 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) @router.get("/calls/{tool_call_id}", response_model=ToolCallDetail)
async def get_tool_call_detail(tool_call_id: str) -> ToolCallDetail: async def get_tool_call(tool_call_id: str) -> ToolCallDetail:
return await get_tool_call_detail(tool_call_id) 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())
+95
View File
@@ -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)
-5
View File
@@ -1,5 +0,0 @@
"""形仪路由(域实现见 app/domains/xingyi/api.py)。"""
from app.domains.xingyi.api import form_router
__all__ = ["form_router"]
-157
View File
@@ -1,157 +0,0 @@
"""息壤(记忆域)API(api-contracts §3.5)。"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from app.api.v1.deps import get_current_user
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.domains import xirang
from app.models.character import Character
from app.models.xirang import FactualRecord, Memory
from app.schemas.auth import CurrentUser
router = APIRouter(tags=["xirang"])
class MemorizeRequest(BaseModel):
label: str = Field(description="fact / insight / opinion / hypothesis")
class CorrectRequest(BaseModel):
new_content: str = Field(min_length=1, max_length=8000)
def _memory_view(m: Memory, with_content: bool = True) -> dict:
return {
"id": m.id,
"character_id": m.character_id,
"tier": m.tier,
"type": m.type,
"status": m.status,
"source": m.source,
"content": m.content if with_content else "",
"source_refs": json.loads(m.source_refs_json or "[]"),
"sensitivity": m.sensitivity,
"priority": m.priority,
"confidence": m.confidence,
"vitality": round(m.vitality, 3),
"realm": m.realm,
"prev_version_id": m.prev_version_id,
"recall_count": m.recall_count,
"last_recalled_at": m.last_recalled_at.isoformat() if m.last_recalled_at else None,
"created_at": m.created_at.isoformat(),
}
async def _owned_character(character_id: str, owner_scope: str) -> Character:
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
return character
@router.get("/characters/{character_id}/memories")
async def list_memories(
character_id: str,
tier: str | None = Query(default=None),
status: str | None = Query(default=None),
type_: str | None = Query(default=None, alias="type"),
user: CurrentUser = Depends(get_current_user),
) -> list[dict]:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
stmt = select(Memory).where(Memory.character_id == character_id)
if tier:
stmt = stmt.where(Memory.tier == tier)
if status:
stmt = stmt.where(Memory.status == status)
if type_:
stmt = stmt.where(Memory.type == type_)
stmt = stmt.order_by(Memory.created_at.desc()).limit(200)
rows = (await session.execute(stmt)).scalars().all()
# 高敏不展示原文(sensitivity=high 摘要化);墓碑不承载原文
return [
_memory_view(
m,
with_content=not (m.sensitivity == "high" or m.status == "tombstoned"),
)
for m in rows
]
@router.get("/memories/{memory_id}")
async def get_memory(memory_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
async with AsyncSessionLocal() as session:
memory = await session.get(Memory, memory_id)
if memory is not None:
character = await session.get(Character, memory.character_id)
if memory is None or character is None or character.owner_scope != user.owner_scope:
raise AppError("not_found", "记忆不存在。")
return _memory_view(memory)
@router.post("/memories/{memory_id}/confirm")
async def confirm_memory_api(memory_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
memory = await xirang.confirm_memory(memory_id, user.owner_scope)
return _memory_view(memory)
@router.post("/memories/{memory_id}/deny")
async def deny_memory_api(memory_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
return await xirang.deny_memory(memory_id, user.owner_scope)
@router.post("/memories/{memory_id}/correct")
async def correct_memory_api(
memory_id: str, payload: CorrectRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
memory = await xirang.correct_memory(memory_id, user.owner_scope, payload.new_content)
return _memory_view(memory)
@router.delete("/memories/{memory_id}", status_code=204)
async def delete_memory_api(memory_id: str, user: CurrentUser = Depends(get_current_user)) -> None:
await xirang.delete_memory(memory_id, user.owner_scope)
@router.post("/nodes/{node_id}/memorize", status_code=201)
async def memorize_node_api(
node_id: str, payload: MemorizeRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
memory = await xirang.memorize_node(
node_id=node_id, owner_scope=user.owner_scope, label=payload.label
)
return _memory_view(memory)
@router.get("/characters/{character_id}/factual-records")
async def list_factual_records(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(FactualRecord)
.where(FactualRecord.character_id == character_id)
.order_by(FactualRecord.occurred_at.desc())
.limit(100)
)
).scalars().all()
return [
{
"id": r.id,
"canonization_id": r.canonization_id,
"summary": r.summary,
"evidence": json.loads(r.evidence_json or "{}"),
"occurred_at": r.occurred_at.isoformat(),
}
for r in rows
]
-5
View File
@@ -1,5 +0,0 @@
"""缘契路由(域实现见 app/domains/yuanqi/api.py)。"""
from app.domains.yuanqi.api import cov_router
__all__ = ["cov_router"]
+82
View File
@@ -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",
]
+303
View File
@@ -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"]
+229
View File
@@ -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
Loaded 100 of 747 files, more files were not shown because too many files have changed in this diff. Show more