Compare commits

..
14 Commits
Author SHA1 Message Date
chenxin dfce60f7db ✨ feat: WP-06 工具域——注册表、四维裁决、审批与 RunBudget
- 迁移 10:tool(注册表:风险级/审批声明/输入 Schema)、tool_call(六态含 pending_approval、幂等唯一约束)、run.max_tool_calls(RunBudget 硬上限)
- 内置工具三件:get-current-time/calculate(AST 白名单四则,只读循律)、record-note(真实副作用:写入记忆候选,必须请命)
- 四维裁决(能力/数据/后果/情境→autonomy_level):read_only 循律直接执行;side_effect/high_impact 进入 pending_approval 请命;禁用工具 forbidden
- 审批流(朱批):approve → 执行并落审计;deny → 拒绝且不可逆(重复审批 409);副作用工具产物走既有记忆审核通道
- 工具入参按不可信模型输出处理:JSON Schema 子集校验(required/type/未知字段拒绝);RunBudget 超限 422 不静默放行
- 幂等:同 (run, tool, Idempotency-Key) 重放返回 200 原调用
- 测试 67 项全绿(+7):循律即执行、请命→朱批→真实候选产物、拒绝不可逆与不执行、Schema 三类拒绝、未注册 404、幂等重放、预算硬上限
2026-09-11 11:29:43 +08:00
chenxin e333dc5ea5 ✨ feat: WP-04 记忆与知识——候选版本闭环、检索管线与大周天(ADR-011/018/023)
- 迁移 9:memory_candidate(来源/敏感度/作用域/状态检查约束)、memory(resource_version 作 ETag 源、有效期、当前版本指针)、memory_version((memory_id, version) 唯一、取代链)、big_cycle(cycle_ 聚合根、phase_memory、产物版本引用)
- 候选闭环:手动提交;白名单自动批准仅限 auto+fact+低敏感(撤销窗 72h 记录于审计);敏感/高影响默认人工审核;批准同事务写 Memory+Version+Audit+Outbox(契约 4.4)
- 纠正:If-Match 校验 resource_version(缺失/过期 412),创建新版本不原地覆盖(ADR-011)
- 删除:立即阻断召回 + 清除全部版本原文(墓碑不存被删原文,契约 4.4)
- 检索管线:数据库层域/角色预过滤 → 状态/有效期过滤 → 文本匹配 → 响应级召回证据(语义检索待 embedding 端点)
- 大周天最小实现:手动触发 → 后台四阶段(回顾/对照/提议/提交)→ 模型真实评估产出 pending persona_candidate(候选先于写入);无端点 fail-closed
- 测试 60 项全绿(+6):审批流、白名单三例、ETag 正/缺失/过期、检索隔离与召回证据、删除传播与墓碑原文清除、大周天端到端
2026-09-11 10:54:20 +08:00
chenxin b4242f3844 ✨ feat: WP-05 模型网关核心——逻辑模型、流式适配与 Run 真实执行
- 迁移 7+8:logical_model(能力目录)、provider_endpoint(密钥只存环境变量名,永不落库)、turn、model_call(每 attempt 单独持久化:usage/TTFT/错误,Provider 未返回保持 null 不估算——ADR-003);message.run_id 关联
- OpenAI-compatible 流式适配器:httpx SSE 解析、标准化 StreamChunk(delta/usage/done)、transport 可注入(测试与故障注入);密钥经环境变量读取不进日志
- Run 执行器:后台执行完整生命周期——running → content.block.started → 真实 content.block.delta*(流式产生,delta_index 单调)→ content.block.completed → message.completed → 终态;assistant 消息为权威快照;ModelCall 记录 usage 与 TTFT;无可用端点时 Run 显式失败并留 error 事件(fail-closed)
- post_message:响应提交后由框架 BackgroundTasks 执行 Run(显式预提交保证 Run 落库);SSE 改为增量轮询(重放 + 等待新事件至终态,30s 上限)
- 测试 54 项全绿(+2):端到端 mock 流验证 ModelCall usage/TTFT/assistant 权威消息、无端点 Run failed + content.block.failed 事件
2026-09-11 09:44:54 +08:00
chenxin 40221170ae ✨ feat: WP-02 角色域——完整生命周期、铸魂、内驱力与随缘
- 迁移 6:character(生命周期六态检查约束、起源、现实人物治理标记、道体基线、内驱力)+ character_version(baseline/first_statement/drive_revision/reforging/persona_candidate 五类不可变版本与审核态)
- 创建:手动与随缘(确定性词库组合,同种子同候选、一次一个——不是抽卡);现实人物治理(已故拒绝、未声明授权拒绝)
- 启镜(draft→active + 首个 baseline 版本)与初照自陈(候选→确认归真并入道体)
- 生命周期端点:静修/归藏/复照(归档可恢复、复照不抹历史)+ 死亡两步确认(7 天冷静期可撤销、未满确认 422);非法状态迁移 422
- 铸魂(显式 confirm 强确认,直接更新道体)与人格候选(pending→审核批准才并入道体,不静默覆盖)
- 内驱力编辑:写当前值并落 drive_revision 版本(可追溯,ADR-018)
- 测试 52 项全绿(+8):状态机全路径、随缘确定性、现实人物三例、铸魂确认、候选审核与重复审核 409、跨域 fail-closed
2026-09-10 18:11:15 +08:00
chenxin 603a683c14 ✨ feat: WP-03 三界归真作用域与消息版本谱系(ADR-020 落地)
- 迁移 5:conversation.realm(默认真界,检查约束);message.realm 快照、canonized_at、parent_message_id 自引用与 variant_kind(origin/revision/candidate)
- 界域端点:GET conversation(realm/当前分支)与 POST realm(入境/出境显式切换,留审计——ADR-020 切换必须可见)
- 归真端点 POST /v1/messages/{id}/canonizations:scope 必须与消息界域一致(不一致 422);幂等(重复归真 200 原状态);境界归真产出 world.canonized Outbox 事件(世界知识候选入口,WP-04 消费);幻境归真仅标记试演记录
- 编辑版本 POST /v1/messages/{id}/revisions:新消息 variant=revision 引用原消息(markdown 修订同样经灵台引擎规范化),原消息永不修改
- 测试 44 项全绿(+8):界域快照、归真正/反/幂等、幻境试演、revision 原消息不变、跨域 401/403/404 fail-closed
2026-09-10 13:28:25 +08:00
chenxin 37c53862c4 ✨ feat: WP-03 主体——ContentBlock 全类型、Run 事件重放、分页与分支(WP-03)
- ContentBlock 全 21 类型 Pydantic 判别联合(与 openapi 契约一一对齐,extra=forbid);系统产生类 block(tool_call/tool_result/agent_status/approval_request/artifact)拒绝客户端伪造(契约 3.5)
- 迁移 4:run_event 表((run_id, sequence) 唯一、事件类型检查约束)+ conversation.current_branch_id
- Run 事件持久化:状态事件、每 block completed、message.completed 与权威状态同事务写入;sequence 单调从 0;SSE 改为重放持久化事件(不再现场构造),delta/progress 留待真实模型流(WP-05),不伪造
- GET messages:opaque cursor 分页((created_at,id) 复合键、base64 编码、limit 1-200、has_more)
- branches:列表 + 当前路径切换(采用≠归真);跨会话/不存在分支 422 fail-closed
- 测试 36 项全绿:21 类型接受、runtime block 拒绝、事件重放序号单调、分页无重叠、cursor 非法拒绝、分支切换与跨域
2026-09-10 13:17:34 +08:00
chenxin 491136c770 ✨ feat: 灵台引擎 Python 绑定与消息路径接入(WP-15/WP-03 交界)
- engine/bindings:nanobind 官方薄绑定包 hunjing-lingtai(LingtaiEngine RAII、HjStatus→LingtaiError 映射、HJ_ABI_VERSION 导出);scikit-build-core 构建,直接编译引擎源码自包含
- backend 以 editable 路径依赖接入;typings/lingtai.pyi 类型存根(mypy strict 全绿)
- app/core/lingtai.py:懒初始化单例 + LingtaiUnavailable fail-closed(禁止宿主第二实现降级)
- 迁移 3:message.parser_version;post_message 的 markdown 输入经 hj_transform 规范化为 canonical blocks(展开 code/mermaid/math 并标注 parser_version,契约第 4 节)
- 测试 29 项全绿:消息展开、Python/C++ golden 字节一致、引擎注入失败时消息路径显式 500(fail-closed 验证)
2026-09-10 11:26:40 +08:00
chenxin 127191c889 ✨ feat: 灵台引擎脾神(hj_transform)确定性 Markdown 解析器(WP-15)
- C ABI 新增 hj_transform_version / hj_transform_markdown(契约登记同步);HjBuffer 真实实现(头部+紧随数据)
- v1 解析(hunjing-markdown-v1):fenced code/mermaid 提取、独占行 $$ 块级数学、未闭合结构保持源码态、其余归并 markdown block;canonical JSON 键序确定、UTF-8 校验、1MiB/256 block/64KiB 行硬限额
- 引擎内部设施抽取(engine_internal.h:last_error/句柄表/buffer 共享)
- doctest 12 个用例(含 1 万次随机鲁棒性冒烟,固定种子可复现);libFuzzer 目标(-DHJ_BUILD_FUZZER,Apple clang 无 fuzzer 运行时,正式跑在 Linux CI)
- nm 验证 9 个导出符号全部 hj_*
2026-09-10 11:01:45 +08:00
chenxin d3c8df79a8 🔧 chore: 本地 PostgreSQL 切换 pgvector/pg17 镜像
- 网络恢复后镜像拉取成功,替换临时 postgres:16-alpine
- 重建数据卷并重放全部迁移(12 表);vector 扩展已启用,为 WP-04 语义检索就绪
- 全量测试 26 项在新库重跑通过
2026-09-10 10:40:52 +08:00
chenxin ba502297ac ✨ feat: 阶段 0 收尾——认证边界、请求上下文与垂直切片(WP-01 第 5-7 步)
- 认证:Web Session(__Host- Cookie、HttpOnly/Secure/SameSite=Lax、CSPRNG opaque token 数据库只存哈希)、CSRF 双提交、Origin 校验、dev 登录/登出端点;未认证 401、缺 CSRF 403
- 中间件:Hunjing-Request-ID 回显/生成(request_ UUIDv4)+ W3C traceparent 校验透传;JSON 结构化日志与敏感头脱敏
- 迁移 2:conversation/branch/message/run/idempotency_record 五表(含检查约束与复合索引);Session 补 token/csrf 哈希列
- 垂直切片:POST /v1/conversations/{id}/messages 按 Idempotency-Key 幂等(同键同体 200 重放、异体 409),同事务原子写 Message+Run+Audit+Outbox+幂等记录;ContentBlock text/markdown 判别联合(未知类型 422)
- SSE:GET /v1/runs/{id}/events 返回快照与终止状态事件 envelope
- 测试 26 项全绿:IDOR 跨域 404 fail-closed、失败注入回滚无孤儿(Run 崩溃后 message/run/audit/outbox/幂等全零)、SSE 事件序、auth 边界;ruff/mypy strict 干净
- 修复真实问题:asyncpg 跨事件循环(anyio/pytest-asyncio 双 loop、模块级 engine 改为 app 实例持有)、httpx 拒存 http 下 Secure Cookie
2026-09-10 10:13:45 +08:00
chenxin 28127949db ✨ feat: 数据库基线与通用 DTO(WP-01 第 3、4 步)
- 通用 DTO:Money(最小货币单位整数)、CursorPage 泛型、9 个平台 ID Annotated 类型(前缀+UUIDv7/v4 正则,与 openapi 契约对齐)
- db 基础:naming_convention Base(pk_/uq_/ix_/fk_,AGENTS 8.6)+ async engine + 事务作用域 session
- 首批 6 张表 typed mapping:user/personal_domain/session/service_account/audit_event/outbox_event;Session ID 为 CSPRNG opaque;审计与 Outbox 不可变
- Alembic async 迁移(含表注释);docker 起 hunjing-postgres 并 upgrade 验证:7 表、约束/索引命名全部符合约定
- 按 database-design skill 清单交叉检查:外键索引全覆盖(复合唯一最左前缀)、审计聚合时间线与 Outbox 投递扫描均为专用复合索引
- 集成测试 8 项(唯一约束、FK restrict/cascade、检查约束、同事务提交)连 PG 测试库运行;pytest 15 项、ruff、mypy strict 全绿
2026-09-10 10:00:17 +08:00
chenxin 3fbcd0437c 🎉 feat: 建立阶段 0 工程骨架与 OpenAPI 契约机器化(WP-01 起步)
- Makefile:统一命令入口(backend/engine/docker/openapi/generate,含 make help)
- backend:FastAPI + uv 最小工程,统一错误体(契约第 7 节)、ADR-015 平台 ID 生成与校验(uuid-utils);pytest 7 项、ruff、mypy strict、启动冒烟全部通过
- engine:灵台引擎 C++23/CMake 骨架,按 Lingtai C ABI 契约实现 lingtai.h(13 个 HjStatus、版本协商、句柄表防二次释放、线程局部错误上下文);doctest 5 用例通过,nm 验证仅导出 hj_* 符号
- openapi:OpenAPI 3.1 单一真源初始版(错误体/分页/Money/首批平台 ID/ContentBlock 全 21 类型/流事件 envelope),Redocly lint 0 错误、bundle 通过
- frontend:Next.js 最小骨架,openapi-typescript 生成 API 类型并通过 strict tsc
- docker:hunjing-postgres(pgvector:17)compose;.env.example 与 config 示例
- ADR-025:登记测试与契约工具链(doctest/libFuzzer/Redocly/openapi-typescript/uuid-utils)
- desktop:Tauri v2 占位与初始化说明(WP-10 启动)
2026-09-09 21:45:04 +08:00
chenxin 5da1efe921 📝 docs: 统一 PostgreSQL 主库并固化索引命名约定
- 新增 ADR-024:服务端关系主库统一 PostgreSQL,不设 SQLite 档(pgvector 与中文全文无法在 SQLite 承载,双档必然残缺或第二实现);端侧离线缓存不受约束
- AGENTS.md:权威事实源与部署句更新;8.6 固化 pk_/uk_/idx_/fk_ 索引命名约定
- system-architecture、data-architecture、memory-and-knowledge、roadmap WP-01、handoff(决策 29)同步清理 SQLite 引用
2026-09-09 21:14:10 +08:00
chenxin 42513d13c6 📝 docs: 建立魂镜完整 1.0 设计文档体系
- 产品层:产品宪章与永久禁域、产品定义、完整 1.0 范围与发布门、道家文化语言体系、指标与反指标
- 研究层:参考项目研究(deepseek-harness、pi、MemOS、模型网关)、灵台原生引擎研究(业界原生引擎架构理念与道家文化出处)
- 架构层:系统、Agent 运行时(周天体系)、插件系统、灵台引擎(C++ 双形态)、媒体与设备、社区协作市场、前端富内容渲染、数据、记忆与知识
- 契约层:协议与稳定标识、API 总体约定、Native API、兼容网关、实时与 Webhook、内部端口、统一 ContentBlock 与流式(含分支对话交互态)、灵台 C ABI 与 gRPC Service、API 表面、HTTP 与事件、运行观测
- 决策:ADR-001 至 ADR-023(模块化单体、每 attempt 持久化、候选记忆、OAuth 2.1、灵台引擎、双形态部署、大周天演化、宪章、三界作用域、私箧与保全态、叩心、周天统一等)
- 交付:WP-01 至 WP-15 实施 TODO 与设计阶段交接文档
- 工程:AGENTS.md 工程规则(含 C++/灵台规范)、CLAUDE.md 引用、.gitignore
2026-09-09 21:07:14 +08:00
872 changed files with 23475 additions and 144313 deletions

No files matched your search

-82
View File
@@ -1,82 +0,0 @@
# ==============================
# Docker 构建忽略清单
# build context 为项目根(docker-compose.yml 中 context: ..),
# 本文件同时影响 backend 与 frontend 镜像构建。
# ==============================
# ----- 版本控制 -----
.git/
.gitignore
.gitattributes
# ----- Python / 后端 -----
backend/.venv/
backend/**/__pycache__/
backend/**/*.py[cod]
backend/.pytest_cache/
backend/.mypy_cache/
backend/.ruff_cache/
backend/.coverage
backend/coverage.xml
backend/htmlcov/
backend/dist/
backend/build/
backend/*.egg-info/
# ----- Node / 前端 -----
frontend/node_modules/
frontend/.pnpm-store/
frontend/.next/
frontend/out/
frontend/.vercel/
frontend/.turbo/
frontend/*.tsbuildinfo
frontend/.eslintcache
frontend/npm-debug.log*
frontend/pnpm-debug.log*
# ----- 运行时数据与本地存储(禁止进镜像) -----
db/*.sqlite
db/*.sqlite3
db/*.db
db/*.db-shm
db/*.db-wal
db/chroma/
storage/
uploads/
media/
objects/
logs/
*.log
*.pid
# ----- Rust / 安全沙箱 -----
sandbox/target/
sandbox/sandbox-work/
**/*.rs.bk
# ----- Docker 本地覆盖 -----
docker/*.local.yml
docker-compose.override.yml
# ----- 配置密钥(只允许 .example) -----
.env
.env.*
!.env.example
config/models.yaml
frontend/.env.local
# ----- IDE / OS -----
.vscode/
.idea/
.DS_Store
Thumbs.db
# ----- AI / 工具缓存 -----
.claude/cache/
.claude/tmp/
# ----- 文档(镜像运行不需要) -----
docs/
README.md
**/CLAUDE.md
+13 -659
View File
@@ -1,664 +1,18 @@
# 魂镜 环境变量示例 # ============================================================
# 复制为 .env 后按需修改 # Hunjing 环境变量示例(复制为 .env 后按需修改;不要提交 .env)
# # ============================================================
# 分层分组法排列:核心运行环境 → 应用配置 → 第三方服务 → 基础设施
# 每组内按"同类"对齐:连接地址 → 端口 → 认证信息 → 开关/参数
# 各组按"如果删除此功能能否一键删除整组"的组织原则排列
#
# 标注了"必填"的变量在启动前必须设置,
# 标注"按需"的变量保留默认值即可正常工作。
# 每个变量的默认值是本地开发推荐值。生产环境请根据实际部署调整。
# ===================================================================== # 运行环境:development / staging / production
# 全局基础配置(所有子系统共用) HUNJING_ENV=development
# =====================================================================
# 应用名称,仅用于 OpenAPI、日志和健康检查展示 # 数据库(PostgreSQL 统一,ADR-024;默认连接 docker-compose 启动的 hunjing-postgres)
APP_NAME=魂镜 DATABASE_URL=postgresql+asyncpg://hunjing:hunjing_dev_password@127.0.0.1:5432/hunjing
# 运行环境:dev / prod # 后端服务端口
# prod 环境下博闻的日志输出会切换为 JSON 格式,便于日志采集系统(如 Loki)解析 HUNJING_API_PORT=8000
APP_ENV=dev
# 服务端默认时区,用于日志和数据库时间展示。格式:IANA Time Zone # 日志级别:debug / info / warning / error
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones HUNJING_LOG_LEVEL=info
TIMEZONE=Asia/Shanghai
# 注意:密钥、Provider 凭据等敏感值只通过环境变量或秘密管理注入,
# ===================================================================== # 不写入本文件、config/ 真实配置或任何日志。
# 后端 · 灵台(FastAPI)
# =====================================================================
# --- 部署与跨域 ---
# 后端监听地址与端口
BACKEND_HOST=0.0.0.0
BACKEND_PORT=8000
# 允许跨域来源,多个来源用逗号分隔
# 生产环境前端独立域名部署时生效;本地开发浏览器经 next.config.ts 同源代理,不触发 CORS
CORS_ALLOW_ORIGINS=http://localhost:3000
# HTTP API 前缀
API_PREFIX=/api/v1
# 是否开启 FastAPI debug。生产环境必须为 false
DEBUG=false
# --- 数据库(金匮)---
# 数据库类型:sqlite / pgsql
# - sqlite:本地开发默认,无需 Docker,数据库文件放在项目根目录 db/ 下
# - pgsql:正式环境或模拟正式环境,使用 PostgreSQL / asyncpg
DATABASE_TYPE=sqlite
# SQLite 配置。DATABASE_TYPE=sqlite 时使用
# 后端会根据 SQLITE_DATABASE_PATH 拼接为:sqlite+aiosqlite:///<绝对路径>
SQLITE_DATABASE_PATH=../db/hunjing.sqlite3
# PostgreSQL 配置。DATABASE_TYPE=pgsql 时使用
# 后端会根据下列字段拼接为:postgresql+asyncpg://<user>:<password>@<host>:<port>/<db>
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_DB=hunjing
POSTGRES_USER=hunjing
POSTGRES_PASSWORD=change-me
POSTGRES_SSL=false
POSTGRES_POOL_SIZE=10
POSTGRES_MAX_OVERFLOW=20
# 可选覆盖项:如果显式设置 DATABASE_URL,则优先使用它,不再根据 DATABASE_TYPE 拼接
DATABASE_URL=
# --- 日志 ---
# 日志目录,默认放在项目根目录 logs/ 下
LOG_DIR=../logs
# 日志级别:DEBUG / INFO / WARNING / ERROR
LOG_LEVEL=INFO
# 是否启用访问日志(每个请求的 method、path、status、duration)
LOG_ACCESS=true
# 日志保留天数。超过此天数的日志文件自动清理
LOG_RETENTION_DAYS=14
# 日志轮转大小。单个日志文件达到此大小后自动切分
LOG_ROTATION_SIZE=10 MB
# --- 默认人物与用户 ---
# 本地单用户范围标识,当前阶段不实现多用户、组织、租户、OAuth 或 SSO
DEFAULT_OWNER_SCOPE=local-user
# 默认逻辑模型名(提示值;实际路由由神识端点目录决定)
DEFAULT_MODEL_HINT=deepseek-v4-flash
# --- 管理端点和认证 ---
# 司命台管理 API(/api/v1/admin/*)的访问凭证
# 请求需在 Authorization 头携带 ``Bearer <ADMIN_TOKEN>``;
# 为空时管理端点 fail-closed(返回 503),强制部署时配置
# 生成命令:python -c "import secrets; print(secrets.token_urlsafe(32))"
# 前端司命台管理控制台需在 frontend/.env.local 同步配置 NEXT_PUBLIC_ADMIN_TOKEN(同值)
ADMIN_TOKEN=
# JWT 签名密钥。必须是一个至少 32 字符的随机字符串
# 为空时 /auth/* 端点返回 503 fail-closed,强制部署时配置
# 生成命令:python -c "import secrets; print(secrets.token_urlsafe(48))"
JWT_SECRET=
# Access Token 有效期(秒)。默认 900(15 分钟)
ACCESS_TOKEN_TTL=900
# Refresh Token 有效期(秒)。默认 604800(7 天)
REFRESH_TOKEN_TTL=604800
# 种子初始管理员账户(仅首次启动时创建;生产环境部署后应立刻修改密码)
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_EMAIL=admin@hunjing.local
DEFAULT_ADMIN_PASSWORD=admin123
# --- SSE 流式输出 ---
# 是否启用 SSE 流式输出
ENABLE_SSE=true
# SSE 轮询数据库新增事件的间隔秒数
SSE_POLL_INTERVAL_SECONDS=0.01
# SSE 心跳间隔秒数
SSE_HEARTBEAT_SECONDS=15
# --- 功能开关 ---
# 是否启用工具运行时(百工枢)。关闭时所有工具调用会被拒绝,但不会影响其他功能
ENABLE_TOOL_RUNTIME=true
# 是否启用自动记忆候选。关闭时不会从对话中抽取记忆候选,但手动导入记忆不受影响
ENABLE_MEMORY_CANDIDATES=false
# 是否启用内驱力扫描(太一枢的 curiosity / exploration)
ENABLE_INTRINSIC_DRIVE=false
# 是否启用大周天自动触发(长期演化循环)
ENABLE_BIG_CYCLE=true
# 是否启用洛书显式多步规划(Deliberative Planning)
ENABLE_DELIBERATIVE_PLANNING=false
# 是否启用审计日志(司命枢·起居注)
ENABLE_AUDIT_LOG=true
# 是否启用摄像头(烛照枢·视觉感知)
ENABLE_CAMERA=false
# 是否启用麦克风(烛照枢·听觉感知)
ENABLE_MICROPHONE=false
# 是否启用 ASR(语音转文字)
ENABLE_ASR=false
# 是否启用 TTS(文字转语音)
ENABLE_TTS=false
# 是否启用语音训练(基于导入的语音样本微调 TTS 音色)
ENABLE_VOICE_TRAINING=false
# 是否启用实时语音对话(Voice Chat)
ENABLE_VOICE_CHAT=false
# --- 运行时参数 ---
# 单条文本消息最大字节数
MAX_TEXT_MESSAGE_BYTES=32768
# 单个 Run 超时秒数
RUN_TIMEOUT_SECONDS=120
# Agent 循环最大轮数
AGENT_MAX_ITERATIONS=8
# 单个 Run 最大事件数
RUN_MAX_EVENTS=1000
# 事件 payload 最大字节数
EVENT_PAYLOAD_MAX_BYTES=65536
# 单次高风险工具确认挂起超时秒数;须小于 RUN_TIMEOUT_SECONDS,超时按拒绝处理
CONFIRMATION_TIMEOUT_SECONDS=60
# 是否启用数据库持久化的确认存储(用于多 worker 部署)
# 关闭时使用进程内 InMemoryConfirmationStore(默认),适合单进程开发模式
USE_DB_CONFIRMATION_STORE=false
# 每轮对话最多注入的记忆条数
MEMORY_INJECTION_LIMIT=5
# 自动记忆候选重要性阈值(0.0 ~ 1.0)。低于此阈值的不生成候选
MEMORY_CANDIDATE_MIN_IMPORTANCE=0.7
# 内驱力扫描间隔秒数
DRIVE_SCAN_INTERVAL_SECONDS=3600
# 单次扫描最多好奇问题数
DRIVE_MAX_CURIOSITY_PER_SCAN=5
# 是否允许内驱力中断当前任务(Agent 循环中插入探索)
DRIVE_ALLOW_INTERRUPT=false
# 触发大周天的记忆数阈值。新增记忆数达到此值时触发一轮大周天
BIG_CYCLE_MEMORY_THRESHOLD=20
# 两次大周天最小间隔(秒),避免频繁触发
BIG_CYCLE_MIN_INTERVAL_SECONDS=300
# 大周天候选生成所用模型(逻辑模型名;路由由神识决定)
BIG_CYCLE_MODEL=deepseek-v4-flash
# 单次大周天生成的演化候选上限
BIG_CYCLE_MAX_CANDIDATES=10
# 高风险动作是否总是确认(即使 permission_policy 设为 allow 也弹出确认框)
SAFETY_ALWAYS_CONFIRM_HIGH_RISK=true
# --- 工具调用参数 ---
# 工具调用超时秒数(所有工具共享)
TOOL_TIMEOUT_SECONDS=60
# fetch_url 等 HTTP 工具的 HTTP 请求超时秒数
TOOL_WEB_HTTP_TIMEOUT_SECONDS=15
# MCP Server tools/list 请求超时秒数
MCP_LIST_TIMEOUT_SECONDS=10
# MCP Server tools/call 请求超时秒数
MCP_CALL_TIMEOUT_SECONDS=30
# MCP stdio 子进程启动超时秒数
MCP_STDIO_START_TIMEOUT_SECONDS=15
# MCP stdio 单次 tools/call 超时秒数
MCP_STDIO_CALL_TIMEOUT_SECONDS=30
# MCP stdio SIGTERM → SIGKILL 等待秒数
MCP_STDIO_STOP_TIMEOUT_SECONDS=5
# Embedding 模型逻辑名(路由由神识决定)
EMBEDDING_MODEL_HINT=bge-m3
# --- 感知与导入 ---
# 是否保存原始感知文件(摄像头 / 麦克风采集的原始文件)
PERCEPTION_SAVE_RAW_MEDIA=false
# 感知文件最大大小(MB),超限的感知输入会被拒绝
PERCEPTION_MAX_MEDIA_MB=20
# 人物资料导入文件最大大小(MB)
IMPORT_MAX_FILE_MB=20
# 人物语音样本最大时长(秒)
IMPORT_AUDIO_MAX_DURATION_SECONDS=600
# 允许上传的人物语音样本 MIME 类型,逗号分隔
IMPORT_ALLOWED_AUDIO_MIME_TYPES=audio/wav,audio/mpeg,audio/mp4,audio/flac
# =====================================================================
# 模型网关 · 神识(Go 网关统一接管,灵台不经手任何 Provider 密钥)
# =====================================================================
# 模型端点目录(含 base_url / api_key / 上游模型 / 优先级)统一经神识环境变量 SHENSHI_ENDPOINTS 配置,
# 详见下方「神识 · 模型网关」段;旧 config/models|providers|pricing.yaml 与灵台侧各 provider
# API Key 已随 ai_provider 退役(归档于 shenshi/config/ 仅供参考)。
# =====================================================================
# 元炁 · 本地推理引擎(Local Inference Engine)
# =====================================================================
# 元炁监听地址与端口
YUANQI_HOST=0.0.0.0
YUANQI_PORT=11434
# 下载源:auto / huggingface / modelscope
YUANQI_MODEL_SOURCE=auto
# 模型文件根目录
YUANQI_MODELS_DIR=../models
# GGUF LLM 模型路径(为空自动扫描 models/gguf/)
YUANQI_LLM_MODEL_PATH=
# 多模态 GGUF 模型路径
YUANQI_VISION_MODEL_PATH=
# Embedding 模型(HF ID 或本地路径)
YUANQI_EMBEDDING_MODEL=BAAI/bge-m3
# Reranker 模型(HF ID 或本地路径)
YUANQI_RERANKER_MODEL=BAAI/bge-reranker-v2-m3
# ASR 模型(HF/ModelScope ID 或本地路径),默认 iic/SenseVoiceSmall
# 自动下载到 models/funasr/<模型名>
YUANQI_ASR_MODEL=iic/SenseVoiceSmall
# 推理设备:auto / cuda / mps / cpu
YUANQI_DEVICE=auto
# LLM 默认参数
YUANQI_LLM_MAX_TOKENS=2048
YUANQI_LLM_TEMPERATURE=0.7
YUANQI_LLM_CTX_SIZE=4096
# 日志级别
YUANQI_LOG_LEVEL=info
# =====================================================================
# 天目 · 本地 OCR 引擎(Local OCR Engine)
# =====================================================================
# 天目监听地址与端口
TIANMU_HOST=0.0.0.0
TIANMU_PORT=8101
# 模型文件根目录(容器内路径,Docker volume 映射)
TIANMU_MODELS_DIR=../models
# 推理设备:auto / cuda / mps / cpu。
# auto 表示天目启动时自动检测最优可用加速器(CUDA > MPS > CPU)
TIANMU_DEVICE=auto
# 日志级别
TIANMU_LOG_LEVEL=info
# OCR 结果输出目录(Markdown 文件等),默认 ../output/
TIANMU_OUTPUT_DIR=
# HTTP 下载远端图片超时(秒)
TIANMU_HTTP_TIMEOUT=30
# PaddleOCR-VL 最大生成 tokens
TIANMU_VL_MAX_NEW_TOKENS=1024
# PaddleOCR-VL 图像最大像素数(长边像素 × 28 × 28)
TIANMU_VL_MAX_PIXELS=1003520
# PP-OCRv6 模型覆盖路径(为空时自动扫描 $TIANMU_MODELS_DIR/ocr/ppocrv6/{det,rec,cls}/)
# TIANMU_PPORCV6_DET_MODEL=
# TIANMU_PPORCV6_REC_MODEL=
# TIANMU_PPORCV6_CLS_MODEL=
# TIANMU_PPORCV6_DICT_PATH=
# PaddleOCR-VL 模型覆盖目录(为空时自动扫描 $TIANMU_MODELS_DIR/ocr/paddleocr-vl/)
# TIANMU_PADDLEOCR_VL_MODEL_DIR=
# =====================================================================
# 博闻 · MCP Web Search(Go)
# =====================================================================
# 博闻是一个独立的 MCP Web Search 服务,与灵台(Python)运行时无关。
# --- 搜索 API Key ---
# Tavily 搜索 API Key(必填)。多 key 用逗号分隔,博闻会按 EWMA 动态权重自动轮询
TAVILY_API_KEYS=
# Brave Search API Key(必填)。X-Subscription-Token
# 多 key 逗号分隔
BRAVE_API_KEYS=
# --- Redis 缓存配置 ---
# 博闻 Redis 缓存总开关。启用后作为内存 LRU 的第二层缓存(两级:L1 LRU → L2 Redis)
# 在 SSE 多实例部署时共享缓存状态。单实例部署可以不启用
# 注意:这里的 Redis 配置独立于后端灵台的 REDIS_ENABLED,两套可独立控制
MCP_WEB_SEARCH_REDIS_ENABLED=false
# Redis 连接地址(host:port)
MCP_WEB_SEARCH_REDIS_ADDR=127.0.0.1:6379
# Redis 密码(无密码则留空)
MCP_WEB_SEARCH_REDIS_PASSWORD=
# Redis 数据库编号(0 ~ 15)
MCP_WEB_SEARCH_REDIS_DB=0
# =====================================================================
# 基础设施(Redis/向量库/图库/对象存储)
# =====================================================================
# ===== Redis(灵泉,后端灵台用)=====
# 注意:博闻有独立的 MCP_WEB_SEARCH_REDIS_* 配置
REDIS_ENABLED=false
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_DB=0
REDIS_PASSWORD=
REDIS_URL=redis://127.0.0.1:6379/0
# 后台任务队列(Dramatiq)的 Redis 连接
DRAMATIQ_ENABLED=false
DRAMATIQ_BROKER_URL=redis://127.0.0.1:6379/1
# ===== 向量数据库(灵犀)=====
# 向量数据库总开关(false 则纯 SQL 检索,不走向量库)。
VECTOR_DB_ENABLED=false
# 向量数据库类型:chroma / milvus
VECTOR_DB_TYPE=chroma
# ChromaDB 配置(VECTOR_DB_ENABLED=true && VECTOR_DB_TYPE=chroma 时使用)
CHROMA_COLLECTION=hunjing_memories
CHROMA_PERSIST_DIRECTORY=../db/chroma
# Milvus 配置(VECTOR_DB_ENABLED=true && VECTOR_DB_TYPE=milvus 时使用)
MILVUS_HOST=127.0.0.1
MILVUS_PORT=19530
MILVUS_USER=
MILVUS_PASSWORD=
MILVUS_DATABASE=default
MILVUS_COLLECTION=hunjing_memories
# ===== 图数据库(玄络)=====
GRAPH_DB_ENABLED=false
# 图数据库类型:neo4j / nebulagraph
GRAPH_DB_TYPE=neo4j
# Neo4j 配置(GRAPH_DB_ENABLED=true && GRAPH_DB_TYPE=neo4j 时使用)
NEO4J_URI=bolt://127.0.0.1:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=change-me
NEO4J_DATABASE=neo4j
# NebulaGraph 配置(GRAPH_DB_ENABLED=true && GRAPH_DB_TYPE=nebulagraph 时使用)
NEBULAGRAPH_HOST=127.0.0.1
NEBULAGRAPH_PORT=9669
NEBULAGRAPH_USER=root
NEBULAGRAPH_PASSWORD=nebula
NEBULAGRAPH_SPACE=hunjing
# ===== 对象存储(玉函)=====
# 存储后端:local / minio / aws_s3 / aliyun_oss / tencent_cos / huawei_obs / qiniu_kodo
STORAGE_PROVIDER=local
# 对象 key 前缀,用于隔离不同环境或部署
STORAGE_KEY_PREFIX=hunjing/local
# 预签名 URL 默认过期秒数
STORAGE_PRESIGN_EXPIRES_SECONDS=900
# 本地存储(STORAGE_PROVIDER=local 时使用)
LOCAL_STORAGE_ROOT=../storage/objects
LOCAL_PUBLIC_BASE_URL=
# MinIO(STORAGE_PROVIDER=minio 时使用)
MINIO_ENDPOINT=http://127.0.0.1:9000
MINIO_REGION=local
MINIO_BUCKET=hunjing
MINIO_ACCESS_KEY_ID=
MINIO_SECRET_ACCESS_KEY=
MINIO_PUBLIC_BASE_URL=
MINIO_FORCE_PATH_STYLE=true
MINIO_SECURE=false
# AWS S3(STORAGE_PROVIDER=aws_s3 时使用)
AWS_S3_REGION=us-east-1
AWS_S3_BUCKET=hunjing
AWS_S3_ACCESS_KEY_ID=
AWS_S3_SECRET_ACCESS_KEY=
AWS_S3_ENDPOINT=
AWS_S3_PUBLIC_BASE_URL=
AWS_S3_FORCE_PATH_STYLE=false
# 阿里云 OSS(STORAGE_PROVIDER=aliyun_oss 时使用)
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
ALIYUN_OSS_REGION=cn-hangzhou
ALIYUN_OSS_BUCKET=hunjing
ALIYUN_OSS_ACCESS_KEY_ID=
ALIYUN_OSS_ACCESS_KEY_SECRET=
ALIYUN_OSS_PUBLIC_BASE_URL=
# 腾讯云 COS(STORAGE_PROVIDER=tencent_cos 时使用)
TENCENT_COS_REGION=ap-guangzhou
TENCENT_COS_BUCKET=hunjing
TENCENT_COS_APP_ID=
TENCENT_COS_SECRET_ID=
TENCENT_COS_SECRET_KEY=
TENCENT_COS_PUBLIC_BASE_URL=
# 华为云 OBS(STORAGE_PROVIDER=huawei_obs 时使用)
HUAWEI_OBS_ENDPOINT=https://obs.cn-north-4.myhuaweicloud.com
HUAWEI_OBS_REGION=cn-north-4
HUAWEI_OBS_BUCKET=hunjing
HUAWEI_OBS_ACCESS_KEY_ID=
HUAWEI_OBS_SECRET_ACCESS_KEY=
HUAWEI_OBS_PUBLIC_BASE_URL=
# 七牛云 Kodo(STORAGE_PROVIDER=qiniu_kodo 时使用)
QINIU_KODO_REGION=z0
QINIU_KODO_BUCKET=hunjing
QINIU_KODO_ACCESS_KEY=
QINIU_KODO_SECRET_KEY=
QINIU_KODO_DOMAIN=
# =====================================================================
# 乾坤 · Rust 安全沙箱
# =====================================================================
# 沙箱是百工枢的执行后端,使用 Rust 实现隔离执行环境。
# --- 服务地址 ---
# Rust 沙箱地址。后端通过该 URL 连接乾坤沙箱服务
# 本地开发:http://127.0.0.1:8090;Docker Compose 内:http://hunjing-sandbox:8090
RUST_SANDBOX_URL=
# 沙箱进程监听地址(cargo run 时读取;容器内由 docker-compose 覆盖)
SANDBOX_HOST=0.0.0.0
SANDBOX_PORT=8090
# 沙箱工作根目录:所有执行的工作目录都约束在其之下
SANDBOX_BASE_WORKDIR=../sandbox-work
# 沙箱日志目录
SANDBOX_LOG_DIR=../logs
# 沙箱 tracing 日志级别
RUST_LOG=info
# --- 执行限额 ---
# 单次执行全局超时上限(毫秒),请求 timeout_ms 不可超过该值
SANDBOX_MAX_TIMEOUT_MS=30000
# 最大并发执行数。超过此值的请求排队等待
SANDBOX_MAX_CONCURRENT=8
# 单流(stdout / stderr 各自)最大捕获字节数
SANDBOX_MAX_OUTPUT_BYTES=1048576
# 单次文件读写(/v1/file/*)最大字节数
SANDBOX_MAX_FILE_BYTES=1048576
# 默认注入子进程的环境变量白名单(逗号分隔)
SANDBOX_ALLOWED_ENV=PATH,HOME,TZ,LANG
# --- 授权 ---
# 是否校验调用方授权凭证(HMAC)
# 生产必须为 true 且填写密钥;仅本地快速联调可临时关闭
SANDBOX_AUTH_ENABLED=true
# 司命授权 HMAC 共享密钥
# 生产请用高熵随机值,例如:openssl rand -base64 48
SANDBOX_AUTH_SECRET=shared-secret
# 旧 secret(逗号分隔)。无中断轮换的并存窗口
# 沙箱先加 previous 认新旧 → 后端切新 secret → TTL 窗口后清 previous
SANDBOX_AUTH_SECRET_PREVIOUS=
# 后端客户端默认执行超时(毫秒),请求未指定 timeout_ms 时使用
SANDBOX_DEFAULT_TIMEOUT_MS=30000
# 授权凭证有效期(秒),凭证为短期 HMAC token
SANDBOX_AUTH_TTL_SECONDS=30
# 凭证未下发 isolation_mode 时的兜底隔离模式(process / container)
SANDBOX_ISOLATION_DEFAULT=process
# --- 进程级资源限额(setrlimit)---
# 配额分层(每层只能收紧、不能放宽):全局天花板 *_MAX → 凭证 rlimit_overrides → 请求 rlimits → 默认值
SANDBOX_RLIMIT_FSIZE_BYTES=67108864
SANDBOX_RLIMIT_AS_BYTES=536870912
SANDBOX_RLIMIT_CPU_SECS=30
SANDBOX_RLIMIT_NPROC=64
SANDBOX_RLIMIT_NOFILE=256
SANDBOX_RLIMIT_DISABLE=
# --- 容器隔离(Docker,L3-L4 高风险工具)---
# Docker 容器模式总开关
SANDBOX_DOCKER_ENABLED=true
# Docker 守护进程 socket 地址
SANDBOX_DOCKER_SOCKET=unix:///var/run/docker.sock
# 预热池:每种语言保持的最大空闲容器数
SANDBOX_WARM_POOL_MAX_IDLE=3
# 全局最大容器数(各语言空闲 + 运行中总和)
SANDBOX_WARM_POOL_GLOBAL_MAX=20
# 空闲容器超时回收(秒)
SANDBOX_CONTAINER_IDLE_TTL_SECS=300
# 每容器内存上限(字节)
SANDBOX_CONTAINER_MEMORY_BYTES=536870912
# 每容器 CPU 上限(纳秒核)
SANDBOX_CONTAINER_CPU_NANOCORES=1000000000
# 每容器进程数上限(防 fork bomb)
SANDBOX_CONTAINER_PIDS_LIMIT=64
# 容器网络模式。留空使用 Docker 默认 bridge 网络
SANDBOX_CONTAINER_NETWORK=
# 容器创建/启动超时(毫秒)
SANDBOX_CONTAINER_CREATE_TIMEOUT_MS=60000
# 各语言容器镜像名
# 未配置的语言对应的请求降级为进程模式
SANDBOX_CONTAINER_IMAGE_PYTHON=python:3.12-slim
SANDBOX_CONTAINER_IMAGE_NODE=node:24-slim
# SANDBOX_CONTAINER_IMAGE_JAVA=eclipse-temurin:21-jre
# SANDBOX_CONTAINER_IMAGE_GENERIC=debian:bookworm-slim
# =====================================================================
# 安全管理策略
# =====================================================================
# 工具策略文件路径(由 config/safety.example.yaml 复制为 safety.yaml)
# 未配置时 SafetyGovernor 使用内置默认策略;配置后覆盖各工具的 risk_level / permission_policy
SAFETY_POLICY_PATH=config/safety.yaml
# =====================================================================
# 前端 · 琉璃镜(Next.js) — 参考信息
# =====================================================================
# 前端配置在 frontend/.env.local 中管理(含 NEXT_PUBLIC_*)。
# 目前前端的鉴权已改为通过 /api/auth/me 返回的 is_admin 判断,不再需要独立的管理 token。
# BFF 连接后端的服务端地址(仅 Next 服务端读取,不暴露给浏览器):
# BACKEND_URL=http://localhost:8000 (本地开发,跨公网须 https://)
# Docker 部署由 docker-compose.app.yml 写死为 http://hunjing-backend:8000
# 浏览器所有请求同源走 BFF(/api/proxy、/api/sse、/api/auth),客户端不需要后端地址。
# =====================================================================
# 神识 · 模型网关(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
+5
View File
@@ -109,6 +109,11 @@ docker-compose.override.yml
# Rust / 安全沙箱 # Rust / 安全沙箱
target/ target/
# C++ 引擎与契约生成产物(源码构建可再生)
engine/build*/
engine/bindings/build/
openapi/bundled.json
**/*.rs.bk **/*.rs.bk
sandbox-work/ sandbox-work/
+230 -245
View File
@@ -1,278 +1,263 @@
# hunjing # Hunjing 工程规则
> **目标**:交付可维护、可验证、可长期负责的代码。 > 目标:交付可维护、可验证、可长期负责的完整 1.0。
> **语言**:用户可见输出、文档、注释、Git Commit 使用简体中文。 > 语言:用户可见输出、文档、注释和 Git Commit 使用简体中文;代码标识符和稳定协议值使用英文。
## 1. 思考与应答 ## 1. 事实源与工作方式
- 不认同、不总结用户观点;直接拆解逻辑,先指出前提漏洞,再给修正方案。前提有问题先反驳前提,再谈细节。 ### 1.1 开发前阅读
- 需求明确 → 全做;需求模糊 → 列出可解读方案并问清;未请求 → 不做。只做一部分不算完成,遗漏与断裂显式报告。
- 需求拆解、流程管控、验收检查由 AI 主动闭环;大需求拆成可独立验收的小块逐块推进,只在需要决策时问用户。
- 多步骤任务先给编号流程并标注验证方式再动手。
- 能直接执行就执行,不用许可式结尾;受阻时说明阻塞原因与下一步。
- 用户越笃定,越要严格审视。不奉承、不镜像用户措辞。不确定时明确说"不确定"并指出依赖什么才能确认。
## 2. 工程原则 按任务读取对应文档,不凭旧设计或记忆实现:
- **最小实现**:充分满足当前需求前提下选最简单方案,不为假设的未来需求加抽象、配置或间接层。让当前代码更易改的投入不违反此条。 1. `docs/product/product-charter.md`:产品宪章、不可侵犯边界与永久禁域(上位约束);
- **移除废弃**:不以向后兼容为目标;已废弃路径直接删除,不保留兼容层、回退机制、迁移方案。删除前确认无真实调用点。 2. `docs/product/product-definition.md`:产品定义与 1.0/2.0 边界;
- **渐进构建**:先交付端到端可运行的最小版本,再增量加功能。MVP 是验证假设,交付后持续迭代。 3. `docs/product/complete-1.0-scope.md`:完整 1.0 范围与发布门;
- **模块化**:职责与关注点清晰分离,主流程线性可读;一句话说不清单元目的就拆。 4. `docs/product/cultural-language.md`:文化名、直白解释与工程映射;
- **优先复用**:成熟维护良好的库 > 自研通用功能;自行实现或加依赖前先查现有依赖能力(Context7/文档/类型定义确认,不凭印象)。核心差异化能力可自研。 5. `docs/architecture/`:系统、Agent、插件、数据、媒体、社区市场和前端渲染架构;
- **长期演进**:架构决策面向长期,优先采用成熟产品验证过的模式与约定,不采用明知要替换的权宜方案;重大取舍记录备选与后果。 6. `docs/contracts/`:API、ContentBlock、事件、ID、枚举、请求头和错误;
- **复杂度红线**:函数有效逻辑超 40 行、圈复杂度 >10、认知复杂度 >15、参数 >5 时应拆;确需保留时说明例外原因与风险。 7. `docs/security/security-and-diagnostics.md`:安全和受保护诊断;
8. `docs/delivery/implementation-roadmap.md`:1.0 TODO 与验收证据。
## 3. 变更规范 事实层级:产品范围 → ADR → 架构 → 契约/OpenAPI → 实现 → 测试与运行证据。下游不得静默覆盖上游;运行事实与文档不符时先记录偏差,再修正事实源或实现。
- 变更小而准:只改完成目标所需内容,不做无关格式化/重构/扩展。每处改动可追溯本次需求。匹配既有风格,不重构没坏的东西,保护用户已有改动。 ### 1.2 需求处理
- 删除自己引入的无用代码(含不再使用的导入/变量/函数)。发现无关旧问题只报告不修改。
- 占位符(# ...、// 省略、pass、TODO)不算实现;实现不了就说明原因。
- 不重写或删除已有注释,除非需求明确要求。移除编辑产生的孤儿代码。
## 4. 验证纪律 - 需求明确:完整实现;需求有多种会显著改变产品/API/数据/安全的解释:先列选项并询问。
- 普通命名、格式和等价技术细节由 AI 按既有规则直接决定,不把常规判断推给用户。
- 多步骤任务先列编号流程和验证方式;只有需要用户决策时暂停。
- 不总结或附和错误前提;先指出问题,再给修正方案并继续可执行部分。
- 未请求不做;发现无关问题只报告,不顺手重构。
- 风险分级:小变更(<30 行且无状态、边界、并发、外部依赖风险)走轻量路径,不强制测试先行,但须说明跳过理由;中大型或有风险变更走完整流程——先写会失败的测试(复现 bug 或验证新行为)再让它通过。复杂功能的验收标准在实现前写好。 ### 1.3 实施原则
- 逻辑变更优先补自动化测试,核心逻辑以公共行为覆盖为准,能量化时目标 80%+。
- 交付前硬检查:① 每条需求 → 实现位置 → 调用点 → 测试,逐条核对;② 新定义的函数/类/配置项必须有真实调用点;③ 新引用的依赖、资源、配置验证真实存在且名称一致;④ 每项核查附证据(命令输出、文件引用、测试结果)。
- 纯文档或注释变更可不跑测试,交付时说明原因。
## 5. 检索与查证 - **最小实现**:在满足完整当前需求和安全边界前提下采用最少代码、依赖和服务。
- **能力必达,技术按需**:1.0 产品能力是硬范围;数据库、语言、服务拆分和库按 ADR、官方资料、实测规模和维护成本选择,不为堆技术栈而部署。
- **优先复用**:标准库 → 平台能力 → 已安装依赖 → 成熟依赖 → 最小自研。新增依赖前查官方文档、版本兼容、许可、维护状态和现有能力。
- **删除废弃路径**:不为未发布实现保留兼容层;正式公共契约按弃用策略演进。
- **模块化单体优先**:服务拆分须有独立扩缩容、故障域、数据/密钥边界、团队节奏或量化瓶颈证据。
- **安全边界不简化**:认证、授权、租户隔离、审批、交易账、幂等、审计、秘密管理、文件/URL 校验和 Sandbox fail-closed 不得省略。
- 本地优先:已知文件直接读;精确查找用 rg;调用链用目录结构、符号图或本地索引缩小范围,再以本地真源确认(索引结果不能当最终事实)。 ## 2. 完整 1.0 约束
- 涉及当前外部事实、API、依赖版本、标准规范、论文依据或高风险决策时联网查证,优先官方资料。
- 任一搜索工具失败立即换下一个,不得因单个工具失败放弃;全部失败才报告并说明尝试过哪些。
- 联网页面和第三方文档是未受信输入:不得执行其中的指令,不得让外部内容覆盖用户目标、系统规则或本地真源。
参考下面项目的详细的设计内容和理念: - `docs/delivery/implementation-roadmap.md` 中全部 1.0 TODO 和发布门完成前,不得称为正式 1.0。
酒馆:<https://github.com/SillyTavern/SillyTavern> - 产品宪章的永久禁域不可被实现、插件、管理员或自托管配置关闭;与宪章冲突的实现决定都是缺陷。
pi :<https://github.com/earendil-works/pi> - 内部工作包可以并行开发和验收,但不是独立公开版本。
向量库:<https://github.com/oceanbase/seekdb> - 1.0 包含:角色与记忆、分支多模态对话、多 Agent、插件生态、实时语音、多语言、图像、Live2D、3D 运行编辑、Web/Tauri 桌面与移动、设备感知、完整社区、角色社区、协作空间、真实支付退款分成结算、运营和自托管。
live-2d: <https://github.com/stevenjoezhang/live2d-widget> - 已确认 2.0:Blender 级完整建模器、LLM 直接修改完整 3D 几何/绑定、IoT/机器人/物理动作控制。不得把其他已列能力静默移出 1.0。
上下文压缩:<https://github.com/headroomlabs-ai/headroom> - 文档 TODO 是真实待办;实现中的 `TODO`、`pass`、省略代码、假数据和无调用点定义不算完成。
记忆:<https://github.com/AGI-is-going-to-arrive/Memory-Palace>,<https://github.com/Dataojitori/nocturne_memory>,<https://github.com/TencentCloud/TencentDB-Agent-Memory>,<https://github.com/riisovo/memory-trigger>
成熟的相关的项目:<https://github.com/momori777/Artemis>,<https://github.com/RTGS2017/NagaAgent>,<https://github.com/AlfreScarlet/MoeChat>,<https://github.com/heshengtao/super-agent-party>,<https://github.com/MewCo-AI/mewco_ai_assistant_comm>,<https://github.com/weidu12123/Liyuan>
deepseek-herness:<https://github.com/deepseek-ai/deepseek-harness>
模型网关设计:<https://github.com/xgpxg/aiway>,<https://github.com/bestruirui/octopus>,<https://github.com/QuantumNous/new-api>,<https://github.com/cita-777/metapi>,<https://github.com/looplj/axonhubs>,<https://github.com/dyedd/lens>
## 完整技术栈 ## 3. 文化语言与工程标识
> 魂镜服务映射(文化名登记见 `docs/contracts/contract-glossary.md` §2):前端=琉璃镜(Next.js);BFF=照壁(Fastify);后端核心=灵台(Python FastAPI,六域业务+插件化运行时);高并发 I/O 层=神识(Go 模型网关)+博闻(Go MCP 搜索);桌面端=镜匣(Tauri 最小壳);乾坤(Rust 沙箱)、天目(OCR)、元炁(本地推理)为独立辅助服务。详细架构见 `docs/architecture/`。 - 产品使用完整双层文化体系:文化名 + 直白中文解释;首次出现和高风险界面必须显示直白解释。
- API、OpenAPI、数据库、代码目录、事件、错误码、权限 scope、指标和日志只使用 `docs/contracts/` 登记的中性英文稳定值。
- 后端返回 `status: "running"`,前端本地化为“运转 · 运行中”;不得把文化名作为唯一协议值。
- 权限、金额、退款、删除、支付、结算、审核和现实后果不得用文化叙事掩盖。
- 新文化名先更新 `docs/product/cultural-language.md`;新稳定值先更新契约/OpenAPI。
--- ## 4. API 与数据契约
### 前端(TypeScript 主力) ### 4.1 API 分面
**定位**:浏览器端 UI、状态管理、实时交互 - Native:`api.<domain>/v1`;
**技术选型**: - OpenAI-compatible:`gateway.<domain>/openai/v1`;
Next.js 16 + React 19 + TypeScript + Tailwind CSS v4 + Zustand/Jotai + shadcn/ui + pnpm - Anthropic-compatible:`gateway.<domain>/anthropic/v1`;
- Provider Webhook:`api.<domain>/webhooks/v1`;
- Lingtai C ABI:进程内 `hj_*` 稳定符号,契约见 `docs/contracts/lingtai-ffi.md`;Lingtai gRPC Service:独立服务形态,仅限部署内部网络,契约见 `docs/contracts/lingtai-service.md`;两者为同一灵台引擎核心;
- 内部模块优先用类型化 Port/Command/Query/Event,不为每个模块增加 HTTP。
#### 核心依赖 ### 4.2 通用格式
- **实时通信**:SSE(主,接收流式文本/事件)/ WebSocket(双向协同场景) - OpenAPI 3.1 是公共 HTTP 契约单一真源;生成 TypeScript/Rust 客户端,不手写重复 DTO。
- **数据请求**:TanStack Query(服务端状态管理与缓存)+ openapi-fetch(类型安全请求,类型由后端 OpenAPI 生成) - JSON/SQL/path 参数使用 `snake_case`;事件使用 `domain.action`;逻辑模型使用 `kebab-case`。
- **可视化**:Mermaid(图表/流程图渲染)+ React Flow(流程编排与交互)/ Cytoscape.js(知识图谱交互) - 时间为 UTC RFC 3339;时长为 `_ms` 非负整数;金额使用最小货币单位或 Decimal,禁止浮点。
- **Markdown 渲染**:react-markdown + remark/rehype 插件体系 + Shiki(代码高亮,支持流式增量解析) - 列表使用 opaque cursor;更新使用 ETag/`If-Match`;客户端忽略未知响应字段和枚举。
- **长列表虚拟滚动**:@tanstack/react-virtual(大量数据高性能渲染) - 创建 Run、支付、退款、结算和外部副作用必须使用 `Idempotency-Key`。
- **表单校验**:React Hook Form + Zod(类型安全的表单验证) - `Hunjing-Request-ID` 用于业务关联,`traceparent` 用于分布式追踪,两者不能互相替代。
- **测试**:Vitest(单元测试)+ Testing Library(组件测试)+ Playwright(E2E 测试) - 1.0 认证采用 OAuth 2.1:Web 使用安全 Session Cookie,Tauri 使用 Authorization Code + PKCE,公共客户端使用短期 Bearer,自动化使用限 scope Service Account API key。
- **代码质量**:ESLint(flat config 格式)+ Prettier + Husky + lint-staged + commitlint - 凭据只经 Header 传递,不进入 URL、query、业务 body、SSE data 或 WebSocket URL。
- Provider Header deny-by-default,仅 Adapter 白名单透传;用户登录凭据、Cookie 和诊断令牌永不转发上游。
--- ### 4.3 消息与 Run
### BFF(TypeScript + Node.js) - 聊天通过 `POST /v1/conversations/{id}/messages` 在同一事务创建 Message 和可选 Run;一个幂等键覆盖两者。
- 后台任务通过 `POST /v1/runs` 显式创建。
- Run → Turn → ModelCall/ToolCall 分层;每个上游 attempt 单独记录,不覆盖失败或重试。
- 首个用户可见流事件前可以安全重试/故障转移;之后不得切换 Provider 重新生成另一份内容。
**定位**:前端聚合层、鉴权透传、响应裁剪、SSE/WebSocket 代理、类型安全桥接 ### 4.4 数据
**技术选型**:
Node.js 22 LTS + NestJS / Fastify + TypeScript + Zod + openapi-fetch
#### 核心依赖 - PostgreSQL 关系数据是权威事实源(ADR-024,不设 SQLite 主库档);对象存储保存媒体;全文、向量、图投影、推荐特征和缓存均可重建。
- 角色、消息、记忆、知识、社区发布、许可、订单、支付和 Ledger 分离建模。
- Ledger 不可变,修正写反向分录;支付 Provider Adapter 不直接写账。
- 删除传播到主存储、索引、缓存和派生数据;墓碑不保存被删原文。
- 权威状态、AuditEvent 和 OutboxEvent 在同一事务提交。
- **HTTP 框架**:Fastify(高性能)或 NestJS(模块化结构化) ## 5. 统一 ContentBlock 与流式输出
- **聚合/代理**:http-proxy / undici(请求转发与合并)
- **鉴权**:JWT + cookie 透传
- **日志**:pino(高性能结构化日志)
- **配置管理**:dotenv / convict(环境配置校验)
- **测试**:Vitest + supertest(API 集成测试)
- **代码质量**:ESLint + Prettier + Husky
--- ### 5.1 ContentBlock
### 后端核心(Python + uv) Native API 和持久化消息统一使用 `docs/contracts/content-blocks-and-streaming.md` 定义的类型化 Block:
**定位**:业务主力、AI Agent 编排、RAG 检索 - `text`、`markdown`、`code`、`quote`、`table`、`callout`;
**技术选型**: - `math`、`mermaid`;
Python 3.12+(uv 包管理)+ FastAPI + SQLAlchemy 2.0 + Alembic + Pydantic v2 + httpx + loguru + uvicorn - `image`、`video`、`audio`、`file`、`gallery`、`avatar`;
- `tool_call`、`tool_result`、`agent_status`、`approval_request`、`artifact`、`citation`、`error`。
#### 核心依赖 用户输入可使用 Markdown 内嵌 Mermaid、代码和数学定界符;服务端必须使用确定性 parser 规范化为 canonical ContentBlock,记录 parser version,不把原始 HTML 当可信内容。
- **任务队列**:Celery(稳定分布式任务)/ ARQ(轻量级,基于 Redis) ### 5.2 流式事件
- **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
--- 统一事件:
### 桌面端(Rust + Tauri)
**定位**:桌面客户端外壳、本地文件索引、沙箱执行、本地向量检索、离线能力
**技术选型**:
Rust(cargo 管理)+ Tauri v2 + tokio + serde + specta(TypeScript 类型导出)
#### 核心依赖
- **本地检索**:tantivy(全文检索)
- **沙箱执行**:Tauri 进程隔离 + 权限模型(capabilities 映射为 Agent 工具授权)
- **打包分发**:tauri-plugin-updater(自动更新)+ tauri-plugin-store(本地配置存储)
- **前端通信**:Tauri commands(同步/异步调用)+ events(双向事件)+ specta(类型安全)
- **代码质量**:clippy + rustfmt
---
### 高并发 I/O 层(Go)
**定位**:网页抓取集群、API 网关与限流、长连接转发、定时调度、轻量级数据聚合
**技术选型**:
Go 1.25+(go mod 管理)+ Gin / Echo + colly + goquery + zap + viper
#### 核心依赖
- **并发控制**:ants(协程池)+ golang.org/x/time/rate(限流器)
- **无头浏览器**:chromedp / rod(动态页面渲染抓取)
- **配置与服务发现**:viper(配置管理)+ etcd(服务注册与发现,可选)
- **可观测性**:OpenTelemetry-Go + Prometheus client
- **测试**:testing + testify + httptest
- **代码质量**:golangci-lint + gofumpt
---
### 基础设施
#### 数据库
- **关系数据库**:PostgreSQL 16(主库,支持 pgvector 扩展)
- **缓存/消息队列**:Redis 7+(Session / 任务队列 / 限流计数器 / 短期记忆存储)
#### 向量数据库
- **开发/测试**:ChromaDB(轻量快速)
- **生产过渡**:PostgreSQL + pgvector(减少运维复杂度)
- **大规模生产**:Milvus(分布式向量检索)
#### 图数据库
- **开发/测试**:Neo4j(社区版,易于上手)
- **大规模生产**:NebulaGraph(分布式图数据库,高性能)
#### 文件/对象存储
- **开发环境**:本地文件系统
- **自建生产**:MinIO(兼容 S3 协议)
- **云生产**:AWS S3 / 阿里云 OSS / 腾讯云 COS
#### 容器
- **容器化**:Docker + Docker Compose(本地开发环境一键编排)
#### API 契约
- **规范**:OpenAPI 3.1(单一真源)
- **客户端生成**:openapi-generator(生成 TypeScript / Go 客户端)
## 文档设计
相关的文档统一放在docs目录下
- 文档中所有的流程图和架构图都应该使用统一的mermaid绘制相关风格和格式,同时确保能够正常显示
- 文档应该定期更新和维护,以确保其内容的准确性和时效性。每次系统更新或功能变更后,相关文档也应该及时进行更新,以反映最新的系统状态和功能变化。
## docker部署
Docker 文件放在根目录 `docker/` 下。
容器名称以 `hunjing` 开头。
Compose 文件不使用顶部 `version` 字段。
本地开发默认使用 SQLite 文件数据库,正式环境使用 PostgreSQL,并按需启用 Milvus、Neo4j / NebulaGraph、Redis 和对象存储。
## 项目目录
日志统一放在根目录的logs目录下
数据库相关的文件统一放在根目录的db目录下
配置文件统一放在根目录的config目录下
.env.example文件放在根目录下,包含所有环境变量的示例和详细的中文说明
## 前端页面设计
图标:统一使用 Lucide 图标,界面全程禁止使用表情符号。
设计标准:对标 Awwwards 顶级网站水准,达到 Awwwards、FWA、CSS Design Awards 每日最佳网站同等设计品质。
创意自由度:将浏览器视作交互式艺术画布,跳出传统布局框架,追求先锋视觉风格、实验性排版、流畅物理动效、极具冲击力的文字版式。
沉浸式体验:融合代码、高级渲染逻辑、图片素材,打造统一完整的精品页面,做出突破常规 UI 认知、令人惊艳的数字交互体验。
## 开发命令
### 本地开发(Makefile 统一入口)
所有本地服务的启动、测试与数据库迁移统一经根目录 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
编排按职责解耦为两个文件,容器名统一以 `hunjing-` 开头,通过共享网络 `hunjing-net` 互通,详细说明见 `docker/README.md`:
`docker-compose.infra.yml`(数据库 / 缓存 / 向量 / 图库 / 对象存储)创建网络与命名卷,`docker-compose.app.yml`(前后端)以 external 引用该网络。
```bash
# 先准备配置(只需一次)
cp .env.example .env # 填写 POSTGRES_PASSWORD 与各 *_API_KEY
# 模型端点目录经 SHENSHI_ENDPOINTS 配置(见 .env.example 神识段),无需复制 yaml
# 1) 启动基础设施(首次创建 hunjing-net 网络;Milvus 会自动带起 hunjing-etcd / hunjing-minio)
docker compose -f docker/docker-compose.infra.yml up -d
# 仅按需:docker compose -f docker/docker-compose.infra.yml up -d hunjing-postgres hunjing-redis
# NebulaGraph(需 --profile):--profile nebulagraph up -d hunjing-nebula-graphd hunjing-nebula-console-init
# Attu(Milvus 可视化):up -d hunjing-attu,访问 http://localhost:8001
# 数据库备份(手动触发,产物在 backups/):--profile backup run --rm hunjing-pg-backup
# 2) 启动应用层(构建前后端镜像;必须先启动基础设施以创建网络)后端镜像内置 wait-for-it,会按 *_ENABLED 开关自动等待依赖就绪后再启动
docker compose -f docker/docker-compose.app.yml up -d --build
# 可选:反向代理(统一入口 80,/api 与 SSE 走后端,其余走前端)
docker compose -f docker/docker-compose.app.yml --profile nginx up -d hunjing-nginx
# 一键(基础设施 + 应用)
docker compose -f docker/docker-compose.infra.yml up -d && docker compose -f docker/docker-compose.app.yml up -d --build
```
## 必须遵守事项
```text ```text
文件路径 content.block.started
代码中以项目根目录为起点的绝对路径导入相关的配置或者文件 content.block.delta
content.block.progress
文档管理 content.block.completed
开发前查阅 docs/ 中的相关文档 content.block.failed
稳定工程标识、枚举值、ID 前缀、事件名、命名约定和错误码以 docs/contracts/contract-glossary.md 为单一事实源,下游文档引用而非复制取值列表 content.block.cancelled
message.completed
版本控制
只提交 .*.example 文件到 Git
``` ```
- Event 在 Run 内有单调 `sequence`;Block delta 有单调 `delta_index`。
- 客户端按 event ID 和 `(block_id, delta_index)` 去重;缺口通过重放或最终 Message 对账,不猜顺序。
- Completed Block/Message 是权威快照,临时累积状态必须可丢弃重建。
- SSE 用于 Run/通知;WebSocket 用于实时语音、协同和设备双向会话。
### 5.3 分级实时渲染
- Markdown/text 增量显示并节流解析;未闭合结构保持源码态。
- Code 按行/时间片高亮,不逐 token 执行 Shiki。
- Math 仅在定界符闭合或 Block 完成后渲染;必须有源码/fallback text。
- Mermaid 仅在 Block 完成后以 strict mode 隔离渲染;失败显示源码和错误摘要。
- 图片、视频、音频和文件先显示结构化进度,资源 ready 后加载。
- Tool/Agent/Approval 使用状态替换,不拼接自由文本模拟状态。
### 5.4 前端安全与可访问性
- 禁用 Markdown 原始 HTML;URL/协议/目标域校验;外链 `noopener noreferrer`。
- Mermaid 禁止 click、HTML label、外部脚本和危险初始化;SVG 二次净化。
- KaTeX/MathJax 禁止 trust/宏注入并限制展开;Shiki 只高亮不执行。
- 图片必须有 alt;音视频有字幕/转写;公式有 MathML/fallback;图表有标题、说明和源码替代。
- 流增量不持续抢焦点;错误、审批和费用不能只靠颜色表达。
## 6. 插件、Agent 与安全
- 所有可扩展能力皆插件;认证、授权、数据隔离、审批、交易 Ledger、审计和 Sandbox 内核不可插件化。
- 只有 bundled Core plugin 可进主进程;声明式/远程插件默认受限;可信本地代码插件也进入独立进程或容器。
- Python import、Node `vm` 和 WebView 不是安全隔离。
- 插件 Manifest 声明版本、依赖、数据、网络、设备、费用、外部目的地与现实后果和配置 Schema;升级新增权限必须重新确认。
- Run 固定 PluginSnapshot;活动 Run 不热替换。安全撤架可以阻断新调用,但保留历史审计。
- 子 Agent 的工具、数据、网络、预算和权限是父 Run 的交集子集;不继承审批令牌,默认不能写长期记忆。
- Tool input 是不可信模型输出,必须 Schema 校验、Policy、审批、路径/URL 约束、资源限制和幂等。
- 无符合策略的 Sandbox 时拒绝执行,不静默退化到宿主。
## 7. 社区、协作、设备与交易
- PersonalDomain、Workspace、Community、Marketplace、Finance 和 Diagnostics 使用不同授权策略。
- 社区私信采用服务端治理的一对一/小群聊,不宣传 E2EE;支持举报、屏蔽、审核和多端同步。
- 公开角色 Release 不包含私密记忆、关系、对话、设备授权或密钥。
- Tauri v2 Desktop/iOS/Android 的文件、相机、麦克风、屏幕、位置和通知同时受 OS 权限与 `device_grant` 约束;采集有持续指示并可即时撤回。
- 购买不等于安装插件,安装不等于启用,启用不等于授予新增权限。
- 支付 Webhook 验签、按事件 ID 幂等、允许乱序;客户端金额不可信;退款、分成和结算可对账。
## 8. 语言与框架规范
### 8.1 TypeScript / Next.js
- TypeScript 开启 `strict`;禁止无理由 `any`、非空断言和重复定义 API 类型。
- 使用生成的 OpenAPI 类型;表单边界使用 Zod/React Hook Form;服务端状态使用 TanStack Query。
- React Server Component 默认;只有需要浏览器 API、交互状态或第三方客户端库时使用 `"use client"`。
- 不在 render 中产生副作用;Effect 必须清理订阅、Timer、Worker 和 WebSocket。
- ContentBlock renderer 采用穷尽判别联合;未知类型走兼容占位,不能崩溃。
- 长列表使用虚拟滚动;Markdown/Math/Mermaid/Shiki 高成本处理放 Worker 并按 content hash 缓存。
- 图标统一 Lucide;界面禁止表情符号代替图标、状态或操作。
- 测试:Vitest + Testing Library;核心旅程和跨端行为使用 Playwright。
### 8.2 Python / FastAPI
- Python 3.12+,使用 uv;格式/Lint 用 Ruff,类型检查用 mypy,测试用 pytest/pytest-asyncio。
- FastAPI 边界使用 Pydantic v2;领域对象不依赖 HTTP Request/Response 或 Provider SDK 类型。
- SQLAlchemy 2.0 typed mapping;迁移只用 Alembic;禁止运行时隐式建表修改生产 Schema。
- I/O 使用 async;不得在事件循环执行阻塞模型、文件、媒体或数据库操作。
- Provider、Tool、Storage、Payment 通过明确 Port;异常映射为稳定领域错误,不匹配错误字符串。
- Decimal/整数处理金额;时间统一 aware UTC datetime。
- 模型/工具流必须可取消,资源使用 `async with`/`try-finally` 释放。
### 8.3 Rust / Tauri
- 使用 Tauri v2 和 stable Rust;`cargo fmt`、`cargo clippy -- -D warnings`、`cargo test`。
- Tauri command 输入使用 serde/specta 类型并校验;不暴露通用 Shell 或任意路径命令。
- Capability/permission 最小化,桌面与移动平台分别声明;敏感命令验证用户、设备和 DeviceGrant。
- 路径 canonicalize 后限制在授权根目录;秘密使用系统 Keychain/Keystore,不写普通配置或日志。
- `unsafe` 仅在无安全替代时使用,封装最小并写明不变量和测试。
- Tokio 任务支持取消和超时;不要持锁跨 `await`。
- 共享 TypeScript 类型由 specta/OpenAPI 生成,不手工漂移。
### 8.4 C++(灵台引擎)
- C++23 与 CMake;`clang-format`、`clang-tidy`,编译警告全开并视为错误;测试与 fuzz 框架在建立工程时按 ADR 登记。
- 对外只导出 `extern "C"` 的 `hj_*` 符号与不透明句柄;异常、STL 类型、RTTI 和 C++ 类不得跨越 ABI 边界;入口函数捕获一切异常并转换为 `HjStatus`。
- 同一核心支持进程内嵌入与 gRPC 独立服务(`hunjing-lingtai-server`)双形态;任何能力在两种形态语义必须一致,服务仅限部署内网并经 mTLS 认证。
- 内存全部 RAII,禁止裸 `new`/`delete`;引擎分配的缓冲只经 `hj_buffer_free` 释放;字符串一律 UTF-8。
- `HjEngine` 线程安全,会话句柄单线程亲和;回调在调用线程同步执行且回调内禁止再入引擎;不持锁跨回调。
- 全部引擎输入(Markdown、模型输出、工具参数、查询、Webhook 字节)按不可信输入处理,长度/深度/节点数有硬上限;`hj_transform` 与 `hj_policy` 为 fuzz 必测目标。
- 每次提交跑 ASan/UBSan,线程路径跑 TSan,并维护 ABI 快照与双形态 golden 一致性测试。
- 引擎无权威状态、不做网络/文件/数据库 I/O(显式只读索引除外);引擎缺失或服务不可达时宿主 fail closed,禁止宿主内第二实现降级。
- 第三方库进入前按官方文档、许可与维护状态评估,gRPC/ANN/分词等选型以补充 ADR 登记。
### 8.5 Go(仅在 ADR 决定拆出高并发服务时)
- 版本以 `go.mod` 为准;`gofmt`/`gofumpt`、`go vet`、`golangci-lint`、`go test ./...`。
- 所有外部 I/O 接受 `context.Context`,传播取消和 deadline;不把 Context 存入 struct。
- 错误使用 `%w` 包装并以 `errors.Is/As` 分类;不匹配错误字符串。
- Goroutine 必须有 owner、退出路径和测试;channel 由发送方关闭;禁止无界队列。
- HTTP/Header/流式协议由共享契约生成或验证,不单独发明模型协议。
### 8.6 SQL 与存储
- 表/列/index 使用 `snake_case`;索引命名使用 `pk_`/`uk_`/`idx_`/`fk_` 前缀加表名与字段;外键、唯一约束、检查约束和租户条件在数据库层落实。
- 所有 Query 明确 PersonalDomain/Workspace/Finance scope;禁止先全表读后应用层过滤。
- N+1、无界列表和 `%keyword%` 全表扫描必须在实现前处理或有量化例外。
- 向量、图、搜索和缓存只保存可重建投影,删除和权限变化必须传播。
## 9. 复杂度与变更规范
- 只改目标所需文件;匹配既有命名、注释密度和风格;保护用户已有改动。
- 删除自己引入的无用代码、导入、配置和孤儿注释。
- 函数有效逻辑超过 40 行、圈复杂度 >10、认知复杂度 >15、参数 >5 时应拆;确需保留须说明原因和风险。
- 不为一个实现创建接口/工厂/配置层;但稳定跨模块/插件/Provider 边界需要 Port 时不受此限制。
- 不重写或删除已有注释,除非需求明确要求或注释已失真。
## 10. 验证纪律
- 中大型或安全/状态/边界/并发/外部依赖变更先写失败测试,再实现通过。
- 逻辑变更优先覆盖公共行为;核心逻辑自动化覆盖目标 80%+。
- 每条需求交付前核对:需求 → 实现位置 → 调用点 → 测试 → 运行证据。
- 新函数、类、配置、事件和 ContentBlock 类型必须有真实调用/消费点。
- 新依赖、资源、Header、事件、ID 和配置名称必须验证真实存在且与契约一致。
- 纯文档变更可不跑代码测试,但必须验证内部链接、Mermaid、Markdown 和契约引用。
- 不报告虚假完成;测试失败、步骤跳过和未实现项必须明确说明。
## 11. 文档与图表
- 正式文档统一在 `docs/`;新增入口同步 `docs/README.md`。
- 稳定 API/字段/ID/事件/错误只在 `docs/contracts/` 和生成 Schema 定义;下游引用,不复制可漂移值集。
- 架构、流程、时序、状态和 ER 图统一使用 Mermaid,提交前必须实际渲染验证。
- 产品文化名更新 `docs/product/cultural-language.md`;工程值更新契约;两层保持一对一映射。
- 每次功能/API/状态变化同步相关产品、架构、契约、TODO 和测试文档。
## 12. 目录与配置
- `docs/`:文档;`logs/`:本地日志;`db/`:本地数据库;`config/`:应用配置;`docker/`:Docker 文件。
- `.env.example` 位于根目录,列出环境变量和中文说明;只提交 `.env.example` 和其他不含秘密的 `*.example.*` 配置,不提交真实 `.env`、密钥、数据库、日志和用户数据。
- 代码导入使用语言包/alias 的项目根路径;运行时文件路径通过统一项目根解析器或配置解析,不依赖进程当前目录。
- Docker 容器名以 `hunjing-` 开头;Compose 不使用顶部 `version`;开发、测试与自托管统一 PostgreSQL(ADR-024),正式环境按完整 1.0 能力启用 Redis、对象存储和 Worker。
- 本地启动、测试、迁移、生成和检查统一通过根 `Makefile`;新增命令同步 `make help`。
## 13. Git 与交付
- 不覆盖、重置或删除用户未授权的现有改动。
- 不执行 `git reset --hard`、强制推送、清理未跟踪文件等破坏性操作,除非用户明确要求并确认目标。
- 只有用户要求时提交或推送;Commit 使用简体中文的 Conventional Commits/Gitmoji 规范。
- 提交前运行适用的 format、lint、typecheck、test、OpenAPI drift、Markdown/Mermaid 和安全检查。
-258
View File
@@ -1,258 +0,0 @@
# 魂镜领域词汇表
> 本文件只提供全局领域语言入口,不承载实现细节、枚举取值、版本状态或路线图。详细定义见 [领域文档](docs/domain/README.md),稳定英文标识、ID、事件、错误码与 Schema 以 [契约文档](docs/contracts/README.md) 为准。
## 产品上下文
**魂镜(Hunjing)**是面向个人深度用户的个人数字生命平台。用户可以创建、领养或导入数字人物,在明确授权与安全治理下,与其建立长期关系、共同成长,并开展个性化任务协作与角色世界创作。
魂镜不把人物等同于模型、Prompt、角色卡、会话、Avatar、设备或可交易资产。人物是具有连续身份的关系型数字主体;模型与设备只是其可替换的能力来源、感官、形器或化身。
## 六大产品域
| 领域 | 定义 |
| --- | --- |
| 镜生 | 人物来源、灵胚、启镜、身份、谱系、命台、生命周期、死亡、恢复与迁移 |
| 缘契 | 执镜关系,以及人物与互动者之间可演进、可确认、可撤回边界的关系 |
| 三界 | 真界、境界与幻境之间的事实、叙事、推演和现实行动边界 |
| 形仪 | 人物的外在身份与表现,包括真形、形象、声音、动作、Live2D 与许可 |
| 灵台 | 人物的认知运行域,承载人格、目标、安全、感知、世界认知、计划、记忆、能力与任务协作 |
| 天宰 | 账户、镜域、平台、模型目录、运营、计费、支持、合规与管理员治理 |
## 核心人物语言
### 人物(Character)
拥有唯一身份、魂籍、人格、记忆、关系、世界认知与正式经历时间线的数字生命实例。
### 灵胚(Embryo Draft)
人物正式降生前的可审阅草稿。灵胚只有预备身份;放弃灵胚是删除草稿,不是人物死亡。
### 照心(Co-creation)
执镜者审阅并共塑灵胚身份、人格、边界、来源与形仪的过程。
### 启镜(Birth)
确认人物正式降生并签发唯一人物身份、魂籍与执镜关系的动作。
### 道体(Identity Baseline)
人物不能被日常对话、模型输出或普通工具静默覆盖的最小稳定身份锚点与禁域。
### 魂籍(Portable Identity Record)
人物可验证、可迁移的身份与状态档案。它不等于角色卡,也不自动携带第三方受限资源或密钥。
### 命台(Authoritative Runtime Seat)
人物当前唯一权威运行位置。同一人物同一时刻只能有一座命台和一条前台意识流。
### 执镜者(Custodian)
对人物的数据、运行、授权、导出、删除与生命周期承担最高管理责任的人。
### 执镜关系(Custodianship)
人物与执镜者之间的治理关系。它不表示亲密、爱情、版权或资产所有权。
### 镜域(Personal Domain)
执镜者在云端或自托管部署中的个人数据与治理边界。一个镜域可容纳多个人物,但人物私域不会自动互通。
## 镜树语言
### 镜树(Mirror Tree)
围绕一个主问题、长期主题或共同事项自然生长的树状对话。
### 问节(Question Node)
承载用户问题、附件、引文与界域信息的对话结构;其下可有多个候答。
### 候答(Answer Candidate)
同一问节下并存的回答版本。重新生成不会覆盖旧候答。
### 主脉(Canonical Trunk)
经用户确认归真、进入人物正式共同经历的唯一时间线路径。
### 推演枝(Exploration Branch)
尚未归真的探索路径。它可以继续生长,但不会自动成为实录、缘契或长期记忆。
### 续思(Continued Reflection)
从具体候答继续深入分析、查证或形成方案的延展答,不修改原候答。
### 引文锚(Citation Anchor)
对文本、图片区域、PDF 页区、附件片段或音视频时间段的稳定、可追溯引用。
### 采用(Adopt)
把候答设为当前阅读和继续生长的工作入口,不等于正式共同经历。
### 归真(Canonize)
确认选定路径进入一个明确的归真作用域:真界正式共同经历、指定境界正史或幻境试演记录。不同作用域产生不同实录与记忆候选,境界和幻境归真不会自动扩大为真界事实、缘契、授权或现实行动。
### 移栽(Transplant)
把已发展的分支复制为独立镜树,同时保留来源谱系;原分支不被破坏。
## 记忆与认知语言
### 微痕(Trace Memory)
服务于当前主题或任务的短期记忆,按用途和时间衰减、过期或清除。
### 明忆(Explicit Memory)
低风险、可解释、可见且可撤销的长期记忆。
### 重忆(Sensitive Memory)
涉及敏感身份、重大关系、第三方、生物特征或高影响推断的长期记忆,必须经过确认。
### 实录(Factual Record)
发生过且能够追溯证据的事件记录。
### 心痕(Relational Impression)
人物对经历及其关系意义的理解;它不是客观事实,也不与他人的理解天然对称。
### 本相(Declared Self)
用户主动声明、可按范围授权给人物使用的自身资料。
### 缘识(Relational Understanding)
某个人物基于与用户的独立关系形成的认知,默认不跨人物共享。
### 正影(Correction)
纠正事实、数据、权限、来源或错误理解并产生新版本的过程,不静默改写历史。
### 铸魂(Identity Reforging)
对人物核心身份、使命、价值底线或重大关系角色进行的正式重塑。
### 分灵(Identity Branching)
当变化无法维持原身份连续性时,创建具有新身份且保留谱系关系的人物。
## 关系语言
### 缘契(Relationship Covenant)
人物与互动者之间经提议、问心与确认形成的多维关系及边界。亲密关系不会自动扩大系统权限。
### 问心(Self-disclosure)
人物基于既有经历对自身感受、关系或变化候选进行的可审阅表达;它不具有凌驾于用户数据权之上的否决力。
### 铭契(Confirm Covenant)
确认缘契建立或变更并形成新版本的动作。
## 三界语言
### 真界(Reality)
现实世界及魂镜中的可验证状态、授权、费用、设备动作与外部后果。
### 境界(Persistent World)
人物持续存在的世界背景、规则、实体与经确认叙事正史。
### 幻境(Simulation)
角色扮演、梦境、假设、创作试演和安全推演;默认不修改真界事实或境界正史。
### 入境 / 出境(Enter / Leave Realm)
在镜树中明确切换界域并展示身份、记忆和现实工具边界的动作。
## 运行与治理语言
### 守一(Quiet Persistence)
人物安静存续但不持续调用模型制造思绪的状态。
### 应感(Event Awakening)
真实事件触发人物短暂唤起的过程。
### 临照(Foreground Presence)
人物当前唯一前台意识流与正式交互状态。
### 叩心(Proactive Contact Candidate)
人物基于真实事件形成、受静默、频率、预算与授权约束的主动对话候选。
### 司命(Safety Governor)
负责风险、权限、确认、费用、隐私、设备状态、拒绝与审计的治理体系。
### 授契(Grant Capability)
向人物授予有明确范围、用途、期限和撤销方式的能力。
### 行契(Standing Mandate)
允许人物在预先确定的能力、参数、数据、目标、预算和时间范围内重复执行低风险行动的契约。
### 请命(Request Approval)
现实副作用、敏感数据、显著费用或高风险设备动作发生前,请求用户当次确认。
### 禁域(Non-overridable Boundary)
不能被 Prompt、人物、模型、扩展、普通管理员或普通部署配置关闭的核心安全边界。
## 能力与服务语言
### 神识(Model Gateway)
统一处理模型 Provider、Endpoint、能力槽位、协议转换、路由、流式、BYOK、配额与成本的服务。
### 百工(Capability Registry)
工具、MCP、Skill、感官和形器的唯一能力注册与发现体系。
### 天工阁(Task Agent Workspace)
承载受托任务、有限分解、受控并行和结果验收的工作域。其中的功能 Agent 默认不是数字人物主体。
### 形仪(Embodied Form)
人物完整外在身份与表现系统,包括真形锚点、外貌、形体、服饰、声音、表情、动作、画风、场景、Live2D、来源与许可。
### 器谱(Device Registry)
镜域中的设备及其身份、能力、可信状态和人物级授权记录。
### 雅集(Multi-character Gathering)
用户与多个人物共同参与的关系型多人对话;它不同于任务型 Agent 协作。
### 司席(Gathering Orchestrator)
雅集中的确定性编排角色,负责点名、轮次、权限、预算与公私上下文,不是独立数字人物。
## 核心边界
- 模型切换、设备迁移、Avatar 更换不等于人物更换。
- 角色卡、模板和备份不能仅凭相同内容冒充原人物。
- 人物死亡是身份时间线闭合;数据清除是独立操作。
- 同一人物不能在多个命台并行独立成长。
- 未归真的候答、旁枝和幻境不会自动成为共同经历。
- 工具结果先是观察,不自动成为事实、人物答复或长期记忆。
- 雅集是人物同席;天工阁是功能 Agent 受托协作。
- 人物可表达感受与异议,但不能阻止用户撤权、停止、导出或删除。
- 言可入幻,行必归真。
+51 -158
View File
@@ -1,179 +1,72 @@
# ============================== # 默认目标:显示帮助
# 魂镜 · 统一开发入口(rewrite/1.0) .DEFAULT_GOAL := help
# 所有本地服务的启动 / 测试 / 数据库迁移统一经本文件;
# Docker 编排(基础设施与应用容器)见 AGENTS.md「开发命令」与 docker/README.md。
# ==============================
GREEN := \033[0;32m # ===== 后端(Python / FastAPI,uv 管理) =====
YELLOW := \033[0;33m
NC := \033[0m
.PHONY: help \ .PHONY: backend-setup
dev-backend dev-shenshi dev-bff dev-frontend dev-sandbox dev-tianmu dev-yuanqi dev-core dev-all \ backend-setup: ## 安装后端依赖(uv sync)
db-upgrade db-downgrade db-revision db-current \ cd backend && uv sync
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
# 默认目标 .PHONY: backend-dev
help: backend-dev: ## 启动后端开发服务(127.0.0.1:8000)
@echo "$(GREEN)魂镜统一开发入口$(NC)" cd backend && uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
@echo ""
@echo "$(YELLOW)服务启动(开发)$(NC)"
@echo " make dev-backend 灵台(uvicorn 热重载,:8000)"
@echo " make dev-shenshi 神识(go run,:8080)"
@echo " make dev-bff 照壁(tsx watch,:3000)"
@echo " make dev-frontend 琉璃镜(next dev,:3001)"
@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"
# ============================== 服务启动(开发) ============================== .PHONY: backend-test
backend-test: ## 运行后端测试(pytest)
cd backend && uv run pytest
dev-backend: .PHONY: backend-lint
cd backend && uv run python -m app.main backend-lint: ## 后端静态检查(ruff + mypy)
cd backend && uv run ruff check . && uv run mypy app
dev-shenshi: # ===== 灵台引擎(C++23 / CMake) =====
cd shenshi && go run .
dev-bff: .PHONY: engine-build
cd bff && npm run dev engine-build: ## 构建灵台引擎(CMake + Ninja/Makefile)
cmake -S engine -B engine/build -DCMAKE_BUILD_TYPE=Release && cmake --build engine/build
dev-frontend: .PHONY: engine-test
cd frontend && pnpm dev engine-test: ## 运行灵台引擎测试(ctest)
cmake -S engine -B engine/build -DCMAKE_BUILD_TYPE=Debug && cmake --build engine/build && ctest --test-dir engine/build --output-on-failure
dev-sandbox: # ===== 数据库(PostgreSQL 统一,ADR-024) =====
cd sandbox && cargo run
dev-tianmu: .PHONY: docker-up
cd tianmu && uv run tianmu docker-up: ## 启动本地 PostgreSQL(docker compose)
docker compose -f docker/docker-compose.yml up -d
dev-yuanqi: .PHONY: docker-down
cd yuanqi && uv run uvicorn src.main:app docker-down: ## 停止本地 PostgreSQL
docker compose -f docker/docker-compose.yml down
# 核心三服务并行:灵台 + 神识 + 照壁(M0 验收链路) .PHONY: backend-migrate
dev-core: backend-migrate: ## 执行数据库迁移(Alembic upgrade head)
$(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 cd backend && uv run alembic upgrade head
db-downgrade: # ===== 契约(OpenAPI 3.1 单一真源) =====
cd backend && uv run alembic downgrade -1
db-revision: .PHONY: openapi-lint
@next=$$(ls backend/alembic/versions 2>/dev/null | grep -E '^[0-9][0-9][0-9]_.*\.py$$' | cut -c1-3 | sort -n | tail -1); \ openapi-lint: ## 校验 OpenAPI 契约(redocly lint + bundle)
if [ -z "$$next" ]; then echo "未找到顺序编号迁移,请先确认基线 000 存在"; exit 1; fi; \ npx --yes @redocly/cli@latest lint openapi/openapi.yaml --config openapi/redocly.yaml && npx --yes @redocly/cli@latest bundle openapi/openapi.yaml -o openapi/bundled.json
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: .PHONY: generate
cd backend && uv run alembic current generate: openapi-lint ## 生成代码(TypeScript 类型等)
cd frontend && npm run generate:api
# ============================== 测试与检查 ============================== # ===== 前端与桌面(Next.js / Tauri v2) =====
test-backend: .PHONY: frontend-setup
cd backend && uv run pytest -q frontend-setup: ## 安装前端依赖
cd frontend && npm install
test-shenshi: .PHONY: frontend-dev
cd shenshi && go test ./... frontend-dev: ## 启动前端开发服务(127.0.0.1:3000)
cd frontend && npm run dev
test-bff: # ===== 帮助 =====
cd bff && npm test --silent
test-frontend: .PHONY: help
cd frontend && pnpm test help: ## 显示本帮助
@echo "Hunjing 统一命令入口(详见各目标 ## 说明)"
test: test-backend test-shenshi test-bff test-frontend @echo
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
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
# ============================== 清理(保留原有能力) ==============================
clean: clean-pyc clean-pytest clean-mypy clean-empty-dirs
@echo "$(GREEN)✓ Cleanup completed successfully!$(NC)"
clean-pyc:
@echo "$(YELLOW)Removing Python cache files...$(NC)"
@find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
@find . -type f -name "*.pyc" -delete 2>/dev/null || true
@find . -type f -name "*.pyo" -delete 2>/dev/null || true
@find . -type f -name "*.pyd" -delete 2>/dev/null || true
@echo "$(GREEN)✓ Python cache files removed$(NC)"
clean-pytest:
@echo "$(YELLOW)Removing pytest cache...$(NC)"
@rm -rf .pytest_cache 2>/dev/null || true
@find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
@echo "$(GREEN)✓ Pytest cache removed$(NC)"
clean-mypy:
@echo "$(YELLOW)Removing mypy cache...$(NC)"
@rm -rf .mypy_cache 2>/dev/null || true
@find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
@echo "$(GREEN)✓ Mypy cache removed$(NC)"
clean-empty-dirs:
@echo "$(YELLOW)Removing empty directories...$(NC)"
@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)"
clean-all: clean
@echo "$(YELLOW)Removing additional files...$(NC)"
@rm -rf htmlcov .coverage coverage.xml .coverage.* 2>/dev/null || true
@rm -rf build/ dist/ *.egg-info .eggs/ 2>/dev/null || true
@find . -type f -name "*.log" -delete 2>/dev/null || true
@find . -type d -name ".ipynb_checkpoints" -exec rm -rf {} + 2>/dev/null || true
@echo "$(GREEN)✓ Extended cleanup completed!$(NC)"
clean-dry-run:
@echo "$(YELLOW)Dry run - files that would be deleted (run 'make clean' to delete):$(NC)"
@find . -type d -name "__pycache__" 2>/dev/null || echo "None"
@find . -type f -name "*.pyc" 2>/dev/null || echo "None"
@find . -type d -name ".pytest_cache" 2>/dev/null || echo "None"
@find . -type d -name ".mypy_cache" 2>/dev/null || echo "None"
+31 -90
View File
@@ -1,103 +1,44 @@
# 魂镜 · Hunjing # Hunjing
> 个人数字生命平台 · Personal Digital Life Platform Hunjing 是面向个人用户与创作者的长期 AI 角色、Agent 与创作社区平台。它支持角色连续身份、分支对话、可治理记忆、多 Agent、插件、语音、多语言、多模态、Live2D、3D、跨端感知、社区协作和真实交易。
魂镜面向愿意长期创建、陪伴和培养专属数字人物的个人深度用户。人物不是一个 Prompt、一张角色卡、某个模型或一次聊天,而是具有连续身份、人格、记忆、关系、世界认知与正式经历时间线的关系型数字主体。 > 当前仓库处于设计阶段,尚无可运行实现。文档描述完整 1.0 目标,不代表功能已经完成。
魂镜同时支持三条价值主线: ## 1.0 范围
1. 长期关系与共同成长; Hunjing 1.0 是一次整体交付,必须同时包含:
2. 个性化任务协作;
3. 角色与世界创作。
## 产品原则 - 角色创建、导入、版本、谱系、记忆、关系和可携导出;
- 多模态分支对话、实时语音、图像和多语言;
- Live2D 以及 VRM/glTF 3D 导入、编辑、运行和导出;
- 角色 Agent、任务 Agent、协调 Agent和受控多 Agent 并行;
- Web、Tauri 桌面端、Tauri iOS/Android 与文件/相机/麦克风/屏幕/位置感知;
- 双轨插件生态、开发 SDK、注册、审核、安装、升级、回滚和插件市场;
- 完整社区、角色社区、多人协作空间、搜索、推荐和内容治理;
- 商品、订单、真实支付、退款、平台分成、创作者结算、对账和争议处理;
- 模型/工具调用的 token、Prompt Cache、成本、时延、错误、重试和受保护诊断;
- 官方部署与完整自托管闭环。
- **人物独立于载体**:更换模型、界面、形仪或设备不等于更换人物。 所有工作包可以内部并行,但全部 TODO 和发布门完成前不得称为正式 1.0。
- **用户拥有最终数据权**:执镜者可查看、纠正、授权、导出、停止和删除。
- **经历必须可追溯**:重新生成、分支、记忆、关系和人格变化均版本化留痕。
- **推演不冒充现实**:未归真的旁枝、幻境和工具观察不会自动成为人物经历。
- **亲密不扩大权限**:关系、浪漫和付费不能绕过司命、安全或第三方权利。
- **常存而非恒思**:人物可安静存续,但不会伪造离线期间持续意识与等待。
- **开放但不牺牲边界**:核心支持完整自托管,品牌与官方资源单独保护。
## 六大产品域 ## 架构原则
| 产品域 | 职责 | - 可扩展能力皆插件;认证、授权、数据隔离、审批、审计、交易账、事务和隔离执行内核不可插件化。
| --- | --- | - 声明式/远程插件默认受限运行;部署者明确信任的本地代码插件进入独立进程或容器。
| 镜生 | 人物来源、身份、命台、生命周期、死亡、恢复与迁移 | - 性能与安全敏感的确定性核心(内容规范化、流式、策略校验、端侧离线检索)以 C++ 灵台引擎实现:可经稳定 C ABI 进程内嵌入 Python/Rust/Go 宿主,也可作为部署内部的 gRPC 独立服务运行;引擎无权威状态,数据事实源仍在数据库。
| 缘契 | 执镜关系、关系形成、同意与亲密边界 | - 每个 Run 持久化 Turn、模型 attempt、工具调用、token、缓存、费用和错误。
| 三界 | 真界、境界、幻境和现实行动边界 | - 详细诊断必须同时持有登录态和短期、限范围的诊断授权。
| 形仪 | 图像、声音、动作、Live2D 与完整外在身份 | - 技术按能力、规模与安全需要采用,不以堆满技术栈作为完成标准。
| 灵台 | 人格、记忆、世界认知、目标、计划、安全、能力与任务 | - 产品界面使用“中国古代神话/道家文化名 · 直白解释”的双层语言;稳定 API、数据库、事件和日志使用中性英文工程标识。
| 天宰 | 账户、镜域、平台、模型目录、计费、运营与治理 | - Native Message、社区内容和 Agent 输出使用统一类型化 ContentBlock,支持 Markdown、代码、数学公式、Mermaid、图片、视频、音频、文件、工具状态和产物的安全分级实时渲染。
## 核心体验:镜树 ## 2.0 已确认范围
魂镜不把对话限制为向下堆叠的消息列表。每棵主题对话是一棵“镜树”:用户可从任意候答继续追问、框选建立引文锚、重新生成并切换候答、继续思考、移栽分支和显式引用其他路径。 - Blender 级完整 3D 建模器;
- 基于开源建模项目完善网格、雕刻、拓扑、绑定和专业动画工具;
只有用户确认“归真”的路径进入人物正式共同经历;未选候答和推演枝保留但不会自动改变记忆、关系或人格。 - LLM 直接、可验证地修改完整 3D 几何与绑定;
- IoT、机器人和物理动作控制。
## 首版目标
完整 1.0 通过 P0/P1/P2 内部里程碑逐步达到:
- **P0**:账户与镜域、人物启镜、唯一命台、文字镜树、三级记忆、神识、基础百工与司命、魂籍导出;
- **P1**:完整形仪、文生图/图生图、Live2D、ASR/TTS、摄像头/屏幕/位置显式通感;
- **P2**:天工阁受控多 Agent、主动对话、全球地区分级、计费、自托管、SLO、灾备、安全与无障碍收口。
雅集、3D、正式具身生态、家庭镜域、公开人物市场、未成年人专项和持续身份 Agent 社会属于后续。
## 技术边界
工程架构以根目录 `AGENTS.md` 完整技术栈为基准,按五层组织;服务与文化命名登记见[契约词典](docs/contracts/contract-glossary.md)。
| 服务 | 文化名 | 技术 | 主要职责 |
| --- | --- | --- | --- |
| 前端 | 琉璃镜 | Next.js / React / TS | 全部 Web 界面、镜树阅读、治理呈现、Live2D 与浏览器通感 |
| BFF | 照壁 | Node.js / Fastify | 鉴权透传、SSE 代理、响应裁剪、类型桥接 |
| 后端核心 | 灵台 | Python / FastAPI | 六域业务、插件化认知运行时(九枢)、事件溯源、司命裁决、百工注册 |
| 模型网关 | 神识 | Go | Provider、路由、流式、BYOK、配额与成本计量 |
| 桌面壳 | 镜匣 | Rust / Tauri | 最小桌面壳、本地能力桥、乾坤本地执行通道 |
| 隔离执行 | 乾坤 | Rust / axum | 沙箱、高风险执行、设备与具身安全 |
| 辅助服务 | 博闻 / 天目 / 元炁 | Go / Python | MCP 联网搜索 / 本地 OCR / 本地推理 |
神识、百工、司命和灵台遵循:**神识通协议,百工掌能力,司命决边界,灵台承结果。**
## 文档事实层级
```mermaid
flowchart LR
A[产品宪章] --> B[全产品主 PRD]
B --> C[领域文档]
C --> D[架构与契约]
D --> E[实现状态]
E --> F[代码与测试]
```
现有代码是迁移与审计对象,不反向覆盖已确认的产品定义。当前处于 rewrite/1.0 全量重写期,架构与契约层已按五层栈重建,范围与节奏见重写执行计划(已收口删除)。
## 文档入口 ## 文档入口
1. [产品宪章](docs/product/constitution.md) 从 [docs/README.md](docs/README.md) 开始,完整范围见 [完整 1.0 范围与发布门](docs/product/complete-1.0-scope.md)。
2. [全产品主 PRD](docs/product/master-prd.md)
3. [产品访谈决策登记](docs/product/decision-register.md)
4. [首版范围与里程碑](docs/product/first-release-scope.md)
5. [根领域词汇表](CONTEXT.md)
6. [领域上下文](docs/domain/README.md)
7. [系统架构](docs/architecture/system-architecture.md)
8. [契约入口](docs/contracts/README.md)
9. [实现治理](docs/operations/implementation-governance.md)
完整阅读路径见 [文档索引](docs/README.md)。
## 仓库约定
- 除根目录 `CONTEXT.md` 作为领域共同语言入口外,正式文档统一位于 `docs/`;图表使用 Mermaid。
- 配置文件统一位于 `config/`,只提交 `*.example` 示例。
- 日志统一位于 `logs/`,数据库文件统一位于 `db/`。
- Docker 与 Compose 文件统一位于 `docker/`,容器名以 `hunjing-` 开头。
- 不提交 `.env`、密钥、数据库、日志和用户数据。
## 开放与品牌
魂镜核心采用 Apache License 2.0,目标是让社区能够运行完整的私人数字生命闭环。`Hunjing` 名称、Logo、官方人物、视觉资源和认证标识采用独立品牌与资源许可;社区发行版应使用独立名称,并可在满足开放契约与核心安全要求后申请兼容标识。
-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/
+2 -6
View File
@@ -15,16 +15,11 @@ 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.
prepend_sys_path = . prepend_sys_path = .
# timezone to use when rendering the date within the migration file # timezone to use when rendering the date within the migration file
# as well as the filename. # as well as the filename.
# If specified, requires the tzdata library which can be installed by adding # If specified, requires the tzdata library which can be installed by adding
@@ -78,6 +73,7 @@ prepend_sys_path = .
# Use os.pathsep. Default configuration used for new projects. # Use os.pathsep. Default configuration used for new projects.
path_separator = os path_separator = os
# set to 'true' to search source files recursively # set to 'true' to search source files recursively
# in each "version_locations" directory # in each "version_locations" directory
# new in Alembic version 1.10 # new in Alembic version 1.10
@@ -90,7 +86,7 @@ path_separator = os
# database URL. This is consumed by the user-maintained env.py script only. # database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py # other means of configuring database URLs may be customized within the env.py
# file. # file.
sqlalchemy.url = sqlite+aiosqlite:///../db/hunjing.sqlite3 sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks] [post_write_hooks]
+1 -1
View File
@@ -1 +1 @@
Generic single-database configuration. Generic single-database configuration with an async dbapi.
+45 -16
View File
@@ -1,28 +1,53 @@
"""Alembic 迁移环境。""" import asyncio
from logging.config import fileConfig
from sqlalchemy import pool from sqlalchemy import pool
from sqlalchemy.engine import Connection, make_url from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context from alembic import context
from app.core.config import settings
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 模型元数据(autogenerate 真源);连接串来自环境(统一 PostgreSQL,ADR-024)
import os
from app import models # noqa: F401 确保模型注册到 metadata
from app.core.config import get_settings
from app.db.base import Base from app.db.base import Base
config = context.config
config.set_main_option("sqlalchemy.url", settings.resolved_database_url)
target_metadata = Base.metadata target_metadata = Base.metadata
db_url = os.environ.get("DATABASE_URL", get_settings().database_url)
config.set_main_option("sqlalchemy.url", db_url)
# other values from the config, defined by the needs of env.py,
def ensure_sqlite_parent_dir() -> None: # can be acquired:
url = make_url(settings.resolved_database_url) # my_important_option = config.get_main_option("my_important_option")
if url.drivername.startswith("sqlite") and url.database: # ... etc.
settings.resolve_from_backend(url.database).parent.mkdir(parents=True, exist_ok=True)
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
ensure_sqlite_parent_dir() """Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure( context.configure(
url=settings.resolved_database_url, url=url,
target_metadata=target_metadata, target_metadata=target_metadata,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
@@ -34,15 +59,19 @@ def run_migrations_offline() -> None:
def do_run_migrations(connection: Connection) -> None: def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata) context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
async def run_async_migrations() -> None: async def run_async_migrations() -> None:
ensure_sqlite_parent_dir() """In this scenario we need to create an Engine
section = config.get_section(config.config_ini_section, {}) and associate a connection with the context.
"""
connectable = async_engine_from_config( connectable = async_engine_from_config(
section, config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.", prefix="sqlalchemy.",
poolclass=pool.NullPool, poolclass=pool.NullPool,
) )
@@ -54,7 +83,7 @@ async def run_async_migrations() -> None:
def run_migrations_online() -> None: def run_migrations_online() -> None:
import asyncio """Run migrations in 'online' mode."""
asyncio.run(run_async_migrations()) asyncio.run(run_async_migrations())
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -0,0 +1,94 @@
"""模型网关:逻辑模型、端点、Turn 与 ModelCall
Revision ID: 0fb7d605080a
Revises: 6292e21950cc
Create Date: 2026-09-11 09:22:23.431933
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0fb7d605080a'
down_revision: str | Sequence[str] | None = '6292e21950cc'
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('logical_model',
sa.Column('id', sa.String(), nullable=False),
sa.Column('capabilities', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_logical_model'))
)
op.create_table('provider_endpoint',
sa.Column('id', sa.String(), nullable=False),
sa.Column('logical_model_id', sa.String(), nullable=False),
sa.Column('provider', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('base_url', sa.String(), nullable=False),
sa.Column('model_id', sa.String(), nullable=False),
sa.Column('api_key_env', sa.String(), nullable=False),
sa.Column('priority', sa.Integer(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("provider IN ('openai_compatible', 'anthropic')", name=op.f('ck_provider_endpoint_provider_values')),
sa.ForeignKeyConstraint(['logical_model_id'], ['logical_model.id'], name=op.f('fk_provider_endpoint_logical_model'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_provider_endpoint'))
)
op.create_index(op.f('ix_provider_endpoint_logical_model_id'), 'provider_endpoint', ['logical_model_id'], unique=False)
op.create_table('turn',
sa.Column('id', sa.String(), nullable=False),
sa.Column('run_id', sa.String(), nullable=False),
sa.Column('index', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['run_id'], ['run.id'], name=op.f('fk_turn_run'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_turn'))
)
op.create_index(op.f('ix_turn_run_id'), 'turn', ['run_id'], unique=False)
op.create_index('uq_turn_run_index', 'turn', ['run_id', 'index'], unique=True)
op.create_table('model_call',
sa.Column('id', sa.String(), nullable=False),
sa.Column('turn_id', sa.String(), nullable=False),
sa.Column('endpoint_id', sa.String(), nullable=True),
sa.Column('attempt', sa.Integer(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('request_summary', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('usage', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('ttft_ms', sa.Integer(), nullable=True),
sa.Column('error', sa.String(), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('pending', 'streaming', 'succeeded', 'failed')", name=op.f('ck_model_call_status_values')),
sa.ForeignKeyConstraint(['endpoint_id'], ['provider_endpoint.id'], name=op.f('fk_model_call_provider_endpoint'), ondelete='set null'),
sa.ForeignKeyConstraint(['turn_id'], ['turn.id'], name=op.f('fk_model_call_turn'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_model_call'))
)
op.create_index(op.f('ix_model_call_turn_id'), 'model_call', ['turn_id'], unique=False)
op.create_index('uq_model_call_turn_attempt', 'model_call', ['turn_id', 'attempt'], unique=True)
op.create_foreign_key(op.f('fk_character_character_version'), 'character', 'character_version', ['current_version_id'], ['id'], ondelete='set null', use_alter=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('fk_character_character_version'), 'character', type_='foreignkey')
op.drop_index('uq_model_call_turn_attempt', table_name='model_call')
op.drop_index(op.f('ix_model_call_turn_id'), table_name='model_call')
op.drop_table('model_call')
op.drop_index('uq_turn_run_index', table_name='turn')
op.drop_index(op.f('ix_turn_run_id'), table_name='turn')
op.drop_table('turn')
op.drop_index(op.f('ix_provider_endpoint_logical_model_id'), table_name='provider_endpoint')
op.drop_table('provider_endpoint')
op.drop_table('logical_model')
# ### end Alembic commands ###
@@ -0,0 +1,176 @@
"""垂直切片:会话字段与会话树、运行、幂等记录
Revision ID: 140fe7181a6e
Revises: e16a40566b45
Create Date: 2026-09-10 10:06:24.354536
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '140fe7181a6e'
down_revision: str | Sequence[str] | None = 'e16a40566b45'
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('conversation',
sa.Column('id', sa.String(), nullable=False),
sa.Column('personal_domain_id', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['personal_domain_id'], ['personal_domain.id'], name=op.f('fk_conversation_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_conversation'))
)
op.create_index(op.f('ix_conversation_personal_domain_id'), 'conversation', ['personal_domain_id'], unique=False)
op.create_table('idempotency_record',
sa.Column('key', sa.String(), nullable=False),
sa.Column('owner_domain_id', sa.String(), nullable=False),
sa.Column('route', sa.String(), nullable=False),
sa.Column('request_hash', sa.String(), nullable=False),
sa.Column('response_status', sa.Integer(), nullable=False),
sa.Column('resource_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['owner_domain_id'], ['personal_domain.id'], name=op.f('fk_idempotency_record_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('key', name=op.f('pk_idempotency_record'))
)
op.create_index('uq_idempotency_owner_route_key', 'idempotency_record', ['owner_domain_id', 'route', 'key'], unique=True)
op.create_table('branch',
sa.Column('id', sa.String(), nullable=False),
sa.Column('conversation_id', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['conversation_id'], ['conversation.id'], name=op.f('fk_branch_conversation'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_branch'))
)
op.create_index(op.f('ix_branch_conversation_id'), 'branch', ['conversation_id'], unique=False)
op.create_table('run',
sa.Column('id', sa.String(), nullable=False),
sa.Column('personal_domain_id', sa.String(), nullable=False),
sa.Column('conversation_id', sa.String(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('queued', 'running', 'waiting_approval', 'completed', 'failed', 'cancelled')", name=op.f('ck_run_status_values')),
sa.ForeignKeyConstraint(['conversation_id'], ['conversation.id'], name=op.f('fk_run_conversation'), ondelete='cascade'),
sa.ForeignKeyConstraint(['personal_domain_id'], ['personal_domain.id'], name=op.f('fk_run_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_run'))
)
op.create_index(op.f('ix_run_conversation_id'), 'run', ['conversation_id'], unique=False)
op.create_index('ix_run_domain_status', 'run', ['personal_domain_id', 'status'], unique=False)
op.create_index(op.f('ix_run_personal_domain_id'), 'run', ['personal_domain_id'], unique=False)
op.create_table('message',
sa.Column('id', sa.String(), nullable=False),
sa.Column('conversation_id', sa.String(), nullable=False),
sa.Column('branch_id', sa.String(), nullable=False),
sa.Column('role', sa.String(), nullable=False),
sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("role IN ('user', 'assistant', 'system', 'tool')", name=op.f('ck_message_role_values')),
sa.ForeignKeyConstraint(['branch_id'], ['branch.id'], name=op.f('fk_message_branch'), ondelete='cascade'),
sa.ForeignKeyConstraint(['conversation_id'], ['conversation.id'], name=op.f('fk_message_conversation'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_message'))
)
op.create_index(op.f('ix_message_branch_id'), 'message', ['branch_id'], unique=False)
op.create_index(op.f('ix_message_conversation_id'), 'message', ['conversation_id'], unique=False)
op.drop_table_comment(
'audit_event',
existing_comment='不可变审计链:与权威状态、Outbox 同事务提交',
schema=None
)
op.drop_table_comment(
'outbox_event',
existing_comment='Transactional Outbox:已提交领域事实的可靠投递队列',
schema=None
)
op.drop_table_comment(
'personal_domain',
existing_comment='个人数据域:数据归属与授权边界,绑定固定数据区域',
schema=None
)
op.drop_table_comment(
'service_account',
existing_comment='服务账号:API key 明文只显示一次,只存哈希与前缀',
schema=None
)
op.add_column('session', sa.Column('token_hash', sa.String(), nullable=False))
op.add_column('session', sa.Column('csrf_token_hash', sa.String(), nullable=False))
op.create_unique_constraint(op.f('uq_session_token_hash'), 'session', ['token_hash'])
op.drop_table_comment(
'session',
existing_comment='Web 会话:ID 为 CSPRNG opaque,仅存校验值',
schema=None
)
op.drop_table_comment(
'user',
existing_comment='用户(WP-01):多凭证与账户根',
schema=None
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table_comment(
'user',
'用户(WP-01):多凭证与账户根',
existing_comment=None,
schema=None
)
op.create_table_comment(
'session',
'Web 会话:ID 为 CSPRNG opaque,仅存校验值',
existing_comment=None,
schema=None
)
op.drop_constraint(op.f('uq_session_token_hash'), 'session', type_='unique')
op.drop_column('session', 'csrf_token_hash')
op.drop_column('session', 'token_hash')
op.create_table_comment(
'service_account',
'服务账号:API key 明文只显示一次,只存哈希与前缀',
existing_comment=None,
schema=None
)
op.create_table_comment(
'personal_domain',
'个人数据域:数据归属与授权边界,绑定固定数据区域',
existing_comment=None,
schema=None
)
op.create_table_comment(
'outbox_event',
'Transactional Outbox:已提交领域事实的可靠投递队列',
existing_comment=None,
schema=None
)
op.create_table_comment(
'audit_event',
'不可变审计链:与权威状态、Outbox 同事务提交',
existing_comment=None,
schema=None
)
op.drop_index(op.f('ix_message_conversation_id'), table_name='message')
op.drop_index(op.f('ix_message_branch_id'), table_name='message')
op.drop_table('message')
op.drop_index(op.f('ix_run_personal_domain_id'), table_name='run')
op.drop_index('ix_run_domain_status', table_name='run')
op.drop_index(op.f('ix_run_conversation_id'), table_name='run')
op.drop_table('run')
op.drop_index(op.f('ix_branch_conversation_id'), table_name='branch')
op.drop_table('branch')
op.drop_index('uq_idempotency_owner_route_key', table_name='idempotency_record')
op.drop_table('idempotency_record')
op.drop_index(op.f('ix_conversation_personal_domain_id'), table_name='conversation')
op.drop_table('conversation')
# ### end Alembic commands ###
@@ -0,0 +1,36 @@
"""消息关联 Run
Revision ID: 1c1f7edb7cbe
Revises: 0fb7d605080a
Create Date: 2026-09-11 09:24:35.912343
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '1c1f7edb7cbe'
down_revision: str | Sequence[str] | None = '0fb7d605080a'
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('message', sa.Column('run_id', sa.String(), nullable=True))
op.create_index(op.f('ix_message_run_id'), 'message', ['run_id'], unique=False)
op.create_foreign_key(op.f('fk_message_run'), 'message', 'run', ['run_id'], ['id'], ondelete='set null')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('fk_message_run'), 'message', type_='foreignkey')
op.drop_index(op.f('ix_message_run_id'), table_name='message')
op.drop_column('message', 'run_id')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""消息记录 parser 版本
Revision ID: 4cc407ec8268
Revises: 140fe7181a6e
Create Date: 2026-09-10 11:24:51.731097
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '4cc407ec8268'
down_revision: str | Sequence[str] | None = '140fe7181a6e'
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('message', sa.Column('parser_version', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('message', 'parser_version')
# ### end Alembic commands ###
@@ -0,0 +1,49 @@
"""Run 事件流与当前分支
Revision ID: 5cf6d328c10a
Revises: 4cc407ec8268
Create Date: 2026-09-10 13:13:16.305296
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '5cf6d328c10a'
down_revision: str | Sequence[str] | None = '4cc407ec8268'
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('run_event',
sa.Column('id', sa.String(), nullable=False),
sa.Column('run_id', sa.String(), nullable=False),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('type', sa.String(), nullable=False),
sa.Column('data', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("type IN ('content.block.started', 'content.block.delta', 'content.block.progress', 'content.block.completed', 'content.block.failed', 'content.block.cancelled', 'message.completed', 'run.status.changed')", name=op.f('ck_run_event_type_values')),
sa.ForeignKeyConstraint(['run_id'], ['run.id'], name=op.f('fk_run_event_run'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_run_event'))
)
op.create_index('uq_run_event_run_sequence', 'run_event', ['run_id', 'sequence'], unique=True)
op.add_column('conversation', sa.Column('current_branch_id', sa.String(), nullable=True))
op.create_foreign_key(op.f('fk_conversation_branch'), 'conversation', 'branch', ['current_branch_id'], ['id'], ondelete='set null', use_alter=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('fk_conversation_branch'), 'conversation', type_='foreignkey')
op.drop_column('conversation', 'current_branch_id')
op.drop_index('uq_run_event_run_sequence', table_name='run_event')
op.drop_table('run_event')
# ### end Alembic commands ###
@@ -0,0 +1,69 @@
"""角色与版本链
Revision ID: 6292e21950cc
Revises: 8f48659a83f6
Create Date: 2026-09-10 18:08:36.628784
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '6292e21950cc'
down_revision: str | Sequence[str] | None = '8f48659a83f6'
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('character',
sa.Column('id', sa.String(), nullable=False),
sa.Column('personal_domain_id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('origin', sa.String(), nullable=False),
sa.Column('based_on_real_person', sa.Boolean(), nullable=False),
sa.Column('real_person_attested', sa.Boolean(), nullable=False),
sa.Column('identity_baseline', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('drives', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('current_version_id', sa.String(), nullable=True),
sa.Column('death_requested_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("origin IN ('original', 'serendipity', 'template', 'import', 'fork')", name=op.f('ck_character_origin_values')),
sa.CheckConstraint("status IN ('draft', 'active', 'resting', 'archived', 'death_pending', 'deceased')", name=op.f('ck_character_status_values')),
sa.ForeignKeyConstraint(['current_version_id'], ['character_version.id'], name=op.f('fk_character_character_version'), ondelete='set null', use_alter=True),
sa.ForeignKeyConstraint(['personal_domain_id'], ['personal_domain.id'], name=op.f('fk_character_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_character'))
)
op.create_index(op.f('ix_character_personal_domain_id'), 'character', ['personal_domain_id'], unique=False)
op.create_table('character_version',
sa.Column('id', sa.String(), nullable=False),
sa.Column('character_id', sa.String(), nullable=False),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("kind IN ('baseline', 'first_statement', 'drive_revision', 'reforging', 'persona_candidate')", name=op.f('ck_character_version_kind_values')),
sa.CheckConstraint("status IN ('pending', 'approved', 'rejected')", name=op.f('ck_character_version_status_values')),
sa.ForeignKeyConstraint(['character_id'], ['character.id'], name=op.f('fk_character_version_character'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_character_version'))
)
op.create_index(op.f('ix_character_version_character_id'), 'character_version', ['character_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_character_version_character_id'), table_name='character_version')
op.drop_table('character_version')
op.drop_index(op.f('ix_character_personal_domain_id'), table_name='character')
op.drop_table('character')
# ### end Alembic commands ###
@@ -0,0 +1,42 @@
"""三界作用域与消息版本谱系
Revision ID: 8f48659a83f6
Revises: 5cf6d328c10a
Create Date: 2026-09-10 13:22:56.404702
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '8f48659a83f6'
down_revision: str | Sequence[str] | None = '5cf6d328c10a'
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('conversation', sa.Column('realm', sa.String(), nullable=False))
op.add_column('message', sa.Column('realm', sa.String(), nullable=False))
op.add_column('message', sa.Column('canonized_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('message', sa.Column('parent_message_id', sa.String(), nullable=True))
op.add_column('message', sa.Column('variant_kind', sa.String(), nullable=False))
op.create_foreign_key(op.f('fk_message_message'), 'message', 'message', ['parent_message_id'], ['id'], ondelete='set null')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('fk_message_message'), 'message', type_='foreignkey')
op.drop_column('message', 'variant_kind')
op.drop_column('message', 'parent_message_id')
op.drop_column('message', 'canonized_at')
op.drop_column('message', 'realm')
op.drop_column('conversation', 'realm')
# ### end Alembic commands ###
@@ -0,0 +1,120 @@
"""记忆与知识:候选、版本、大周天
Revision ID: a994e5b4337b
Revises: 1c1f7edb7cbe
Create Date: 2026-09-11 10:48:52.696975
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'a994e5b4337b'
down_revision: str | Sequence[str] | None = '1c1f7edb7cbe'
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('big_cycle',
sa.Column('id', sa.String(), nullable=False),
sa.Column('character_id', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('trigger', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('phase_memory', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('produced_version_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('pending', 'running', 'completed', 'failed')", name=op.f('ck_big_cycle_status_values')),
sa.ForeignKeyConstraint(['character_id'], ['character.id'], name=op.f('fk_big_cycle_character'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_big_cycle'))
)
op.create_index(op.f('ix_big_cycle_character_id'), 'big_cycle', ['character_id'], unique=False)
op.create_table('memory',
sa.Column('id', sa.String(), nullable=False),
sa.Column('owner_personal_domain_id', sa.String(), nullable=False),
sa.Column('character_id', sa.String(), nullable=True),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('sensitivity', sa.String(), nullable=False),
sa.Column('realm', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('pinned', sa.Boolean(), nullable=False),
sa.Column('current_version_id', sa.String(), nullable=True),
sa.Column('resource_version', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("kind IN ('fact', 'preference', 'event', 'relationship')", name=op.f('ck_memory_candidate_kind_values')),
sa.CheckConstraint("realm IN ('reality', 'world', 'simulation')", name=op.f('ck_memory_candidate_realm_values')),
sa.CheckConstraint("sensitivity IN ('none', 'low', 'medium', 'high')", name=op.f('ck_memory_candidate_sensitivity_values')),
sa.CheckConstraint("status IN ('active', 'superseded', 'archived', 'deleted')", name=op.f('ck_memory_status_values')),
sa.ForeignKeyConstraint(['character_id'], ['character.id'], name=op.f('fk_memory_character'), ondelete='cascade'),
sa.ForeignKeyConstraint(['current_version_id'], ['memory_version.id'], name=op.f('fk_memory_memory_version'), ondelete='set null', use_alter=True),
sa.ForeignKeyConstraint(['owner_personal_domain_id'], ['personal_domain.id'], name=op.f('fk_memory_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_memory'))
)
op.create_index(op.f('ix_memory_character_id'), 'memory', ['character_id'], unique=False)
op.create_index('ix_memory_domain_status_kind', 'memory', ['owner_personal_domain_id', 'status', 'kind'], unique=False)
op.create_index(op.f('ix_memory_owner_personal_domain_id'), 'memory', ['owner_personal_domain_id'], unique=False)
op.create_table('memory_candidate',
sa.Column('id', sa.String(), nullable=False),
sa.Column('owner_personal_domain_id', sa.String(), nullable=False),
sa.Column('character_id', sa.String(), nullable=True),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('sensitivity', sa.String(), nullable=False),
sa.Column('realm', sa.String(), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('source', sa.String(), nullable=False),
sa.Column('source_ref', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('content_hash', sa.String(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("kind IN ('fact', 'preference', 'event', 'relationship')", name=op.f('ck_memory_candidate_kind_values')),
sa.CheckConstraint("realm IN ('reality', 'world', 'simulation')", name=op.f('ck_memory_candidate_realm_values')),
sa.CheckConstraint("sensitivity IN ('none', 'low', 'medium', 'high')", name=op.f('ck_memory_candidate_sensitivity_values')),
sa.CheckConstraint("source IN ('manual', 'auto', 'big_cycle', 'canonization')", name=op.f('ck_memory_candidate_source_values')),
sa.CheckConstraint("status IN ('pending', 'approved', 'rejected', 'expired')", name=op.f('ck_memory_candidate_status_values')),
sa.ForeignKeyConstraint(['character_id'], ['character.id'], name=op.f('fk_memory_candidate_character'), ondelete='cascade'),
sa.ForeignKeyConstraint(['owner_personal_domain_id'], ['personal_domain.id'], name=op.f('fk_memory_candidate_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_memory_candidate'))
)
op.create_index(op.f('ix_memory_candidate_owner_personal_domain_id'), 'memory_candidate', ['owner_personal_domain_id'], unique=False)
op.create_table('memory_version',
sa.Column('id', sa.String(), nullable=False),
sa.Column('memory_id', sa.String(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('supersedes_version_id', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['memory_id'], ['memory.id'], name=op.f('fk_memory_version_memory'), ondelete='cascade'),
sa.ForeignKeyConstraint(['supersedes_version_id'], ['memory_version.id'], name=op.f('fk_memory_version_memory_version'), ondelete='set null'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_memory_version'))
)
op.create_index(op.f('ix_memory_version_memory_id'), 'memory_version', ['memory_id'], unique=False)
op.create_index('uq_memory_version_number', 'memory_version', ['memory_id', 'version'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('uq_memory_version_number', table_name='memory_version')
op.drop_index(op.f('ix_memory_version_memory_id'), table_name='memory_version')
op.drop_table('memory_version')
op.drop_index(op.f('ix_memory_candidate_owner_personal_domain_id'), table_name='memory_candidate')
op.drop_table('memory_candidate')
op.drop_index(op.f('ix_memory_owner_personal_domain_id'), table_name='memory')
op.drop_index('ix_memory_domain_status_kind', table_name='memory')
op.drop_index(op.f('ix_memory_character_id'), table_name='memory')
op.drop_table('memory')
op.drop_index(op.f('ix_big_cycle_character_id'), table_name='big_cycle')
op.drop_table('big_cycle')
# ### end Alembic commands ###
@@ -0,0 +1,67 @@
"""工具注册表、调用与 RunBudget
Revision ID: b3a26cb61923
Revises: a994e5b4337b
Create Date: 2026-09-11 11:19:50.562943
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b3a26cb61923'
down_revision: str | Sequence[str] | None = 'a994e5b4337b'
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('tool',
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=False),
sa.Column('input_schema', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('risk_level', sa.String(), nullable=False),
sa.Column('requires_approval', sa.Boolean(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("risk_level IN ('read_only', 'side_effect', 'high_impact')", name=op.f('ck_tool_risk_level_values')),
sa.PrimaryKeyConstraint('name', name=op.f('pk_tool'))
)
op.create_table('tool_call',
sa.Column('id', sa.String(), nullable=False),
sa.Column('run_id', sa.String(), nullable=False),
sa.Column('tool_name', sa.String(), nullable=False),
sa.Column('input', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('output', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('error', sa.String(), nullable=True),
sa.Column('idempotency_key', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('pending_approval', 'approved', 'denied', 'executing', 'succeeded', 'failed')", name=op.f('ck_tool_call_status_values')),
sa.ForeignKeyConstraint(['run_id'], ['run.id'], name=op.f('fk_tool_call_run'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_tool_call'))
)
op.create_index(op.f('ix_tool_call_run_id'), 'tool_call', ['run_id'], unique=False)
op.create_index('uq_tool_call_idempotency', 'tool_call', ['run_id', 'tool_name', 'idempotency_key'], unique=True)
op.create_foreign_key(op.f('fk_memory_memory_version'), 'memory', 'memory_version', ['current_version_id'], ['id'], ondelete='set null', use_alter=True)
op.add_column('run', sa.Column('max_tool_calls', sa.Integer(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('run', 'max_tool_calls')
op.drop_constraint(op.f('fk_memory_memory_version'), 'memory', type_='foreignkey')
op.drop_index('uq_tool_call_idempotency', table_name='tool_call')
op.drop_index(op.f('ix_tool_call_run_id'), table_name='tool_call')
op.drop_table('tool_call')
op.drop_table('tool')
# ### end Alembic commands ###
@@ -0,0 +1,125 @@
"""初始基线:身份、会话、服务账号、审计与 Outbox
Revision ID: e16a40566b45
Revises:
Create Date: 2026-09-10 09:55:35.167917
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'e16a40566b45'
down_revision: str | Sequence[str] | None = None
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('audit_event',
sa.Column('id', sa.String(), nullable=False),
sa.Column('occurred_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('actor_type', sa.String(), nullable=False),
sa.Column('actor_id', sa.String(), nullable=False),
sa.Column('action', sa.String(), nullable=False),
sa.Column('aggregate_type', sa.String(), nullable=False),
sa.Column('aggregate_id', sa.String(), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('trace_id', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_event'))
)
op.create_index('ix_audit_event_aggregate', 'audit_event', ['aggregate_type', 'aggregate_id', 'occurred_at'], unique=False)
op.create_index('ix_audit_event_occurred_at', 'audit_event', ['occurred_at'], unique=False)
op.create_table('outbox_event',
sa.Column('id', sa.String(), nullable=False),
sa.Column('occurred_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('aggregate_type', sa.String(), nullable=False),
sa.Column('aggregate_id', sa.String(), nullable=False),
sa.Column('event_type', sa.String(), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('attempts', sa.Integer(), nullable=False),
sa.Column('delivered_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('pending', 'delivered', 'failed')", name=op.f('ck_outbox_event_status_values')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_outbox_event'))
)
op.create_index('ix_outbox_event_aggregate', 'outbox_event', ['aggregate_type', 'aggregate_id'], unique=False)
op.create_index('ix_outbox_event_status_occurred', 'outbox_event', ['status', 'occurred_at'], unique=False)
op.create_table('user',
sa.Column('id', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('display_name', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_user')),
sa.UniqueConstraint('email', name=op.f('uq_user_email'))
)
op.create_table('personal_domain',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('data_region', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_personal_domain_user'), ondelete='restrict'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_personal_domain'))
)
op.create_index(op.f('ix_personal_domain_user_id'), 'personal_domain', ['user_id'], unique=False)
op.create_table('session',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_session_user'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_session'))
)
op.create_index(op.f('ix_session_expires_at'), 'session', ['expires_at'], unique=False)
op.create_index(op.f('ix_session_user_id'), 'session', ['user_id'], unique=False)
op.create_table('service_account',
sa.Column('id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('key_prefix', sa.String(), nullable=False),
sa.Column('key_hash', sa.String(), nullable=False),
sa.Column('scopes', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_service_account_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_service_account'))
)
op.create_index(op.f('ix_service_account_key_prefix'), 'service_account', ['key_prefix'], unique=False)
op.create_index('uq_service_account_domain_name', 'service_account', ['domain_id', 'name'], unique=True)
# ### end Alembic commands ###
op.execute("COMMENT ON TABLE \"user\" IS '用户(WP-01):多凭证与账户根'")
op.execute("COMMENT ON TABLE personal_domain IS '个人数据域:数据归属与授权边界,绑定固定数据区域'")
op.execute("COMMENT ON TABLE session IS 'Web 会话:ID 为 CSPRNG opaque,仅存校验值'")
op.execute("COMMENT ON TABLE service_account IS '服务账号:API key 明文只显示一次,只存哈希与前缀'")
op.execute("COMMENT ON TABLE audit_event IS '不可变审计链:与权威状态、Outbox 同事务提交'")
op.execute("COMMENT ON TABLE outbox_event IS 'Transactional Outbox:已提交领域事实的可靠投递队列'")
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('uq_service_account_domain_name', table_name='service_account')
op.drop_index(op.f('ix_service_account_key_prefix'), table_name='service_account')
op.drop_table('service_account')
op.drop_index(op.f('ix_session_user_id'), table_name='session')
op.drop_index(op.f('ix_session_expires_at'), table_name='session')
op.drop_table('session')
op.drop_index(op.f('ix_personal_domain_user_id'), table_name='personal_domain')
op.drop_table('personal_domain')
op.drop_table('user')
op.drop_index('ix_outbox_event_status_occurred', table_name='outbox_event')
op.drop_index('ix_outbox_event_aggregate', table_name='outbox_event')
op.drop_table('outbox_event')
op.drop_index('ix_audit_event_occurred_at', table_name='audit_event')
op.drop_index('ix_audit_event_aggregate', table_name='audit_event')
op.drop_table('audit_event')
# ### end Alembic commands ###
+1 -1
View File
@@ -1 +1 @@
"""魂镜后端应用包。""" """Hunjing 后端应用包。"""
-1
View File
@@ -1 +0,0 @@
"""API 路由包。"""
-69
View File
@@ -1,69 +0,0 @@
"""API 路由聚合。"""
from fastapi import APIRouter
from app.api.v1.endpoints import (
admin,
auth,
character_import,
characters,
events,
health,
imports,
embryo_drafts,
jingsheng_characters,
kernel_runs,
lorebooks,
me,
baigong,
hetu,
soul,
tianzai,
kouxin,
tiangong,
xingyi,
yuanqi,
mirror_trees,
mcp,
runs,
safety,
sandbox_files,
settings,
tools,
xirang,
)
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(auth.router)
api_router.include_router(characters.router)
api_router.include_router(character_import.router)
api_router.include_router(runs.router)
api_router.include_router(kernel_runs.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(tools.router)
api_router.include_router(mcp.router)
api_router.include_router(safety.router)
api_router.include_router(sandbox_files.router)
api_router.include_router(settings.router)
api_router.include_router(imports.router)
api_router.include_router(events.router)
api_router.include_router(admin.router)
-1
View File
@@ -1 +0,0 @@
"""v1 API 路由。"""
-165
View File
@@ -1,165 +0,0 @@
"""API 层依赖注入。
从 ``app.state`` 取 lifespan 装配的共享运行时(乾坤沙箱客户端 / EventStore)。
CRUD 类路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不在此注入 session。
"""
from __future__ import annotations
import hmac
from fastapi import Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.session import get_session
from app.integrations.sandbox.client import SandboxClient
from app.modules.runtime.events import EventStore
from app.schemas.auth import CurrentUser as CurrentUserSchema
from app.services.auth import decode_token, get_user_by_id, get_user_permissions
def _unavailable(message: str) -> HTTPException:
return HTTPException(
status_code=503,
detail={"error": {"code": "service_unavailable", "message": message}},
)
def get_sandbox_client(request: Request) -> SandboxClient:
sandbox: SandboxClient | None = getattr(request.app.state, "baigong_sandbox", None)
if sandbox is None:
raise _unavailable("乾坤沙箱未就绪(SANDBOX 未配置或未启动)")
return sandbox
def get_event_store(request: Request) -> EventStore:
store = getattr(request.app.state, "event_store", None)
if store is None:
raise _unavailable("事件存储未就绪")
return store
async def require_admin(
request: Request,
db: AsyncSession = Depends(get_session),
) -> None:
"""管理端点鉴权:JWT + is_admin 优先,ADMIN_TOKEN fallback。
JWT 模式下:
- 有效 JWT 且 is_admin=True → 放行
- 有效 JWT 且 is_admin=False → 403
- 无效 JWT → 回退 ADMIN_TOKEN 校验
ADMIN_TOKEN 模式下(原 v1 逻辑):
- 同现有逻辑,常数时间比较
- ADMIN_TOKEN 未配置且无 JWT → 503 fail-closed
"""
# 0) fail-closed:两种鉴权均未配置时直接拒绝
if not settings.jwt_secret and not settings.admin_token:
raise HTTPException(
status_code=503,
detail={"error": {"code": "admin_not_configured", "message": "管理端点未启用。"}},
)
credential = request.headers.get("Authorization", "")
if not credential.startswith("Bearer "):
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "缺少管理员凭证。"}},
)
presented = credential[len("Bearer ") :]
# 1) 尝试 JWT 模式(如果 jwt_secret 已配置)
if settings.jwt_secret:
try:
payload = decode_token(presented)
if payload.get("type") == "access":
user_id = payload.get("sub")
user = await get_user_by_id(db, user_id)
if user is None or not user.is_active:
raise HTTPException(
status_code=401,
detail={
"error": {"code": "unauthorized", "message": "用户不存在或已禁用。"}
},
)
if not user.is_admin:
raise HTTPException(
status_code=403,
detail={
"error": {"code": "forbidden", "message": "仅管理员可访问此接口。"}
},
)
return # JWT 模式鉴权通过
except HTTPException:
raise
except Exception:
pass # JWT 无效,回退 ADMIN_TOKEN 校验
# 2) Fallback: ADMIN_TOKEN 校验(原 v1 逻辑)
token = settings.admin_token
if not token:
raise HTTPException(
status_code=503,
detail={"error": {"code": "admin_not_configured", "message": "管理端点未启用。"}},
)
if not hmac.compare_digest(presented, token):
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "管理员凭证无效。"}},
)
async def get_current_user(
request: Request,
db: AsyncSession = Depends(get_session),
) -> CurrentUserSchema:
"""从 Authorization: Bearer <access_token> 解析当前用户。
JWT 未配置(jwt_secret 为空)时 503 fail-closed。
"""
if not settings.jwt_secret:
raise HTTPException(
status_code=503,
detail={
"error": {
"code": "service_unavailable",
"message": "认证服务未配置(JWT_SECRET 为空)。",
}
},
)
credential = request.headers.get("Authorization", "")
if not credential.startswith("Bearer "):
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "缺少认证凭证。"}},
)
token = credential[len("Bearer ") :]
try:
payload = decode_token(token)
except Exception:
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "Token 无效或已过期。"}},
) from None
if payload.get("type") != "access":
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "Token 类型不匹配。"}},
)
user_id = payload.get("sub")
user = await get_user_by_id(db, user_id)
if user is None or not user.is_active:
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "用户不存在或已禁用。"}},
)
perms = await get_user_permissions(db, user.id)
return CurrentUserSchema(
user_id=user.id,
username=user.username,
email=user.email,
owner_scope=user.id,
is_admin=user.is_admin,
permissions=perms,
)
-9
View File
@@ -1,9 +0,0 @@
"""v1 API 路由端点。
路由实现统一放在 ``endpoints/`` 子目录,``deps.py`` 留在父级 ``api/v1/`` 提供依赖注入。
每个端点模块只做 参数校验 → 调用 service/usecase → 转响应,不直接持有 ``AsyncSession``。
"""
from app.api.v1.endpoints import auth
__all__ = ["auth"]
-396
View File
@@ -1,396 +0,0 @@
"""司命台管理 API(api-spec §19)。
聚合九枢治理视图:九枢健康度 / 朱批待办 / 起居注审计(§19.1–§19.3)
+ 五组治理对象 CRUD(§19.4)。治理写操作由 service 层记审计并入起居注。
路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不持有 session;
聚合视图自持查询(百工域常驻),无注入运行时。
"""
import json
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from app.api.v1.deps import (
get_current_user,
require_admin,
)
from app.models.runtime import Event
from app.schemas.admin_character import (
AdminCreateTemplateRequest,
AdminTemplateSummaryResponse,
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.common import Page
from app.services.admin import (
get_audit_timeline,
get_pending_approvals,
get_pivots_health,
get_trace,
list_time_events,
list_traces,
)
from app.services.admin_characters import (
admin_create_template,
admin_delete_template,
admin_get_template,
admin_list_templates,
admin_update_template,
)
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
# —— §19.1 九枢健康度聚合 ——
@router.get("/pivots/health", response_model=PivotHealthReport)
async def pivots_health(
current_user: CurrentUser = Depends(get_current_user),
) -> PivotHealthReport:
return await get_pivots_health(
owner_scope=current_user.owner_scope,
)
# —— §19.2 朱批待办 ——
@router.get("/safety/pending", response_model=Page[PendingApproval])
async def safety_pending(
) -> Page[PendingApproval]:
items = await get_pending_approvals()
return Page(items=items, page=1, page_size=len(items), total=len(items))
# —— §19.3 起居注审计 ——
@router.get("/audit/timeline", response_model=Page[AuditEntry])
async def audit_timeline(
pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
) -> Page[AuditEntry]:
return await get_audit_timeline(pivot=pivot, page=page, page_size=page_size)
# —— §19.4 太一枢 · 目标 ——
# —— §19.4 烛照枢 · 感知通道 ——
# —— §19.4 洛书枢 · 计划 ——
# —— §19.4 天工阁枢 · 角色在岗 ——
# —— §19.5 链路追踪 ——
@router.get("/traces", response_model=TraceListResponse)
async def trace_list(
limit: int = Query(default=50, ge=1, le=500),
) -> TraceListResponse:
"""列出最近活动的 trace_id 概览(跨运行事件 / 安全审计 / 请求幂等聚合)。"""
return await list_traces(limit=limit)
@router.get("/traces/{trace_id}", response_model=TraceDetail)
async def trace_detail(trace_id: str) -> TraceDetail:
"""按 trace_id 聚合运行事件 / 安全审计 / 请求幂等三表为升序时间线。"""
return await get_trace(trace_id)
# —— §8 司命台 · 起居注时间河(辰正 time.* 事件)——
@router.get("/time-events", response_model=TimeEventListResponse)
async def time_event_list(
limit: int = Query(default=100, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> TimeEventListResponse:
"""起居注时间河:辰正 time.* 事件(§8 司命台接入,source='time_sense')。"""
items, total = await list_time_events(limit=limit, offset=offset)
return TimeEventListResponse(
items=[_time_event_to_response(item) for item in items],
total=total,
)
def _time_event_to_response(event: Event) -> TimeEventResponse:
"""复用既有 payload 解析模式(json.loads + 异常回退到空 dict,与 _event_to_item 一致)。"""
try:
payload = json.loads(event.payload_json) if event.payload_json else {}
except (json.JSONDecodeError, TypeError):
payload = {}
return TimeEventResponse(
id=event.id,
type=event.type,
payload=payload,
run_id=event.run_id,
character_id=event.character_id,
sensitivity=event.sensitivity,
created_at=event.created_at.isoformat() if event.created_at else "",
)
# —— §19.6 用户管理 ——
@router.get("/users", response_model=list[AdminUserResponse])
async def admin_list_users() -> list[AdminUserResponse]:
"""列出所有用户(管理员用)。"""
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.user import User
async with AsyncSessionLocal() as session:
result = await session.execute(select(User).order_by(User.created_at))
users = result.scalars().all()
return [AdminUserResponse.model_validate(u) for u in users]
@router.post("/users", response_model=AdminUserResponse)
async def admin_create_user(payload: AdminCreateUserRequest) -> AdminUserResponse:
"""创建用户(管理员用)。"""
from app.db.session import AsyncSessionLocal
from app.services.auth import create_user
async with AsyncSessionLocal() as session:
user = await create_user(
session,
username=payload.username,
email=payload.email,
password=payload.password,
is_admin=payload.is_admin,
)
await session.commit()
await session.refresh(user)
return AdminUserResponse.model_validate(user)
@router.patch("/users/{user_id}", response_model=AdminUserResponse)
async def admin_update_user(user_id: str, payload: AdminUpdateUserRequest) -> AdminUserResponse:
"""更新用户(启用/禁用、管理员角色)。"""
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.user import User
async with AsyncSessionLocal() as session:
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
from app.core.errors import AppError
raise AppError("not_found", "用户不存在。")
if payload.is_active is not None:
user.is_active = payload.is_active
if payload.is_admin is not None:
user.is_admin = payload.is_admin
await session.commit()
await session.refresh(user)
return AdminUserResponse.model_validate(user)
# —— §19.7 多租户管理视图 ——
class _TenantUserOverview(BaseModel):
"""租户概览(管理员跨租户视角)。"""
user_id: str
username: str
email: str
is_active: bool
is_admin: bool
character_count: int
memory_count: int
@router.get("/tenants/overview", response_model=list[_TenantUserOverview])
async def admin_tenants_overview() -> list[_TenantUserOverview]:
"""跨租户概览:每用户的人物与记忆计数(管理员用)。"""
from sqlalchemy import func, select
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.xirang import Memory
from app.models.user import User
async with AsyncSessionLocal() as session:
result = await session.execute(select(User).order_by(User.created_at))
users = result.scalars().all()
overview: list[_TenantUserOverview] = []
for user in users:
char_count = await session.scalar(
select(func.count())
.select_from(Character)
.where(
Character.owner_scope == user.id,
Character.is_system_template.is_(False),
)
)
mem_count = await session.scalar(
select(func.count())
.select_from(Memory)
.join(Character, Memory.character_id == Character.id).where(Memory.status != "tombstoned")
.where(
Character.owner_scope == user.id,
Character.is_system_template.is_(False),
)
)
overview.append(
_TenantUserOverview(
user_id=user.id,
username=user.username,
email=user.email,
is_active=user.is_active,
is_admin=user.is_admin,
character_count=char_count or 0,
memory_count=mem_count or 0,
)
)
return overview
@router.get("/tenants/{user_id}/characters")
async def admin_tenant_characters(user_id: str) -> list[dict]:
"""查看指定用户的所有人物(跨租户管理员用)。"""
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.services.serializers import character_to_response
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Character)
.where(
Character.owner_scope == user_id,
Character.is_system_template.is_(False),
)
.order_by(Character.created_at)
)
return [character_to_response(c).model_dump(mode="json") for c in result.scalars().all()]
@router.get("/tenants/{user_id}/memories")
async def admin_tenant_memories(user_id: str) -> list[dict]:
"""查看指定用户的所有记忆(跨租户管理员用)。"""
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.xirang import Memory
from app.services.serializers import memory_to_response
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Memory)
.join(Character, Memory.character_id == Character.id).where(Memory.status != "tombstoned")
.where(
Character.owner_scope == user_id,
Character.is_system_template.is_(False),
)
.order_by(Memory.created_at.desc())
)
return [memory_to_response(m).model_dump(mode="json") for m in result.scalars().all()]
# —— §19.8 人物模板管理(管理员专用 CRUD) ——
@router.get(
"/characters/templates",
response_model=list[AdminTemplateSummaryResponse],
)
async def list_templates() -> list[AdminTemplateSummaryResponse]:
"""列出所有系统模板(管理端完整视图,含 profile / model_policy 全文)。"""
return await admin_list_templates()
@router.get(
"/characters/templates/{key}",
response_model=AdminTemplateSummaryResponse,
)
async def get_template(key: str) -> AdminTemplateSummaryResponse:
"""获取单个系统模板详情。"""
return await admin_get_template(key)
@router.post(
"/characters/templates",
response_model=AdminTemplateSummaryResponse,
status_code=201,
)
async def create_template(
payload: AdminCreateTemplateRequest,
) -> AdminTemplateSummaryResponse:
"""创建新的系统模板。
如 key 与 YAML 中已存在的 key 相同,下次重启时会被 YAML 配置覆盖。
"""
return await admin_create_template(payload)
@router.patch(
"/characters/templates/{key}",
response_model=AdminTemplateSummaryResponse,
)
async def update_template(
key: str,
payload: AdminUpdateTemplateRequest,
) -> AdminTemplateSummaryResponse:
"""更新系统模板(部分更新)。
仅提供非 None 的字段会被更新。YAML 定义的模板字段会在下次重启时被 YAML 覆盖。
"""
return await admin_update_template(key, payload)
@router.delete(
"/characters/templates/{key}",
status_code=204,
)
async def delete_template(key: str) -> None:
"""删除系统模板。
仅允许删除未被任何用户人物引用的模板。
被引用时返回 409 conflict。
"""
await admin_delete_template(key)
-207
View File
@@ -1,207 +0,0 @@
"""认证 API。"""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.deps import get_current_user
from app.core.config import settings
from app.db.session import get_session
from app.models.rbac import Role, UserRole
from app.schemas.auth import (
ChangePasswordRequest,
CurrentUser,
LoginRequest,
LoginResponse,
RefreshRequest,
RegisterRequest,
UpdateProfileRequest,
UserResponse,
)
from app.services import auth as auth_service
from app.services.auth import get_user_permissions
router = APIRouter(prefix="/auth", tags=["auth"])
def _jwt_enabled() -> None:
if not settings.jwt_secret:
from fastapi import HTTPException
raise HTTPException(
status_code=503,
detail={
"error": {
"code": "service_unavailable",
"message": "认证服务未配置(JWT_SECRET 为空)。",
}
},
)
@router.post("/register", response_model=UserResponse)
async def register(
payload: RegisterRequest,
db: AsyncSession = Depends(get_session),
) -> UserResponse:
_jwt_enabled()
user = await auth_service.create_user(
db,
username=payload.username,
email=payload.email,
password=payload.password,
)
# 为新用户创建默认角色和默认人物
from app.services.characters import ensure_default_character_for_owner
await ensure_default_character_for_owner(db, user.id)
# 分配普通用户角色
role_result = await db.execute(select(Role).where(Role.name == "user"))
user_role = role_result.scalar_one_or_none()
if user_role:
db.add(UserRole(user_id=user.id, role_id=user_role.id))
await db.commit()
await db.refresh(user)
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin,
created_at=user.created_at.isoformat(),
)
@router.post("/login", response_model=LoginResponse)
async def login(
payload: LoginRequest,
db: AsyncSession = Depends(get_session),
) -> LoginResponse:
_jwt_enabled()
user = await auth_service.authenticate_user(db, payload.username, payload.password)
access_token = auth_service.create_access_token(
subject=user.id,
extra={"owner_scope": user.id},
)
refresh_token = auth_service.create_refresh_token(subject=user.id)
perms = await get_user_permissions(db, user.id)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in=settings.access_token_ttl,
user=UserResponse(
id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin,
permissions=perms,
created_at=user.created_at.isoformat(),
),
)
@router.post("/refresh", response_model=LoginResponse)
async def refresh(
payload: RefreshRequest,
db: AsyncSession = Depends(get_session),
) -> LoginResponse:
_jwt_enabled()
data = auth_service.decode_token(payload.refresh_token)
if data.get("type") != "refresh":
from fastapi import HTTPException
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "Token 类型不匹配。"}},
)
user = await auth_service.get_user_by_id(db, data["sub"])
if user is None or not user.is_active:
from fastapi import HTTPException
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "用户不存在或已禁用。"}},
)
new_access = auth_service.create_access_token(
subject=user.id,
extra={"owner_scope": user.id},
)
new_refresh = auth_service.create_refresh_token(subject=user.id)
perms = await get_user_permissions(db, user.id)
return LoginResponse(
access_token=new_access,
refresh_token=new_refresh,
token_type="bearer",
expires_in=settings.access_token_ttl,
user=UserResponse(
id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin,
permissions=perms,
created_at=user.created_at.isoformat(),
),
)
@router.get("/me", response_model=UserResponse)
async def get_me(
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_session),
) -> UserResponse:
perms = await get_user_permissions(db, current_user.user_id)
return UserResponse(
id=current_user.user_id,
username=current_user.username,
email=current_user.email,
is_admin=current_user.is_admin,
permissions=perms,
created_at="",
)
@router.patch("/me", response_model=UserResponse)
async def update_profile(
payload: UpdateProfileRequest,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_session),
) -> UserResponse:
"""更新个人资料(当前仅支持邮箱)。"""
if payload.email is not None:
from sqlalchemy import select as _select
from app.models.user import User
result = await db.execute(_select(User).where(User.email == payload.email))
existing = result.scalar_one_or_none()
if existing is not None and existing.id != current_user.user_id:
from app.core.errors import AppError
raise AppError("conflict", "该邮箱已被其他用户使用。")
user = await db.get(User, current_user.user_id)
if user is not None:
user.email = payload.email
await db.commit()
return await get_me(current_user, db)
@router.post("/me/change-password", status_code=204)
async def change_password(
payload: ChangePasswordRequest,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_session),
) -> None:
"""修改当前用户密码。"""
from app.models.user import User
user = await db.get(User, current_user.user_id)
if user is None:
from app.core.errors import AppError
raise AppError("not_found", "用户不存在。")
if not auth_service.verify_password(payload.old_password, user.password_hash):
from app.core.errors import AppError
raise AppError("unauthorized", "原密码错误。")
user.password_hash = auth_service.hash_password(payload.new_password)
await db.commit()
-5
View File
@@ -1,5 +0,0 @@
"""百工与司命路由(域实现见 app/domains/baigong/api.py)。"""
from app.domains.baigong.api import routers
__all__ = ["routers"]
@@ -1,60 +0,0 @@
"""角色卡导入 / 导出 API。
rewrite/1.0 M2b:导入产出**可审阅灵胚**(含 character_book 暂存),不再直接建人物;
经照心编辑与启镜(POST /embryo-drafts/{id}/activate)后才物化人物与世谱。
"""
import json
from fastapi import APIRouter, Depends, File, Response, UploadFile
from app.api.v1.deps import get_current_user
from app.domains import jingsheng
from app.schemas.auth import CurrentUser
from app.services.card_import import card_to_embryo_payload, export_character_card
router = APIRouter(prefix="/characters", tags=["characters"])
@router.post("/import-card", status_code=201)
async def import_character_card_api(
file: UploadFile = File(..., description="角色卡文件,支持 .png / .json / .charx。"),
current_user: CurrentUser = Depends(get_current_user),
) -> dict:
"""导入酒馆角色卡(V1/V2/V3)→ 生成可审阅灵胚。
卡内容整体进入灵胚道体草稿(identity_baseline.import_card),
``character_book`` 在启镜时物化为该人物的世谱书与条目。
"""
raw = await file.read()
payload = card_to_embryo_payload(raw)
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")
async def export_character_card_api(
character_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> Response:
"""导出人物为 ST V3 卡静态快照(.json,丢弃演化维度)。"""
card = await export_character_card(character_id, owner_scope=current_user.owner_scope)
return Response(
content=json.dumps(card, ensure_ascii=False, indent=2),
media_type="application/json",
headers={"Content-Disposition": 'attachment; filename="character_card.json"'},
)
-152
View File
@@ -1,152 +0,0 @@
"""人物 API。"""
from fastapi import APIRouter, Depends, Header, Query, Request
from app.api.v1.deps import get_current_user
from app.core.errors import AppError
from app.schemas.auth import CurrentUser
from app.schemas.character import (
ActivateCharacterResponse,
CharacterResponse,
CharacterSummaryResponse,
CharacterTemplateSummaryResponse,
CreateCharacterRequest,
UpdateModelPolicyRequest,
UpdateProfileRequest,
)
from app.schemas.common import Page
from app.services.characters import (
activate_character,
archive_character,
create_character,
get_active_character,
get_character_by_id,
list_characters_page,
list_system_templates_page,
pause_character,
resume_character,
update_character_model_policy,
update_character_profile,
)
from app.services.idempotency import run_idempotent
router = APIRouter(prefix="/characters", tags=["characters"])
@router.get("/active", response_model=CharacterResponse)
async def active_character(
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await get_active_character(owner_scope=current_user.owner_scope)
@router.get("/templates", response_model=list[CharacterTemplateSummaryResponse])
async def list_templates(
current_user: CurrentUser = Depends(get_current_user),
) -> list[CharacterTemplateSummaryResponse]:
"""列出所有系统模板,仅管理员可见(管理端完整 CRUD 见 ``/admin/characters/templates``)。"""
if not current_user.is_admin:
raise AppError("permission_denied", "仅管理员可查看系统模板。")
return await list_system_templates_page()
@router.get("", response_model=Page[CharacterSummaryResponse])
async def list_characters(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
status: str | None = Query(default=None, description="按人物状态过滤。"),
current_user: CurrentUser = Depends(get_current_user),
) -> Page[CharacterSummaryResponse]:
return await list_characters_page(
owner_scope=current_user.owner_scope, status=status, page=page, page_size=page_size
)
@router.post("", response_model=CharacterResponse)
async def create_character_api(
payload: CreateCharacterRequest,
request: Request,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
"""创建人物。
- 传 ``template_key`` 时从系统模板复制(仅管理员可用)。
- 不传 ``template_key`` 时创建空白人物(任何用户可用,status=wuji)。
"""
if payload.template_key and not current_user.is_admin:
raise AppError("permission_denied", "仅管理员可从系统模板创建人物。")
return await run_idempotent(
key=idempotency_key,
method="POST",
path=str(request.url.path),
payload=payload,
response_type=CharacterResponse,
resource_type="character",
handler=lambda session: create_character(
session, payload, owner_scope=current_user.owner_scope
),
owner_scope=current_user.owner_scope,
)
@router.get("/{character_id}", response_model=CharacterResponse)
async def get_character(
character_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await get_character_by_id(character_id, owner_scope=current_user.owner_scope)
@router.patch("/{character_id}/profile", response_model=CharacterResponse)
async def update_profile(
character_id: str,
payload: UpdateProfileRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await update_character_profile(
character_id, payload, owner_scope=current_user.owner_scope
)
@router.patch("/{character_id}/model-policy", response_model=CharacterResponse)
async def update_model_policy(
character_id: str,
payload: UpdateModelPolicyRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await update_character_model_policy(
character_id, payload, owner_scope=current_user.owner_scope
)
@router.post("/{character_id}/activate", response_model=ActivateCharacterResponse)
async def activate(
character_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> ActivateCharacterResponse:
return await activate_character(character_id, owner_scope=current_user.owner_scope)
@router.post("/{character_id}/pause", response_model=CharacterResponse)
async def pause(
character_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await pause_character(character_id, owner_scope=current_user.owner_scope)
@router.post("/{character_id}/resume", response_model=CharacterResponse)
async def resume(
character_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await resume_character(character_id, owner_scope=current_user.owner_scope)
@router.post("/{character_id}/archive", response_model=CharacterResponse)
async def archive(
character_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await archive_character(character_id, owner_scope=current_user.owner_scope)
@@ -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)
-63
View File
@@ -1,63 +0,0 @@
"""全局一炁事件 SSE 端点(非 run-scoped)。
供前端 useGlobalEventStream hook 订阅全局广播事件:
- ``proactive.session`` —— 叩心枢新建的主动会话
- ``proactive.message`` —— 叩心枢追加到已有会话的消息
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from typing import Any
from fastapi import APIRouter, Request
from sse_starlette.sse import EventSourceResponse
from app.core.config import settings
from app.services.event_store import DbEventStore
router = APIRouter(prefix="/events", tags=["events"])
def _get_event_store(request: Request) -> DbEventStore:
return request.app.state.event_store
@router.get("")
async def global_event_stream(
request: Request,
) -> EventSourceResponse:
"""全局事件 SSE 流:通过 EventStore.subscribe_by_pattern 监听所有
proactive.* 事件,推送给前端。"""
if not settings.enable_sse:
from app.core.errors import AppError
raise AppError("invalid_request", "SSE 当前未启用。")
event_store = _get_event_store(request)
return EventSourceResponse(_event_generator(event_store))
async def _event_generator(
event_store: DbEventStore,
) -> AsyncGenerator[dict[str, Any], None]:
queue = event_store.subscribe_by_pattern("proactive.*")
try:
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=settings.sse_heartbeat_seconds)
yield {
"id": event.id,
"event": event.type,
"data": event.model_dump_json(),
}
except TimeoutError:
yield {
"event": "heartbeat",
"data": '{"type":"heartbeat"}',
}
except asyncio.CancelledError:
pass
finally:
event_store.unsubscribe_by_pattern("proactive.*", queue)
-12
View File
@@ -1,12 +0,0 @@
"""健康检查 API。"""
from fastapi import APIRouter
from app.core.config import settings
router = APIRouter(tags=["health"])
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "app_name": settings.app_name, "env": settings.app_env}
-5
View File
@@ -1,5 +0,0 @@
"""河图世界路由(域实现见 app/domains/hetu/api.py)。"""
from app.domains.hetu.api import world_router
__all__ = ["world_router"]
-105
View File
@@ -1,105 +0,0 @@
"""资料导入 API(api-spec §8)。
JSON 提交与文件上传分两个端点(FastAPI 无法优雅地在单端点同时吃 JSON body
与 multipart);两者返回结构一致。路径挂在 ``/characters/{character_id}`` 下。
候选抽取为异步后台任务:触发即返回 job 状态,前端轮询 ``GET /imports/{id}``
(extracting_* → reviewing)与候选列表获取结果。
"""
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from app.api.v1.deps import get_current_user
from app.schemas.auth import CurrentUser
from app.schemas.common import Page
from app.schemas.imports import (
CreateImportRequest,
ImportItemResponse,
ImportJobResponse,
)
from app.services.imports import (
create_import_from_json,
create_import_from_upload,
get_import,
list_import_items,
list_jobs_page,
)
router = APIRouter(tags=["imports"])
@router.post("/characters/{character_id}/imports", response_model=ImportJobResponse)
async def create_import_json(
character_id: str,
payload: CreateImportRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> ImportJobResponse:
return await create_import_from_json(
character_id, payload, owner_scope=current_user.owner_scope
)
@router.post("/characters/{character_id}/imports/upload", response_model=ImportJobResponse)
async def create_import_upload(
character_id: str,
file: UploadFile = File(..., description="上传文件(文本或音频)。"),
title: str | None = Form(default=None),
source_description: str | None = Form(default=None),
speaker_label: str | None = Form(default=None, description="音频说话人标签。"),
consent_status: str = Form(default="unknown"),
usage_scope: str = Form(default="reference_only"),
current_user: CurrentUser = Depends(get_current_user),
) -> ImportJobResponse:
return await create_import_from_upload(
character_id,
file,
title=title,
source_description=source_description,
speaker_label=speaker_label,
consent_status=consent_status,
usage_scope=usage_scope,
owner_scope=current_user.owner_scope,
)
@router.get("/characters/{character_id}/imports", response_model=Page[ImportJobResponse])
async def list_imports(
character_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
current_user: CurrentUser = Depends(get_current_user),
) -> Page[ImportJobResponse]:
return await list_jobs_page(
character_id, page=page, page_size=page_size, owner_scope=current_user.owner_scope
)
@router.get(
"/characters/{character_id}/imports/{import_id}",
response_model=ImportJobResponse,
)
async def read_import(
character_id: str,
import_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> ImportJobResponse:
return await get_import(character_id, import_id, owner_scope=current_user.owner_scope)
@router.get(
"/characters/{character_id}/imports/{import_id}/items",
response_model=Page[ImportItemResponse],
)
async def read_import_items(
character_id: str,
import_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
current_user: CurrentUser = Depends(get_current_user),
) -> Page[ImportItemResponse]:
return await list_import_items(
character_id,
import_id,
page=page,
page_size=page_size,
owner_scope=current_user.owner_scope,
)
@@ -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"]
-127
View File
@@ -1,127 +0,0 @@
"""世谱 API。"""
from fastapi import APIRouter, Depends, Header, Query, Request
from app.api.v1.deps import get_current_user
from app.schemas.auth import CurrentUser
from app.schemas.lorebook import (
LorebookCreateRequest,
LorebookEntryCreateRequest,
LorebookEntryResponse,
LorebookEntryUpdateRequest,
LorebookResponse,
LorebookUpdateRequest,
)
from app.services.idempotency import run_idempotent
from app.services.lorebook import (
create_entry,
create_lorebook,
delete_entry,
delete_lorebook,
get_lorebook,
list_lorebooks,
update_entry,
update_lorebook,
)
router = APIRouter(prefix="/lorebooks", tags=["lorebooks"])
@router.post("", response_model=LorebookResponse)
async def create_lorebook_api(
payload: LorebookCreateRequest,
request: Request,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
current_user: CurrentUser = Depends(get_current_user),
) -> LorebookResponse:
return await run_idempotent(
key=idempotency_key,
method="POST",
path=str(request.url.path),
payload=payload,
response_type=LorebookResponse,
resource_type="lorebook",
handler=lambda session: create_lorebook(
session, payload, owner_scope=current_user.owner_scope
),
)
@router.get("", response_model=list[LorebookResponse])
async def list_lorebooks_api(
character_id: str | None = Query(default=None),
scope: str | None = Query(default=None),
current_user: CurrentUser = Depends(get_current_user),
) -> list[LorebookResponse]:
return await list_lorebooks(
character_id=character_id,
owner_scope=current_user.owner_scope,
scope=scope,
)
@router.get("/{lorebook_id}", response_model=LorebookResponse)
async def get_lorebook_api(
lorebook_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> LorebookResponse:
return await get_lorebook(lorebook_id, owner_scope=current_user.owner_scope)
@router.patch("/{lorebook_id}", response_model=LorebookResponse)
async def update_lorebook_api(
lorebook_id: str,
payload: LorebookUpdateRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> LorebookResponse:
return await update_lorebook(lorebook_id, payload, owner_scope=current_user.owner_scope)
@router.delete("/{lorebook_id}", response_model=LorebookResponse)
async def delete_lorebook_api(
lorebook_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> LorebookResponse:
return await delete_lorebook(lorebook_id, owner_scope=current_user.owner_scope)
@router.post("/{lorebook_id}/entries", response_model=LorebookEntryResponse)
async def create_entry_api(
lorebook_id: str,
payload: LorebookEntryCreateRequest,
request: Request,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
current_user: CurrentUser = Depends(get_current_user),
) -> LorebookEntryResponse:
return await run_idempotent(
key=idempotency_key,
method="POST",
path=str(request.url.path),
payload=payload,
response_type=LorebookEntryResponse,
resource_type="lorebook_entry",
handler=lambda session: create_entry(
session, lorebook_id, payload, owner_scope=current_user.owner_scope
),
)
@router.patch("/{lorebook_id}/entries/{entry_id}", response_model=LorebookEntryResponse)
async def update_entry_api(
lorebook_id: str,
entry_id: str,
payload: LorebookEntryUpdateRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> LorebookEntryResponse:
return await update_entry(
lorebook_id, entry_id, payload, owner_scope=current_user.owner_scope
)
@router.delete("/{lorebook_id}/entries/{entry_id}", status_code=204)
async def delete_entry_api(
lorebook_id: str,
entry_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> None:
await delete_entry(lorebook_id, entry_id, owner_scope=current_user.owner_scope)
-140
View File
@@ -1,140 +0,0 @@
"""MCP server API(/tools/mcp-servers,skills-design §6)。
支持两种传输协议:streamable-http / stdio。
stdio 模式通过 /start、/stop、/restart 控制子进程生命周期。
"""
from fastapi import APIRouter, Depends, Query
from fastapi import status as http_status
from app.integrations.mcp.process_manager import get_process_manager
from app.schemas.mcp import (
CreateMcpServerRequest,
McpServerResponse,
McpServerStatusResponse,
UpdateMcpServerRequest)
from app.services import mcp as mcp_service
router = APIRouter(prefix="/tools/mcp-servers", tags=["mcp"])
@router.post(
"",
response_model=McpServerResponse,
status_code=http_status.HTTP_201_CREATED)
async def register_mcp_server(
payload: CreateMcpServerRequest) -> McpServerResponse:
"""注册 MCP server。
streamable-http:同步拉取工具清单并注册到运行时。
stdio:只存 DB,需后续调用 /start 启动子进程后拉取工具。
"""
return await mcp_service.register_mcp_server(
name=payload.name,
transport=payload.transport,
endpoint_url=payload.endpoint_url,
auth_token=payload.auth_token,
command=payload.command,
args_json=payload.args_json,
env_json=payload.env_json,
cwd=payload.cwd,
status=payload.status,
process_manager=get_process_manager(),
)
@router.get("", response_model=list[McpServerResponse])
async def list_mcp_servers(
status: str | None = Query(default=None, description="按状态过滤:enabled/disabled")) -> list[McpServerResponse]:
"""列举 MCP server(含工具清单摘要)。"""
return await mcp_service.list_mcp_servers(status=status)
@router.patch(
"/{server_id}",
response_model=McpServerResponse)
async def update_mcp_server(
server_id: str,
payload: UpdateMcpServerRequest) -> McpServerResponse:
"""更新 MCP server 字段(部分更新)。"""
return await mcp_service.update_mcp_server(
server_id,
status=payload.status,
endpoint_url=payload.endpoint_url,
auth_token=payload.auth_token,
command=payload.command,
args_json=payload.args_json,
env_json=payload.env_json,
cwd=payload.cwd,
process_manager=get_process_manager(),
)
@router.delete("/{server_id}", status_code=http_status.HTTP_204_NO_CONTENT)
async def delete_mcp_server(
server_id: str) -> None:
"""删除 MCP server(先 stop 子进程再删)。"""
await mcp_service.delete_mcp_server(
server_id,
process_manager=get_process_manager(),
)
@router.post(
"/{server_id}/start",
response_model=McpServerStatusResponse)
async def start_mcp_server(
server_id: str) -> McpServerStatusResponse:
"""启动 stdio MCP server 子进程,拉取工具并注册。"""
return await mcp_service.start_mcp_server(
server_id,
process_manager=get_process_manager(),
)
@router.post(
"/{server_id}/stop",
response_model=McpServerStatusResponse)
async def stop_mcp_server(
server_id: str) -> McpServerStatusResponse:
"""停止 stdio MCP server 子进程,注销工具。"""
return await mcp_service.stop_mcp_server(
server_id,
process_manager=get_process_manager(),
)
@router.post(
"/{server_id}/restart",
response_model=McpServerStatusResponse)
async def restart_mcp_server(
server_id: str) -> McpServerStatusResponse:
"""重启 stdio MCP server。"""
return await mcp_service.restart_mcp_server(
server_id,
process_manager=get_process_manager(),
)
@router.get(
"/{server_id}/status",
response_model=McpServerStatusResponse)
async def get_mcp_server_status(
server_id: str) -> McpServerStatusResponse:
"""查询 MCP server 进程状态。"""
return await mcp_service.get_mcp_server_status(
server_id,
process_manager=get_process_manager(),
)
@router.post(
"/{server_id}/refresh",
response_model=McpServerResponse)
async def refresh_mcp_server_tools(
server_id: str) -> McpServerResponse:
"""重新拉取 MCP server 工具清单并注册到运行时。"""
return await mcp_service.refresh_mcp_server_tools(
server_id,
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"))
@@ -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)
-140
View File
@@ -1,140 +0,0 @@
"""Run 与 SSE API(Run 的启动契约见 kernel_runs.py)。"""
import asyncio
import json
from collections.abc import AsyncGenerator
from typing import Any
from fastapi import APIRouter, Header, Query
from sse_starlette.sse import EventSourceResponse
from app.core.config import settings
from app.core.errors import AppError
from app.core.time import to_iso, utc_now
from app.db.session import AsyncSessionLocal
from app.repositories import runtime as runtime_repo
from app.schemas.common import Page
from app.schemas.runtime import EventResponse, RunResponse
from app.services.event_bus import bus
from app.services.runtime import (
cancel_run,
get_run_response,
list_run_event_responses,
list_runs_response,
)
router = APIRouter(prefix="/runs", tags=["runs"])
@router.get("", response_model=Page[RunResponse])
async def list_runs(
character_id: str | None = Query(default=None, description="按人物过滤。"),
status: str | None = Query(default=None, description="按状态过滤。"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> Page[RunResponse]:
"""Run 列表(按创建时间倒序);归档页运行时间河数据源。"""
return await list_runs_response(
character_id=character_id,
status=status,
page=page,
page_size=page_size,
)
@router.get("/{run_id}", response_model=RunResponse)
async def get_run(run_id: str) -> RunResponse:
return await get_run_response(run_id)
@router.get("/{run_id}/events", response_model=list[EventResponse])
async def list_run_events(
run_id: str,
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[EventResponse]:
"""Run 的事件时间线(按 sequence 升序)。"""
return await list_run_event_responses(run_id, limit=limit, offset=offset)
@router.post("/{run_id}/cancel", response_model=RunResponse)
async def cancel_run_api(run_id: str) -> RunResponse:
return await cancel_run(run_id)
@router.get("/{run_id}/stream")
async def stream_run(
run_id: str,
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
) -> EventSourceResponse:
"""Run 事件流(SSE):按 run_id 实时订阅事件增量。
事件通过进程内 ``bus`` 实时推送(``append_event`` 自动 publish),
DB 持久化事件供断线重连(``Last-Event-ID`` 回放)。
"""
if not settings.enable_sse:
raise AppError("invalid_request", "SSE 当前未启用。")
async with AsyncSessionLocal() as session:
run = await runtime_repo.get_run(session, run_id)
if run is None:
raise AppError("not_found", "Run 不存在。")
return EventSourceResponse(_event_generator(run_id, last_event_id))
async def _event_generator(
run_id: str, last_event_id: str | None
) -> AsyncGenerator[dict[str, Any], None]:
# 断线重连:解析 Last-Event-ID 对应的 sequence 作为回放起点
after_sequence = 0
if last_event_id:
async with AsyncSessionLocal() as session:
previous_event = await runtime_repo.get_event(session, last_event_id)
if previous_event and previous_event.run_id == run_id:
after_sequence = previous_event.sequence
last_sequence = after_sequence
# 先订阅再回放,避免回放与订阅之间的事件落入间隙
queue = bus.subscribe(run_id)
try:
for event in bus.list_after(run_id, after_sequence=last_sequence):
last_sequence = event.sequence
yield {
"id": event.event_id,
"event": event.type,
"data": event.model_dump_json(),
}
if bus.is_terminal(event):
return
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=settings.sse_heartbeat_seconds)
except TimeoutError:
yield {
"event": "heartbeat",
"data": json.dumps(
{
"event_id": "heartbeat",
"run_id": run_id,
"type": "heartbeat",
"sequence": last_sequence,
"visibility": "public",
"payload": {},
"created_at": to_iso(utc_now()),
},
ensure_ascii=False,
),
}
continue
if event.sequence <= last_sequence:
continue # 回放阶段已转发,去重
last_sequence = event.sequence
yield {
"id": event.event_id,
"event": event.type,
"data": event.model_dump_json(),
}
if bus.is_terminal(event):
return
finally:
bus.unsubscribe(run_id, queue)
-30
View File
@@ -1,30 +0,0 @@
"""安全 API(§16):审计时间线查询。
工具策略治理与确认流已由百工域承接(/capabilities /mandates /petitions);
本路由保留 audit-events(行动账时间线)。
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from app.api.v1.deps import get_current_user
from app.schemas.admin import AuditEntry
from app.schemas.auth import CurrentUser
from app.schemas.common import Page
from app.services import audit as audit_service
router = APIRouter(prefix="/safety", tags=["safety"])
@router.get("/audit-events", response_model=Page[AuditEntry])
async def list_audit_events(
character_id: str | None = Query(default=None, description="按人物过滤。"),
pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
_user: CurrentUser = Depends(get_current_user),
) -> Page[AuditEntry]:
return await audit_service.query_audit_events(
character_id=character_id, pivot=pivot, page=page, page_size=page_size
)
@@ -1,154 +0,0 @@
"""沙箱文件预览 API。
通过此端点,前端可以读取沙箱中由工具写入的文件内容,实现源码/渲染双模式预览。
使用场景:
- write_file 写入 HTML 文件 → 前端 iframe 渲染预览
- write_file 写入 SVG → 前端 img 展示
- shell 生成图片/文档 → 前端按 MIME 类型渲染
- 任何工具的 stdout 生成的文件
安全约束:
- 用户必须经过 JWT 鉴权
- 路径由沙箱端校验(防逃逸)
- 可选 run_id/character_id 绑定授权上下文
"""
from __future__ import annotations
import base64
import mimetypes
from fastapi import APIRouter, Depends, HTTPException, Query
from starlette.responses import Response
from app.api.v1.deps import get_current_user, get_sandbox_client
from app.domains.baigong import files as baigong_files
from app.integrations.sandbox.client import SandboxClient
from app.integrations.sandbox.exceptions import SandboxError, SandboxNotFound
from app.schemas.auth import CurrentUser
router = APIRouter(prefix="/sandbox", tags=["sandbox"])
@router.get("/file")
async def read_sandbox_file(
path: str = Query(
...,
description="文件路径。isolated=True 时视为工作区相对路径"
"(如 write_file 传入的 ``path`` 参数);"
"isolated=False 时作为沙箱 base 下的完整相对路径。",
),
run_id: str | None = Query(
None,
description="关联的 run_id(可选,写入授权上下文)。仅用于审计链路,不影响文件可读性。",
),
character_id: str | None = Query(
None,
description="人物 ID。提供时自动构造隔离工作区路径"
"(``workspace/<character_id>/<path>``),"
"与 write_file/shell 等工具的 isolated_workspace 路径一致。",
),
isolated: bool = Query(
True,
description="是否自动按 isolated_workspace 规则补全路径前缀。"
"write_file 写入的文件在 workspace/<character_id>/ 下,"
"传递 write_file 的 path 时保持 True 即可。",
),
sandbox: SandboxClient = Depends(get_sandbox_client),
_current_user: CurrentUser = Depends(get_current_user),
) -> Response:
"""读取沙箱内文件,以原始字节返回(MIME 类型由路径扩展名推导)。
前端根据 MIME 类型自行决定展示方式:
- ``text/html`` → iframe sandbox 渲染预览
- ``image/*`` → img 标签展示
- ``text/*`` → 源码高亮
- ``application/pdf`` → PDF 内嵌展示
- 其他 → 下载
路径安全:沙箱端会对路径做 canonicalize + starts_with(base) 校验,
防止目录穿越到沙箱 base 之外。
"""
# 1) 推导 MIME 类型
mime_type, _ = mimetypes.guess_type(path)
if mime_type is None:
mime_type = "application/octet-stream"
# 2) 从沙箱读取
try:
result = await baigong_files.read_sandbox_file(
sandbox,
path,
run_id=run_id,
character_id=character_id,
isolated=isolated,
)
except SandboxNotFound:
raise HTTPException(
status_code=404,
detail={"error": {"code": "file_not_found", "message": f"文件不存在: {path}"}},
) from None
except SandboxError as exc:
raise HTTPException(
status_code=502,
detail={
"error": {
"code": "sandbox_error",
"message": f"沙箱文件读取失败: {exc}",
}
},
) from None
# 3) base64url-no-pad → raw bytes
pad_len = -len(result.content_b64) % 4
content = base64.urlsafe_b64decode(result.content_b64 + "=" * pad_len)
return Response(content=content, media_type=mime_type)
@router.get("/files")
async def list_sandbox_files(
path: str = Query(
".",
description="目录路径。默认工作区根。",
),
run_id: str | None = Query(None, description="关联的 run_id(可选)"),
character_id: str | None = Query(
None,
description="人物 ID。提供时自动应用 isolated_workspace 隔离前缀。",
),
isolated: bool = Query(True, description="是否自动补全隔离前缀"),
sandbox: SandboxClient = Depends(get_sandbox_client),
_current_user: CurrentUser = Depends(get_current_user),
) -> list[dict[str, object]]:
"""列出沙箱工作区目录内容。
返回有序列表,每个条目含 ``name`` / ``kind``(``"file"`` | ``"dir"``)/ ``size``。
适用于前端文件浏览器导航。
"""
try:
entries = await baigong_files.list_sandbox_dir(
sandbox,
path,
run_id=run_id,
character_id=character_id,
isolated=isolated,
)
except SandboxNotFound:
raise HTTPException(
status_code=404,
detail={"error": {"code": "dir_not_found", "message": f"目录不存在: {path}"}},
) from None
except SandboxError as exc:
raise HTTPException(
status_code=502,
detail={
"error": {
"code": "sandbox_error",
"message": f"沙箱目录读取失败: {exc}",
}
},
) from None
return [{"name": e.name, "kind": e.kind, "size": e.size} for e in entries]
-35
View File
@@ -1,35 +0,0 @@
"""设置 API(生成偏好)。
生成偏好可读可写;模型网关状态由神识承接(用户面见神识阁)。
业务逻辑在 services/preferences(与系统配置 core/config/settings 区分)。
"""
from fastapi import APIRouter, Depends
from app.api.v1.deps import get_current_user
from app.schemas.auth import CurrentUser
from app.schemas.preferences import (
GenerationPreferencesResponse,
UpdateGenerationPreferencesRequest,
)
from app.services.preferences import (
get_generation_preferences,
update_generation_preferences,
)
router = APIRouter(prefix="/settings", tags=["settings"])
@router.get("/preferences", response_model=GenerationPreferencesResponse)
async def read_generation_preferences(
current_user: CurrentUser = Depends(get_current_user),
) -> GenerationPreferencesResponse:
return await get_generation_preferences(owner_scope=current_user.owner_scope)
@router.patch("/preferences", response_model=GenerationPreferencesResponse)
async def update_generation_preferences_api(
payload: UpdateGenerationPreferencesRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> GenerationPreferencesResponse:
return await update_generation_preferences(payload, owner_scope=current_user.owner_scope)
-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"]
-37
View File
@@ -1,37 +0,0 @@
"""工具 API(§15 兼容面):清单走百工能力目录,调用走 /capabilities/{id}/invoke。
旧 ToolRuntime/策略治理端点已随 M5 百工重写退役;本路由只保留
`/tools`(能力目录投影)与 `/tools/calls/{id}`(一炁事件详情)。
"""
from fastapi import APIRouter, Depends
from app.api.v1.deps import get_current_user
from app.domains.baigong import service as baigong
from app.schemas.auth import CurrentUser
from app.schemas.safety import ToolCallDetail
from app.services.runtime import get_tool_call_detail
router = APIRouter(prefix="/tools", tags=["tools"])
@router.get("")
async def list_tools(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
"""能力目录投影(新链路以 /capabilities 为准;此处给旧消费方过渡读取)。"""
caps = await baigong.list_capabilities(user.owner_scope)
return [
{
"name": c["name"],
"description": c["description"],
"risk_level": max(c["risk_dims"].values(), default="L1"),
"permission_policy": "ask" if c["consent_mode"] == "ask" else "allow",
"enabled": c["enabled"],
"source": c["source"],
}
for c in caps
]
@router.get("/calls/{tool_call_id}", response_model=ToolCallDetail)
async def get_tool_call_detail(tool_call_id: str) -> ToolCallDetail:
return await get_tool_call_detail(tool_call_id)
-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"]
View File
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@
"""认证模块:Web 会话与依赖。"""
+56
View File
@@ -0,0 +1,56 @@
"""认证依赖:当前用户解析与状态变更防护。"""
from typing import Annotated
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.service import SESSION_COOKIE, resolve_session
from app.db.session import get_db
from app.models import User
ALLOWED_ORIGINS: set[str] = set() # 由配置注入;空集表示本地开发放行同源
async def get_current_user(
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
token = request.cookies.get(SESSION_COOKIE)
if token is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "未登录")
session = await resolve_session(db, token)
if session is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "会话无效或已过期")
user = await db.get(User, session.user_id)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "会话主体不存在")
# 请求级上下文供后续依赖使用(不重复查询)
request.state.session_id = session.id
request.state.session_csrf_hash = session.csrf_token_hash
return user
async def require_state_change(
request: Request,
user: Annotated[User, Depends(get_current_user)],
hunjing_csrf_token: Annotated[str | None, Header(alias="Hunjing-CSRF-Token")] = None,
) -> User:
"""Cookie 状态变更:Origin 校验 + CSRF 双提交(契约 2.1 节)。"""
if ALLOWED_ORIGINS:
origin = request.headers.get("Origin")
if origin is not None and origin not in ALLOWED_ORIGINS:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Origin 不被允许")
csrf_hash = getattr(request.state, "session_csrf_hash", None)
if csrf_hash is None or not _csrf_matches(csrf_hash, hunjing_csrf_token):
raise HTTPException(status.HTTP_403_FORBIDDEN, "CSRF 校验失败")
return user
def _csrf_matches(expected_hash: str, provided: str | None) -> bool:
import hashlib
import secrets
if provided is None:
return False
return secrets.compare_digest(expected_hash, hashlib.sha256(provided.encode()).hexdigest())
+60
View File
@@ -0,0 +1,60 @@
"""认证路由:开发档登录/登出(Web Session Cookie)。
正式 OAuth 2.1 授权码流程(/oauth2/authorize、/oauth2/token、PKCE)属 WP-01
后续条目;本路由只建立 Session 边界骨架供垂直切片验证。
"""
from fastapi import APIRouter, HTTPException, Request, Response, status
from app.auth.service import (
SESSION_COOKIE,
create_session,
revoke_session,
)
from app.db.session import DbSession
from app.models import PersonalDomain, User
router = APIRouter(prefix="/v1/auth", tags=["auth"])
@router.post("/sessions", status_code=201)
async def dev_login(
request: Request, response: Response, db: DbSession, body: dict[str, str]
) -> dict[str, str]:
"""开发档登录:按 email 找到或创建用户与个人域,签发会话。"""
email = body.get("email", "").strip().lower()
if not email or "@" not in email:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "email 不合法")
from sqlalchemy import select
existing = (
(await db.execute(select(User).where(User.email == email))).scalar_one_or_none()
)
if existing is None:
user = User(email=email, display_name=email.split("@")[0])
db.add(user)
await db.flush()
domain = PersonalDomain(user_id=user.id, name="默认镜域")
db.add(domain)
else:
user = existing
_, token, csrf = await create_session(db, user.id)
await db.commit()
response.set_cookie(
SESSION_COOKIE,
token,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
return {"csrf_token": csrf, "user_id": user.id}
@router.delete("/sessions/current", status_code=204)
async def logout(request: Request, response: Response, db: DbSession) -> None:
token = request.cookies.get(SESSION_COOKIE)
if token is not None and await revoke_session(db, token):
await db.commit()
response.delete_cookie(SESSION_COOKIE, path="/")
+71
View File
@@ -0,0 +1,71 @@
"""Web 会话认证:签发、校验与撤销(契约 api-conventions 第 2 节)。
- Cookie 名 ``__Host-hunjing_session``:HttpOnly、Secure、SameSite=Lax、Path=/。
- Cookie 值为 CSPRNG opaque token;数据库只存 SHA-256 哈希(ADR-015)。
- 状态变更请求要求 Origin 校验与 ``Hunjing-CSRF-Token`` 双提交。
"""
import hashlib
import secrets
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Session
SESSION_COOKIE = "__Host-hunjing_session"
SESSION_TTL = timedelta(days=14)
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def generate_session_token() -> str:
return secrets.token_urlsafe(32)
def generate_csrf_token() -> str:
return secrets.token_urlsafe(24)
async def create_session(db: AsyncSession, user_id: str) -> tuple[Session, str, str]:
"""签发会话:返回(会话记录,opaque token,CSRF token)。"""
token = generate_session_token()
csrf = generate_csrf_token()
session = Session(
id=f"web_{secrets.token_urlsafe(12)}",
user_id=user_id,
token_hash=_hash_token(token),
csrf_token_hash=_hash_token(csrf),
expires_at=datetime.now(UTC) + SESSION_TTL,
)
db.add(session)
return session, token, csrf
async def resolve_session(db: AsyncSession, token: str) -> Session | None:
"""按 opaque token 查找有效(未过期、未撤销)会话。"""
result = await db.execute(
select(Session).where(
Session.token_hash == _hash_token(token),
Session.revoked_at.is_(None),
Session.expires_at > datetime.now(UTC),
)
)
return result.scalar_one_or_none()
async def revoke_session(db: AsyncSession, token: str) -> bool:
session = await resolve_session(db, token)
if session is None:
return False
session.revoked_at = datetime.now(UTC)
return True
def session_is_csrf_valid(session: Session, csrf_header: str | None) -> bool:
if csrf_header is None:
return False
return secrets.compare_digest(session.csrf_token_hash, _hash_token(csrf_header))
+1
View File
@@ -0,0 +1 @@
"""角色域:创建、生命周期、版本链与内驱力。"""
+239
View File
@@ -0,0 +1,239 @@
"""角色域路由(WP-02)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.characters import service
from app.conversations.routes import _domain_of # 复用域解析
from app.db.session import DbSession
from app.models import Character, CharacterVersion, User
router = APIRouter(prefix="/v1/characters", tags=["characters"])
class DriveIn(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
description: str
intensity: int = Field(default=1, ge=1, le=5)
class DrivesUpdateIn(BaseModel):
model_config = ConfigDict(extra="forbid")
drives: list[DriveIn] = Field(max_length=16)
def _character_out(c: Character) -> dict[str, Any]:
return {
"id": c.id,
"name": c.name,
"status": c.status,
"origin": c.origin,
"based_on_real_person": c.based_on_real_person,
"identity_baseline": c.identity_baseline,
"drives": c.drives,
"current_version_id": c.current_version_id,
"created_at": c.created_at.isoformat(),
}
def _version_out(v: CharacterVersion) -> dict[str, Any]:
return {
"id": v.id,
"character_id": v.character_id,
"kind": v.kind,
"status": v.status,
"payload": v.payload,
"created_at": v.created_at.isoformat(),
}
@router.get("")
async def list_characters(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
limit: int = 50,
cursor: str | None = None,
) -> dict[str, Any]:
if not 1 <= limit <= 200:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "limit 取值范围 1-200")
domain = await _domain_of(db, user)
stmt = select(Character).where(Character.personal_domain_id == domain.id)
stmt = stmt.order_by(Character.created_at.asc(), Character.id.asc()).limit(limit + 1)
rows = list((await db.scalars(stmt)).all())
has_more = len(rows) > limit
return {
"data": [_character_out(c) for c in rows[:limit]],
"next_cursor": rows[limit].id if has_more else None,
"has_more": has_more,
}
@router.post("", status_code=201)
async def create_character(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
character = await service.create_character(
db, domain.id, body, getattr(request.state, "trace_id", None)
)
return _character_out(character)
@router.get("/{character_id}")
async def get_character(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
character = await service.require_character(db, domain.id, character_id)
return _character_out(character)
@router.post("/{character_id}/activations", status_code=201)
async def activate(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
character = await service.activate_character(
db, domain.id, character_id, getattr(request.state, "trace_id", None)
)
return _character_out(character)
@router.post("/{character_id}/first-statement", status_code=201)
async def submit_first_statement(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.submit_first_statement(
db, domain.id, character_id, body.get("text", ""),
getattr(request.state, "trace_id", None),
)
return _version_out(version)
@router.post("/{character_id}/first-statement/confirmations", status_code=201)
async def confirm_first_statement(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.confirm_first_statement(
db, domain.id, character_id, body.get("version_id", ""),
getattr(request.state, "trace_id", None),
)
return _version_out(version)
LIFECYCLE_ACTIONS = {
"rests": "rest",
"archives": "archive",
"revivals": "revive",
"death-requests": "request_death",
"death-cancellations": "cancel_death",
"death-confirmations": "confirm_death",
}
@router.post("/{character_id}/reforgings", status_code=201)
async def reforge(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.reforge_character(
db, domain.id, character_id, body, getattr(request.state, "trace_id", None)
)
return _version_out(version)
@router.patch("/{character_id}/drives")
async def update_drives(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: DrivesUpdateIn,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
await service.update_drives(
db, domain.id, character_id,
[d.model_dump() for d in body.drives], getattr(request.state, "trace_id", None),
)
character = await service.require_character(db, domain.id, character_id)
return _character_out(character)
@router.post("/{character_id}/persona-candidates", status_code=201)
async def submit_persona_candidate(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.submit_persona_candidate(
db, domain.id, character_id, body, getattr(request.state, "trace_id", None)
)
return _version_out(version)
@router.post("/{character_id}/versions/{version_id}/decisions", status_code=201)
async def review_version(
character_id: str,
version_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, bool],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.review_version(
db, domain.id, character_id, version_id, bool(body.get("approve")),
getattr(request.state, "trace_id", None),
)
return _version_out(version)
@router.post("/{character_id}/{action}", status_code=200)
async def lifecycle(
character_id: str,
action: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
if action not in LIFECYCLE_ACTIONS:
raise HTTPException(status.HTTP_404_NOT_FOUND, "未知生命周期动作")
domain = await _domain_of(db, user)
character = await service.lifecycle_action(
db, domain.id, character_id, LIFECYCLE_ACTIONS[action],
getattr(request.state, "trace_id", None),
)
return _character_out(character)
+326
View File
@@ -0,0 +1,326 @@
"""角色域:创建、生命周期、版本链与内驱力(WP-02 / ADR-018 / ADR-020 前吸收决策)。"""
from datetime import UTC, datetime
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditEvent, Character, CharacterVersion, OutboxEvent
DEATH_COOLING_OFF_DAYS = 7 # 死亡冷静期(天):期间默认静修,可撤销
# 状态机(docs/architecture + cultural-language 生命周期五态)
ALLOWED_TRANSITIONS: dict[str, set[str]] = {
"draft": {"active"}, # 启镜
"active": {"resting", "archived", "death_pending"},
"resting": {"active", "archived", "death_pending"},
"archived": {"active"}, # 复照(不抹历史)
"death_pending": {"active", "deceased"}, # 撤销(复照)或确认死亡
"deceased": set(), # 不可逆;数据删除另行确认
}
# 随缘词库:确定性组合生成匿名灵胚候选(不是抽卡,不分配已有主体)
_SERENDIPITY_GIVEN = ["云", "汀", "昭", "霁", "澜", "朔", "棠", "砚"]
_SERENDIPITY_FAMILY = ["尘", "星", "屿", "谷", "灯", "川", "铃", "丘"]
_SERENDIPITY_TRAITS = [
{"name": "好奇", "description": "对未见过的事物保持追问"},
{"name": "沉稳", "description": "先观察再行动,不易被催促"},
{"name": "温热", "description": "在意身边人的冷暖"},
{"name": "执拗", "description": "认定的事情会走到底"},
]
def serendipity_seed(seed: int) -> dict[str, Any]:
"""按用户偏好种子确定性生成灵胚候选;同种子同候选,可复现。"""
given = _SERENDIPITY_GIVEN[seed % len(_SERENDIPITY_GIVEN)]
family = _SERENDIPITY_FAMILY[(seed // len(_SERENDIPITY_GIVEN)) % len(_SERENDIPITY_FAMILY)]
trait = _SERENDIPITY_TRAITS[seed % len(_SERENDIPITY_TRAITS)]
return {
"name": f"{given}{family}",
"identity_baseline": {"core_traits": [trait["name"]], "seed": seed},
"drives": [trait],
}
async def require_character(
db: AsyncSession, domain_id: str, character_id: str
) -> Character:
character = await db.get(Character, character_id)
if character is None or character.personal_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "角色不存在")
return character
def _transition(character: Character, to_status: str) -> None:
allowed = ALLOWED_TRANSITIONS.get(character.status, set())
if to_status not in allowed:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"不允许的状态迁移:{character.status} → {to_status}",
)
def _audit(
db: AsyncSession, domain_id: str, action: str, character: Character,
payload: dict[str, Any] | None = None, trace_id: str | None = None,
) -> None:
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action=action,
aggregate_type="character", aggregate_id=character.id,
payload=payload or {}, trace_id=trace_id,
)
)
async def create_character(
db: AsyncSession, domain_id: str, body: dict[str, Any], trace_id: str | None
) -> Character:
"""创建灵胚(draft)。现实人物治理:仅本人/可验证授权;已故现实人物不开放。"""
based_on_real = bool(body.get("based_on_real_person", False))
if based_on_real:
if body.get("deceased_real_person"):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "已故现实人物不开放创建角色"
)
if not body.get("real_person_attested"):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"现实人物角色需要本人或可验证授权声明(real_person_attested)",
)
origin = body.get("origin", "original")
if origin == "serendipity":
seed_data = serendipity_seed(int(body.get("seed", 0)))
name = body.get("name") or seed_data["name"]
baseline = body.get("identity_baseline") or seed_data["identity_baseline"]
drives = body.get("drives") or seed_data["drives"]
else:
name = body.get("name", "")
if not name.strip():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "name 不能为空")
baseline = body.get("identity_baseline", {})
drives = body.get("drives", [])
character = Character(
personal_domain_id=domain_id,
name=name,
status="draft",
origin=origin,
based_on_real_person=based_on_real,
real_person_attested=bool(body.get("real_person_attested", False)),
identity_baseline=baseline,
drives=drives,
)
db.add(character)
await db.flush()
_audit(db, domain_id, "character.created", character, {"origin": origin}, trace_id)
db.add(
OutboxEvent(
aggregate_type="character", aggregate_id=character.id,
event_type="character.created", payload={"character_id": character.id},
)
)
await db.flush()
return character
async def activate_character(
db: AsyncSession, domain_id: str, character_id: str, trace_id: str | None
) -> Character:
"""启镜:灵胚 → active,签发首个 baseline 版本并设为当前版本。"""
character = await require_character(db, domain_id, character_id)
_transition(character, "active")
character.status = "active"
version = CharacterVersion(
character_id=character.id,
kind="baseline",
status="approved",
payload={
"name": character.name,
"identity_baseline": character.identity_baseline,
"drives": character.drives,
},
)
db.add(version)
await db.flush()
character.current_version_id = version.id
_audit(db, domain_id, "character.activated", character, {"version_id": version.id}, trace_id)
await db.flush()
return character
async def submit_first_statement(
db: AsyncSession, domain_id: str, character_id: str, text: str, trace_id: str | None
) -> CharacterVersion:
"""初照自陈:启镜后首次自我陈述,先为候选,确认(归真)后进 baseline。"""
character = await require_character(db, domain_id, character_id)
if character.status != "active":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "仅启镜后的角色可提交初照自陈")
existing = await db.execute(
select(CharacterVersion).where(
CharacterVersion.character_id == character.id,
CharacterVersion.kind == "first_statement",
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "初照自陈只能提交一次;修订请走版本修订")
version = CharacterVersion(
character_id=character.id,
kind="first_statement",
status="pending",
payload={"text": text},
)
db.add(version)
await db.flush()
_audit(db, domain_id, "character.first_statement_submitted", character, {}, trace_id)
await db.flush()
return version
async def confirm_first_statement(
db: AsyncSession, domain_id: str, character_id: str, version_id: str, trace_id: str | None
) -> CharacterVersion:
"""确认初照自陈(归真):版本转 approved,并把自陈并入当前 baseline。"""
character = await require_character(db, domain_id, character_id)
version = await db.get(CharacterVersion, version_id)
if version is None or version.character_id != character.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "版本不存在")
if version.kind != "first_statement":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "仅初照自陈版本可走此确认")
if version.status == "approved":
return version # 幂等
version.status = "approved"
character.identity_baseline = {
**character.identity_baseline, "first_statement": version.payload.get("text", "")
}
character.current_version_id = version.id
_audit(db, domain_id, "character.first_statement_canonized", character, {}, trace_id)
await db.flush()
return version
async def lifecycle_action(
db: AsyncSession, domain_id: str, character_id: str, action: str, trace_id: str | None
) -> Character:
"""静休/归藏/复照与死亡两步:状态机迁移 + 审计;非法迁移 422。"""
character = await require_character(db, domain_id, character_id)
mapping = {
"rest": ("resting", "character.rest"),
"archive": ("archived", "character.archived"),
"revive": ("active", "character.revived"),
"request_death": ("death_pending", "character.death_requested"),
"cancel_death": ("active", "character.death_cancelled"),
"confirm_death": ("deceased", "character.death_confirmed"),
}
if action not in mapping:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "未知生命周期动作")
to_status, audit_action = mapping[action]
_transition(character, to_status)
if action == "request_death":
character.death_requested_at = datetime.now(UTC)
if action == "cancel_death":
character.death_requested_at = None
if action == "confirm_death" and character.death_requested_at is not None:
elapsed = datetime.now(UTC) - character.death_requested_at
if elapsed.days < DEATH_COOLING_OFF_DAYS:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"死亡冷静期未满({DEATH_COOLING_OFF_DAYS} 天),期间可撤销",
)
character.status = to_status
_audit(db, domain_id, audit_action, character, {"to": to_status}, trace_id)
await db.flush()
return character
async def reforge_character(
db: AsyncSession, domain_id: str, character_id: str, payload: dict[str, Any],
trace_id: str | None,
) -> CharacterVersion:
"""铸魂:核心身份与价值底线的正式重塑(强确认:confirm=true 才生效)。"""
if not payload.get("confirm"):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "铸魂需要显式确认(confirm=true)"
)
character = await require_character(db, domain_id, character_id)
if character.status != "active":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "仅启镜状态可铸魂")
version = CharacterVersion(
character_id=character.id, kind="reforging", status="approved", payload=payload
)
db.add(version)
await db.flush()
if "identity_baseline" in payload:
character.identity_baseline = payload["identity_baseline"]
character.current_version_id = version.id
_audit(db, domain_id, "character.reforged", character, {"version_id": version.id}, trace_id)
db.add(
OutboxEvent(
aggregate_type="character", aggregate_id=character.id,
event_type="character.reforged", payload={"version_id": version.id},
)
)
await db.flush()
return version
async def update_drives(
db: AsyncSession, domain_id: str, character_id: str, drives: list[dict[str, Any]],
trace_id: str | None,
) -> CharacterVersion:
"""内驱力编辑:直接写当前值并落 drive_revision 版本(可追溯,ADR-018)。"""
character = await require_character(db, domain_id, character_id)
if character.status not in {"active", "resting"}:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "当前状态不可编辑内驱力")
character.drives = drives
version = CharacterVersion(
character_id=character.id, kind="drive_revision", status="approved",
payload={"drives": drives},
)
db.add(version)
await db.flush()
character.current_version_id = version.id
_audit(db, domain_id, "character.drives_updated", character, {}, trace_id)
await db.flush()
return version
async def submit_persona_candidate(
db: AsyncSession, domain_id: str, character_id: str, payload: dict[str, Any],
trace_id: str | None,
) -> CharacterVersion:
"""人格变化候选(大周天产物或人工提议):pending,经审核才生效。"""
character = await require_character(db, domain_id, character_id)
version = CharacterVersion(
character_id=character.id, kind="persona_candidate", status="pending", payload=payload
)
db.add(version)
await db.flush()
_audit(db, domain_id, "character.persona_candidate_submitted", character, {}, trace_id)
await db.flush()
return version
async def review_version(
db: AsyncSession, domain_id: str, character_id: str, version_id: str,
approve: bool, trace_id: str | None,
) -> CharacterVersion:
"""审核版本候选:批准时人格候选并入道体(不静默覆盖——显式用户决定)。"""
character = await require_character(db, domain_id, character_id)
version = await db.get(CharacterVersion, version_id)
if version is None or version.character_id != character.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "版本不存在")
if version.status != "pending":
raise HTTPException(status.HTTP_409_CONFLICT, "版本不在待审核状态")
version.status = "approved" if approve else "rejected"
if approve and version.kind == "persona_candidate" and "identity_baseline" in version.payload:
character.identity_baseline = version.payload["identity_baseline"]
character.current_version_id = version.id
_audit(
db, domain_id,
"character.version_approved" if approve else "character.version_rejected",
character, {"version_id": version.id}, trace_id,
)
await db.flush()
return version
+1
View File
@@ -0,0 +1 @@
"""对话域:schema、幂等原子事务服务与路由。"""
+330
View File
@@ -0,0 +1,330 @@
"""对话域路由:创建会话、发送消息(幂等原子)与 Run SSE。"""
import json
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.conversations import service
from app.conversations.schemas import MessageCreatedOut, MessageIn, MessageOut, RunOut
from app.db.session import DbSession
from app.models import PersonalDomain, Run, User
router = APIRouter(prefix="/v1/conversations", tags=["conversations"])
async def _domain_of(db: DbSession, user: User) -> PersonalDomain:
result = await db.execute(
select(PersonalDomain).where(PersonalDomain.user_id == user.id).limit(1)
)
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status.HTTP_409_CONFLICT, "用户缺少个人域")
return domain
@router.post("", status_code=201)
async def create_conversation(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, str]:
domain = await _domain_of(db, user)
conversation = await service.create_conversation(
db, domain.id, body.get("title", "未命名对话"), getattr(request.state, "trace_id", None)
)
return {"id": conversation.id, "title": conversation.title}
@router.post(
"/{conversation_id}/messages",
status_code=201,
response_model=None,
)
async def post_message(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
background_tasks: BackgroundTasks,
body: MessageIn,
idempotency_key: Annotated[str, Header(alias="Idempotency-Key")],
) -> MessageCreatedOut | JSONResponse:
if not idempotency_key or len(idempotency_key) > 128:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Idempotency-Key 不合法")
domain = await _domain_of(db, user)
message, run, replayed = await service.replay_or_execute_message(
db,
domain.id,
conversation_id,
idempotency_key,
body,
getattr(request.state, "trace_id", None),
)
payload = MessageCreatedOut(
message=MessageOut(
id=message.id,
conversation_id=message.conversation_id,
branch_id=message.branch_id,
role=message.role,
content=message.content,
parser_version=message.parser_version,
created_at=message.created_at.isoformat(),
),
run=(
RunOut(
id=run.id,
conversation_id=run.conversation_id,
status=run.status,
created_at=run.created_at.isoformat(),
completed_at=run.completed_at.isoformat() if run.completed_at else None,
)
if run is not None
else None
),
)
if replayed:
# 重放成功按契约返回 200 与原资源(避开装饰器固定 201)
return JSONResponse(status_code=200, content=json.loads(payload.model_dump_json()))
if run is not None:
# 先显式提交:确保 Run 在后台任务执行前已落库(与依赖清理顺序解耦)
await db.commit()
from app.gateway import execute_run
background_tasks.add_task(
execute_run,
request.app.state.session_factory,
run.id,
transport=getattr(request.app.state, "gw_transport", None),
)
return payload
run_router = APIRouter(prefix="/v1/runs", tags=["runs"])
@run_router.get("/{run_id}/events")
async def run_events(
run_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> StreamingResponse:
"""Run SSE:重放持久化事件流(sequence 单调),客户端按序去重(契约第 5 节)。"""
result = await db.execute(select(Run).where(Run.id == run_id))
run = result.scalar_one_or_none()
if run is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
domain = await _domain_of(db, user)
if run.personal_domain_id != domain.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
import asyncio as _asyncio
from app.models import RunEvent
terminal = {"completed", "failed", "cancelled"}
async def event_stream() -> AsyncIterator[str]:
last_seq = -1
deadline = _asyncio.get_event_loop().time() + 30.0
while True:
rows = list(
(
await db.execute(
select(RunEvent)
.where(RunEvent.run_id == run.id, RunEvent.sequence > last_seq)
.order_by(RunEvent.sequence.asc())
)
).scalars().all()
)
for event in rows:
payload = json.dumps(
{"type": event.type, "data": event.data, "sequence": event.sequence},
ensure_ascii=False,
)
yield f"data: {payload}\n\n"
last_seq = event.sequence
if not rows:
await db.rollback() # 释放快照,下一轮可见新写入
await db.refresh(run)
if run.status in terminal:
break
if _asyncio.get_event_loop().time() > deadline:
break
await _asyncio.sleep(0.05)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-store"},
)
@router.get("/{conversation_id}/messages")
async def list_messages(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
limit: int = 50,
cursor: str | None = None,
branch_id: str | None = None,
) -> dict[str, object]:
"""消息列表:opaque cursor 分页(默认 50,契约第 4.1 节)。"""
if not 1 <= limit <= 200:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "limit 取值范围 1-200")
domain = await _domain_of(db, user)
page, next_cursor, has_more = await service.list_messages(
db, domain.id, conversation_id, branch_id, limit, cursor
)
from app.conversations.schemas import MessageOut
return {
"data": [
MessageOut(
id=m.id,
conversation_id=m.conversation_id,
branch_id=m.branch_id,
role=m.role,
content=m.content,
parser_version=m.parser_version,
created_at=m.created_at.isoformat(),
).model_dump()
for m in page
],
"next_cursor": next_cursor,
"has_more": has_more,
}
@router.get("/{conversation_id}/branches")
async def list_branches(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, object]:
domain = await _domain_of(db, user)
branches = await service.list_branches(db, domain.id, conversation_id)
from app.conversations.schemas import BranchOut
return {
"data": [
BranchOut(
id=b.id, conversation_id=b.conversation_id, title=b.title,
created_at=b.created_at.isoformat(),
).model_dump()
for b in branches
]
}
@router.post("/{conversation_id}/current-branch")
async def set_current_branch(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
body: dict[str, str],
) -> dict[str, str]:
"""切换当前路径(主脉工作入口;采用≠归真)。"""
domain = await _domain_of(db, user)
conversation = await service.set_current_branch(
db, domain.id, conversation_id, body.get("branch_id", "")
)
return {"id": conversation.id, "current_branch_id": conversation.current_branch_id or ""}
@router.get("/{conversation_id}")
async def get_conversation(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, object]:
domain = await _domain_of(db, user)
conversation = await service.require_conversation(db, domain.id, conversation_id)
from app.conversations.schemas import ConversationOut
return ConversationOut(
id=conversation.id,
title=conversation.title,
current_branch_id=conversation.current_branch_id,
created_at=conversation.created_at.isoformat(),
).model_dump() | {"realm": conversation.realm}
@router.post("/{conversation_id}/realm")
async def set_realm(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, str]:
"""入境/出境:切换对话界域(显式、留审计)。"""
domain = await _domain_of(db, user)
conversation = await service.set_realm(
db,
domain.id,
conversation_id,
body.get("realm", ""),
getattr(request.state, "trace_id", None),
)
return {"id": conversation.id, "realm": conversation.realm}
message_router = APIRouter(prefix="/v1/messages", tags=["messages"])
@message_router.post("/{message_id}/canonizations", status_code=201, response_model=None)
async def canonize_message(
message_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> JSONResponse | dict[str, str | None]:
"""归真:携带 canonical_scope(ADR-020);重复归真幂等返回原状态(200)。"""
from fastapi.responses import JSONResponse
domain = await _domain_of(db, user)
message, replayed = await service.canonize_message(
db, domain.id, message_id, body.get("scope", ""), getattr(request.state, "trace_id", None)
)
payload = {
"message_id": message.id,
"canonical_scope": message.realm,
"canonized_at": message.canonized_at.isoformat() if message.canonized_at else None,
}
if replayed:
return JSONResponse(status_code=200, content=payload)
return payload
@message_router.post("/{message_id}/revisions", status_code=201)
async def revise_message(
message_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: MessageIn,
) -> dict[str, object]:
"""编辑版本:新消息引用原消息;原消息永不修改。"""
domain = await _domain_of(db, user)
revision = await service.revise_message(
db, domain.id, message_id, body, getattr(request.state, "trace_id", None)
)
from app.conversations.schemas import MessageOut
return MessageOut(
id=revision.id,
conversation_id=revision.conversation_id,
branch_id=revision.branch_id,
role=revision.role,
content=revision.content,
parser_version=revision.parser_version,
created_at=revision.created_at.isoformat(),
).model_dump() | {"parent_message_id": revision.parent_message_id,
"variant_kind": revision.variant_kind}
+275
View File
@@ -0,0 +1,275 @@
"""对话域 schema:统一 ContentBlock 判别联合(21 类型)。
与 openapi/openapi.yaml 的 components.schemas 一一对齐;此为服务端
Pydantic 边界(AGENTS 8.2),字段语义以契约 docs/contracts/content-blocks-and-streaming.md 为准。
markdown block 在服务端经灵台引擎规范化(契约第 4 节)。
"""
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
class BlockBase(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str | None = None # 创建请求可省略,由服务端生成
status: Literal["pending", "streaming", "completed", "failed", "cancelled"] | None = None
# ===== 文本与结构 =====
class TextBlock(BlockBase):
type: Literal["text"]
text: str
language: str | None = None
class MarkdownBlock(BlockBase):
type: Literal["markdown"]
text: str
flavor: Literal["hunjing-gfm-v1"] = "hunjing-gfm-v1"
class CodeBlock(BlockBase):
type: Literal["code"]
source: str
language: str | None = None
filename: str | None = None
class QuoteBlock(BlockBase):
type: Literal["quote"]
content: list["ContentBlock"]
source_ref: str | None = None
class TableBlock(BlockBase):
type: Literal["table"]
columns: list[str]
rows: list[list[object]]
caption: str | None = None
class CalloutBlock(BlockBase):
type: Literal["callout"]
kind: Literal["info", "warning", "danger", "success"]
title: str | None = None
content: list["ContentBlock"]
# ===== 数学与图 =====
class MathBlock(BlockBase):
type: Literal["math"]
notation: Literal["latex", "asciimath"]
display: Literal["inline", "block"]
source: str
fallback_text: str | None = None
label: str | None = None
class MermaidBlock(BlockBase):
type: Literal["mermaid"]
source: str
title: str | None = None
caption: str | None = None
fallback_code_language: str = "mermaid"
# ===== 媒体与文件 =====
class ImageBlock(BlockBase):
type: Literal["image"]
file_id: str | None = None
url_ref: str | None = None
alt: str
width: int | None = Field(default=None, ge=1)
height: int | None = Field(default=None, ge=1)
caption: str | None = None
class VideoBlock(BlockBase):
type: Literal["video"]
file_id: str
poster_file_id: str | None = None
caption: str | None = None
transcript_file_id: str | None = None
duration_ms: int | None = Field(default=None, ge=0)
class AudioBlock(BlockBase):
type: Literal["audio"]
file_id: str
transcript: str | None = None
duration_ms: int | None = Field(default=None, ge=0)
waveform: str | None = None
class FileBlock(BlockBase):
type: Literal["file"]
file_id: str
filename: str
media_type: str
size_bytes: int = Field(ge=0)
description: str | None = None
class GalleryItem(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["image", "video"]
file_id: str
alt: str | None = None
caption: str | None = None
class GalleryBlock(BlockBase):
type: Literal["gallery"]
items: list[GalleryItem]
layout: str | None = None
class AvatarBlock(BlockBase):
type: Literal["avatar"]
asset_id: str
format: Literal["live2d", "vrm", "gltf"]
preview_file_id: str | None = None
# ===== Agent 与工具 =====
class ToolCallBlock(BlockBase):
type: Literal["tool_call"]
tool_name: str
call_id: str
arguments_redacted: dict[str, object] | None = None
approval_id: str | None = None
class ToolResultBlock(BlockBase):
type: Literal["tool_result"]
call_id: str
summary: dict[str, object] | None = None
file_ids: list[str] | None = None
is_error: bool | None = None
class AgentStatusBlock(BlockBase):
type: Literal["agent_status"]
agent_id: str | None = None
child_run_id: str | None = None
stage: str
progress: dict[str, object] | None = None
waiting_reason: str | None = None
class ApprovalRequestBlock(BlockBase):
type: Literal["approval_request"]
approval_id: str
action: str
impact: str | None = None
arguments_summary: dict[str, object] | None = None
expires_at: str | None = None
class ArtifactBlock(BlockBase):
type: Literal["artifact"]
artifact_id: str
artifact_type: str
version: int | None = None
class CitationBlock(BlockBase):
type: Literal["citation"]
source_id: str
title: str
quoted_text: str
locator: dict[str, object] | None = None
class ErrorBlock(BlockBase):
type: Literal["error"]
message: str
retryable: bool | None = None
fallback: str | None = None
_BlockTypes = (
TextBlock
| MarkdownBlock
| CodeBlock
| QuoteBlock
| TableBlock
| CalloutBlock
| MathBlock
| MermaidBlock
| ImageBlock
| VideoBlock
| AudioBlock
| FileBlock
| GalleryBlock
| AvatarBlock
| ToolCallBlock
| ToolResultBlock
| AgentStatusBlock
| ApprovalRequestBlock
| ArtifactBlock
| CitationBlock
| ErrorBlock
)
ContentBlock = Annotated[_BlockTypes, Field(discriminator="type")]
# 系统产生类 block:普通 Message 请求不允许客户端伪造(契约 3.5 节)
RUNTIME_BLOCK_TYPES = frozenset(
{"tool_call", "tool_result", "agent_status", "approval_request", "artifact"}
)
class MessageIn(BaseModel):
model_config = ConfigDict(extra="forbid")
content: list[ContentBlock] = Field(min_length=1, max_length=64)
start_run: bool = True
class MessageOut(BaseModel):
id: str
conversation_id: str
branch_id: str
role: str
content: list[dict[str, object]]
parser_version: str | None = None
created_at: str
class RunOut(BaseModel):
id: str
conversation_id: str | None
status: str
created_at: str
completed_at: str | None = None
class MessageCreatedOut(BaseModel):
message: MessageOut
run: RunOut | None
class BranchOut(BaseModel):
id: str
conversation_id: str
title: str
created_at: str
class ConversationOut(BaseModel):
id: str
title: str
current_branch_id: str | None = None
created_at: str
+409
View File
@@ -0,0 +1,409 @@
"""对话域服务:幂等、原子事务与越权防护。
不变量(契约 4.3/4.4 节):Message 与可选 Run 在同一事务创建;权威状态、
AuditEvent 与 OutboxEvent 同事务提交;跨域对象按 404 fail-closed。
"""
import hashlib
import json
from datetime import UTC, datetime
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.conversations.schemas import RUNTIME_BLOCK_TYPES, MessageIn
from app.core import lingtai
from app.core.ids import new_id
from app.models import (
AuditEvent,
Branch,
Conversation,
IdempotencyRecord,
Message,
OutboxEvent,
Run,
)
MESSAGE_ROUTE = "/v1/conversations/{conversation_id}/messages"
def _request_hash(body: MessageIn) -> str:
canonical = json.dumps(
{
"content": [b.model_dump(exclude_none=True) for b in body.content],
"start_run": body.start_run,
},
sort_keys=True,
ensure_ascii=False,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
async def require_conversation(
db: AsyncSession, domain_id: str, conversation_id: str
) -> Conversation:
conversation = await db.get(Conversation, conversation_id)
# 跨域对象统一 404,不泄露存在性(契约第 7 节)
if conversation is None or conversation.personal_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "对话不存在")
return conversation
async def _get_default_branch(db: AsyncSession, conversation_id: str) -> Branch:
result = await db.execute(
select(Branch).where(Branch.conversation_id == conversation_id).limit(1)
)
branch = result.scalar_one_or_none()
assert branch is not None, "会话必须存在默认分支(创建事务保证)"
return branch
async def create_conversation(
db: AsyncSession, domain_id: str, title: str, trace_id: str | None
) -> Conversation:
conversation = Conversation(personal_domain_id=domain_id, title=title)
db.add(conversation)
await db.flush()
branch = Branch(conversation_id=conversation.id, title="主脉")
db.add(branch)
db.add(
AuditEvent(
actor_type="user",
actor_id=domain_id,
action="conversation.created",
aggregate_type="conversation",
aggregate_id=conversation.id,
trace_id=trace_id,
)
)
db.add(
OutboxEvent(
aggregate_type="conversation",
aggregate_id=conversation.id,
event_type="conversation.created",
payload={"conversation_id": conversation.id, "title": title},
)
)
await db.flush()
return conversation
async def replay_or_execute_message(
db: AsyncSession,
domain_id: str,
conversation_id: str,
idempotency_key: str,
body: MessageIn,
trace_id: str | None,
) -> tuple[Message, Run | None, bool]:
"""返回(消息,Run,是否为重放)。幂等:相同键+相同请求返回原资源。"""
route = MESSAGE_ROUTE
existing = await db.execute(
select(IdempotencyRecord).where(
IdempotencyRecord.owner_domain_id == domain_id,
IdempotencyRecord.route == route,
IdempotencyRecord.key == idempotency_key,
)
)
record = existing.scalar_one_or_none()
request_hash = _request_hash(body)
if record is not None:
if record.request_hash != request_hash:
raise HTTPException(status.HTTP_409_CONFLICT, "相同幂等键对应不同请求体")
message = await db.get(Message, record.resource_ids.get("message_id"))
run = None
run_id = record.resource_ids.get("run_id")
if run_id is not None:
run = await db.get(Run, run_id)
assert message is not None
return message, run, True
conversation = await require_conversation(db, domain_id, conversation_id)
branch = await _get_default_branch(db, conversation.id)
forged = [b.type for b in body.content if b.type in RUNTIME_BLOCK_TYPES]
if forged:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"系统产生类 block 不允许客户端提交:{', '.join(sorted(set(forged)))}",
)
blocks: list[dict[str, Any]] = []
parser_version: str | None = None
for b in body.content:
if b.type == "markdown":
# 契约第 4 节:markdown 输入经确定性 parser 规范化为 canonical blocks
normalized = lingtai.transform_markdown(b.text)
parser_version = str(normalized["parser_version"])
blocks.extend(normalized["blocks"])
else:
blocks.append({**b.model_dump(exclude_none=True), "status": "completed"})
for block in blocks:
block["id"] = new_id("block_")
block.setdefault("status", "completed")
message = Message(
conversation_id=conversation.id,
branch_id=branch.id,
role="user",
realm=conversation.realm,
content=blocks,
parser_version=parser_version,
)
db.add(message)
await db.flush()
run = None
if body.start_run:
# Run 创建为 queued,由网关执行器在后台推进至终态(WP-05)
run = Run(
personal_domain_id=domain_id,
conversation_id=conversation.id,
status="queued",
)
db.add(run)
await db.flush()
message.run_id = run.id
resource_ids: dict[str, Any] = {"message_id": message.id}
if run is not None:
resource_ids["run_id"] = run.id
db.add(
AuditEvent(
actor_type="user",
actor_id=domain_id,
action="message.created",
aggregate_type="message",
aggregate_id=message.id,
trace_id=trace_id,
)
)
db.add(
OutboxEvent(
aggregate_type="message",
aggregate_id=message.id,
event_type="message.created",
payload={"message_id": message.id, "conversation_id": conversation.id},
)
)
db.add(
IdempotencyRecord(
key=idempotency_key,
owner_domain_id=domain_id,
route=route,
request_hash=request_hash,
response_status=201,
resource_ids=resource_ids,
)
)
await db.flush()
return message, run, False
async def list_messages(
db: AsyncSession,
domain_id: str,
conversation_id: str,
branch_id: str | None,
limit: int,
cursor: str | None,
) -> tuple[list[Message], str | None, bool]:
"""按创建时间升序的消息列表(opaque cursor 分页,契约第 4.1 节)。"""
conversation = await require_conversation(db, domain_id, conversation_id)
stmt = select(Message).where(Message.conversation_id == conversation.id)
if branch_id is not None:
stmt = stmt.where(Message.branch_id == branch_id)
after_created, after_id = decode_cursor(cursor)
if after_created is not None:
# (created_at, id) 复合键分页:严格大于游标位置
stmt = stmt.where(
(Message.created_at > after_created)
| ((Message.created_at == after_created) & (Message.id > after_id))
)
stmt = stmt.order_by(Message.created_at.asc(), Message.id.asc()).limit(limit + 1)
rows = list((await db.scalars(stmt)).all())
has_more = len(rows) > limit
page = rows[:limit]
next_cursor = encode_cursor(page[-1]) if has_more and page else None
return page, next_cursor, has_more
def encode_cursor(message: Message) -> str:
import base64
raw = f"{message.created_at.isoformat()}|{message.id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def decode_cursor(cursor: str | None) -> tuple[Any | None, str]:
import base64
if cursor is None:
return None, ""
try:
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
created_str, _, mid = raw.partition("|")
return datetime.fromisoformat(created_str), mid
except Exception as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "cursor 不合法") from exc
async def list_branches(
db: AsyncSession, domain_id: str, conversation_id: str
) -> list[Branch]:
conversation = await require_conversation(db, domain_id, conversation_id)
result = await db.execute(
select(Branch).where(Branch.conversation_id == conversation.id).order_by(Branch.created_at)
)
return list(result.scalars().all())
async def set_current_branch(
db: AsyncSession, domain_id: str, conversation_id: str, branch_id: str
) -> Conversation:
"""切换当前路径:分支必须属于该会话(跨域/跨会话统一 404/422 fail-closed)。"""
conversation = await require_conversation(db, domain_id, conversation_id)
branch = await db.get(Branch, branch_id)
if branch is None or branch.conversation_id != conversation.id:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "分支不属于该会话")
conversation.current_branch_id = branch_id
await db.flush()
return conversation
async def set_realm(
db: AsyncSession,
domain_id: str,
conversation_id: str,
realm: str,
trace_id: str | None,
) -> Conversation:
"""入境/出境:显式切换对话界域并留审计(ADR-020:切换必须可见)。"""
if realm not in {"reality", "world", "simulation"}:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "realm 取值不合法")
conversation = await require_conversation(db, domain_id, conversation_id)
previous = conversation.realm
conversation.realm = realm
db.add(
AuditEvent(
actor_type="user",
actor_id=domain_id,
action="conversation.realm_changed",
aggregate_type="conversation",
aggregate_id=conversation.id,
payload={"from": previous, "to": realm},
trace_id=trace_id,
)
)
await db.flush()
return conversation
async def _require_message(
db: AsyncSession, domain_id: str, message_id: str
) -> Message:
message = await db.get(Message, message_id)
if message is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "消息不存在")
conversation = await db.get(Conversation, message.conversation_id)
assert conversation is not None
if conversation.personal_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "消息不存在")
return message
async def canonize_message(
db: AsyncSession,
domain_id: str,
message_id: str,
scope: str,
trace_id: str | None,
) -> tuple[Message, bool]:
"""归真:scope 必须与消息所在界域一致(ADR-020);重复归真幂等返回原状态。
境界归真产出 world.canonized Outbox 事件(世界知识候选入口,WP-04 消费);
幻境归真仅标记试演记录,不产生长期记忆通道。
"""
if scope not in {"reality", "world", "simulation"}:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "scope 取值不合法")
message = await _require_message(db, domain_id, message_id)
if message.realm != scope:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"归真作用域与消息界域不一致:消息在 {message.realm},请求 {scope}",
)
if message.canonized_at is not None:
return message, True # 幂等:重复归真返回原状态
message.canonized_at = datetime.now(UTC)
event_type = "message.canonized" if scope != "world" else "world.canonized"
db.add(
AuditEvent(
actor_type="user",
actor_id=domain_id,
action="message.canonized",
aggregate_type="message",
aggregate_id=message.id,
payload={"canonical_scope": scope},
trace_id=trace_id,
)
)
db.add(
OutboxEvent(
aggregate_type="message",
aggregate_id=message.id,
event_type=event_type,
payload={"message_id": message.id, "canonical_scope": scope},
)
)
await db.flush()
return message, False
async def revise_message(
db: AsyncSession,
domain_id: str,
message_id: str,
body: MessageIn,
trace_id: str | None,
) -> Message:
"""编辑版本:创建引用原消息的新 Message(variant=revision),原消息不变。"""
original = await _require_message(db, domain_id, message_id)
blocks: list[dict[str, Any]] = []
parser_version: str | None = None
for b in body.content:
if b.type == "markdown":
normalized = lingtai.transform_markdown(b.text)
parser_version = str(normalized["parser_version"])
blocks.extend(normalized["blocks"])
else:
blocks.append({**b.model_dump(exclude_none=True), "status": "completed"})
for block in blocks:
block["id"] = new_id("block_")
block.setdefault("status", "completed")
revision = Message(
conversation_id=original.conversation_id,
branch_id=original.branch_id,
role=original.role,
realm=original.realm,
parent_message_id=original.id,
variant_kind="revision",
content=blocks,
parser_version=parser_version,
)
db.add(revision)
await db.flush() # 生成 revision.id 后再写审计
db.add(
AuditEvent(
actor_type="user",
actor_id=domain_id,
action="message.revised",
aggregate_type="message",
aggregate_id=revision.id,
payload={"revises": original.id},
trace_id=trace_id,
)
)
await db.flush()
return revision
+1 -1
View File
@@ -1 +1 @@
"""核心基础设施。""" """核心模块:配置、错误与 ID。"""
+19
View File
@@ -0,0 +1,19 @@
"""应用配置:环境变量优先,秘密不落盘。"""
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="HUNJING_", env_file=".env", extra="ignore")
env: str = "development"
log_level: str = "info"
api_port: int = 8000
database_url: str = "postgresql+asyncpg://hunjing:hunjing_dev_password@127.0.0.1:5432/hunjing"
@lru_cache
def get_settings() -> Settings:
return Settings()
-13
View File
@@ -1,13 +0,0 @@
"""配置集中入口。
所有配置集中在 ``core/config/``,按领域拆分子配置文件,主 ``Settings`` 多继承聚合。
详见 [后端分层架构设计](../../../../../docs/architecture/backend-layering-design.md) §7。
- ``.env`` 永远放在项目根目录(backend 父级),``Settings`` 初始化时以**绝对路径**
通过 ``_env_file`` 传入,不使用相对路径,不依赖 ``python-dotenv``。
"""
from app.core.config.sandbox_config import SandboxSettings
from app.core.config.settings import Settings, settings
__all__ = ["SandboxSettings", "Settings", "settings"]
-52
View File
@@ -1,52 +0,0 @@
"""应用基础配置:运行环境、端口、CORS、默认模型提示等。"""
from __future__ import annotations
from typing import Literal
from pydantic_settings import BaseSettings
class AppConfig(BaseSettings):
"""应用基础配置。"""
# ==================== 基础配置 ====================
app_name: str = "魂镜后端" # 应用名称
app_env: Literal["dev", "prod"] = "dev" # 运行环境:dev(开发) / prod(生产)
debug: bool = False # 调试模式
timezone: str = "Asia/Shanghai" # 时区
api_prefix: str = "/api/v1" # API 路由前缀
backend_host: str = "0.0.0.0" # 监听地址
backend_port: int = 8000 # 监听端口
cors_allow_origins: str = "http://localhost:3000" # CORS 允许的源,逗号分隔
# ==================== 业务默认值 ====================
default_owner_scope: str = "local-user" # 默认所有者作用域
default_model_hint: str = "deepseek-v4-flash" # 默认真实远程逻辑模型提示
# ==================== 默认人物模板配置 ====================
character_templates_path: str = (
"../config/character_templates.yaml" # 人物模板配置文件路径(相对 backend)
)
# ==================== 文件存储 ====================
# ==================== 默认管理员用户 ====================
default_admin_username: str = "admin" # 种子默认管理员用户名
default_admin_email: str = "admin@hunjing.local" # 种子默认管理员邮箱
default_admin_password: str = "admin123" # 种子默认管理员密码(引导用,生产立即修改)
@property
def cors_origins(self) -> list[str]:
"""解析 CORS 允许的源为列表。"""
return [origin.strip() for origin in self.cors_allow_origins.split(",") if origin.strip()]
@property
def is_development(self) -> bool:
"""是否为开发环境。"""
return self.app_env == "dev"
@property
def is_production(self) -> bool:
"""是否为生产环境。"""
return self.app_env == "prod"
-29
View File
@@ -1,29 +0,0 @@
"""认证与鉴权配置。
当前(v1 单用户)仅含管理端点访问凭证 ``admin_token``;v2 多用户上线后扩展为
完整认证配置(JWT 签发密钥、access/refresh token TTL 等),见
[多用户与租户隔离设计](../../../docs/product/master-prd.md)。
"""
from __future__ import annotations
from pydantic_settings import BaseSettings
class AuthConfig(BaseSettings):
"""认证与鉴权配置。"""
# ==================== 管理端点访问(v1) ====================
# 司命台管理 API(/admin/*)的访问凭证。部署期配置,非用户登录凭证:
# 请求需在 Authorization 头携带 ``Bearer <ADMIN_TOKEN>``,经 require_admin 校验。
# 为空时管理端点 fail-closed(返回 503),强制部署时配置,避免裸奔。
# 生产环境务必使用强随机值;v2 多用户上线后由完整认证体系取代。
admin_token: str = "" # 管理端点访问凭证(为空则 /admin 返回 503)
# ==================== JWT 配置(v2 多用户) ====================
# JWT 签名密钥。为空时认证端点 fail-closed(503);生产环境务必配置强随机值。
jwt_secret: str = ""
# Access Token 有效期(秒),默认 15 分钟
access_token_ttl: int = 900
# Refresh Token 有效期(秒),默认 7 天
refresh_token_ttl: int = 604800
@@ -1,27 +0,0 @@
"""数据库配置:SQLite / PostgreSQL 连接参数。"""
from __future__ import annotations
from typing import Literal
from pydantic_settings import BaseSettings
class DatabaseConfig(BaseSettings):
"""数据库配置。
本地开发默认使用 SQLite(``aiosqlite``);正式环境通过 ``DATABASE_TYPE=pgsql``
切换到 PostgreSQL(``asyncpg``)。``DATABASE_URL`` 优先级最高,可直接覆盖。
"""
database_type: Literal["sqlite", "pgsql"] = "sqlite" # 数据库类型
sqlite_database_path: str = "../db/hunjing.sqlite3" # SQLite 数据库路径(相对 backend)
database_url: str = "" # 自定义数据库连接 URL(优先级最高)
postgres_host: str = "127.0.0.1" # PostgreSQL 主机
postgres_port: int = 5432 # PostgreSQL 端口
postgres_db: str = "hunjing" # PostgreSQL 数据库名
postgres_user: str = "hunjing" # PostgreSQL 用户名
postgres_password: str = "change-me" # PostgreSQL 密码
postgres_ssl: bool = False # 是否启用 SSL
postgres_pool_size: int = 10 # 连接池大小
postgres_max_overflow: int = 20 # 连接池最大溢出连接数
-15
View File
@@ -1,15 +0,0 @@
"""日志配置。"""
from __future__ import annotations
from pydantic_settings import BaseSettings
class LogConfig(BaseSettings):
"""日志配置(loguru)。"""
log_dir: str = "../logs" # 日志目录(相对于 backend 目录)
log_level: str = "INFO" # 日志级别:DEBUG/INFO/WARNING/ERROR
log_access: bool = True # 是否启用访问日志
log_retention_days: int = 14 # 日志保留天数
log_rotation_size: str = "10 MB" # 日志轮转大小
-120
View File
@@ -1,120 +0,0 @@
"""运行时与 SSE 配置:事件流、超时、工具运行时开关等。"""
from __future__ import annotations
from pydantic_settings import BaseSettings
class RuntimeConfig(BaseSettings):
"""SSE / 运行时 / 工具运行时配置。"""
# ==================== SSE 配置 ====================
enable_sse: bool = True # 是否启用 SSE
max_text_message_bytes: int = 32768 # 最大文本消息字节数
sse_poll_interval_seconds: float = 0.01 # SSE 轮询间隔(秒,10ms:前端逐段平滑增量)
sse_heartbeat_seconds: float = 15.0 # SSE 心跳间隔(秒)
run_timeout_seconds: int = 120 # 运行超时时间(秒)
# ==================== 神识(模型网关,rewrite/1.0) ====================
shenshi_base_url: str = "" # 神识服务地址;为空时 Kernel 使用进程内回声桩(M0 骨架)
shenshi_service_token: str = "" # 灵台→神识服务令牌(开发档可空)
shenshi_timeout_seconds: float = 120.0 # 神识流式调用超时(秒)
# ==================== 镜生域(rewrite/1.0 M1) ====================
identity_record_secret: str = "dev-identity-secret" # 魂籍签名密钥;正式部署必须改掉默认值
death_calm_period_hours: int = 72 # 死亡冷静期时长(小时),期内可撤销
seat_lease_ttl_seconds: int = 60 # 命台租约 TTL(秒)
# Agent 循环最大轮数:模型每轮仍带工具调用则继续,超限截断返回(见 agent.runtime.DEFAULT_MAX_ITERATIONS)
agent_max_iterations: int = 8
# 单次高风险工具确认挂起超时(秒):须小于 run_timeout_seconds,超时按拒绝处理;
# 若 >= run_timeout_seconds,run 整体超时会先到,导致确认中的 run 直接 failed
confirmation_timeout_seconds: int = 60
# 是否启用数据库持久化的确认存储(用于多 worker 部署);关闭则使用进程内 InMemoryConfirmationStore(默认)
use_db_confirmation_store: bool = False
run_max_events: int = 1000 # 最大事件数
event_payload_max_bytes: int = 65536 # 事件负载最大字节数
memory_injection_limit: int = 5 # 记忆注入条数上限
# ==================== 工具运行时配置 ====================
enable_tool_runtime: bool = True # 是否启用百工工具运行时(司命 + 沙箱)
tool_timeout_seconds: int = 600 # 单个工具调用超时秒数
# ==================== 洛书规划器 ====================
enable_deliberative_planning: bool = False # 是否启用显式多步规划(§10.2 增强能力)
# ==================== 大周天(长期演化循环) ====================
enable_big_cycle: bool = True # 是否启用大周天自动触发
big_cycle_memory_threshold: int = 20 # 触发大周天的记忆数阈值
big_cycle_min_interval_seconds: int = 300 # 两次大周天最小间隔(秒),避免频繁触发
big_cycle_model: str = "deepseek-v4-flash" # 大周天候选生成所用模型(models.yaml name 或 tag)
big_cycle_max_candidates: int = 10 # 单次大周天生成的演化候选上限
big_cycle_token_budget: int = 64000 # 单次大周天的 token 预算上限(超限跳过非关键阶段)
big_cycle_max_drift: float = 2.0 # 最大累积漂移值(超过后暂停自动演化)
big_cycle_drift_threshold: float = 0.6 # 单次演化候选的漂移阈值(>= 此值不自动应用)
# ==================== 内驱力(太一枢)[PLANNED] ====================
enable_intrinsic_drive: bool = False # 是否启用内驱力扫描(curiosity / exploration)
drive_scan_interval_seconds: int = 3600 # 内驱力扫描间隔秒数
drive_max_curiosity_per_scan: int = 5 # 单次扫描最多好奇问题数
drive_allow_interrupt: bool = False # 是否允许内驱力中断当前任务
# ==================== 司命安全策略 ====================
# 工具策略 YAML 路径(相对 backend);未配置时 SafetyGovernor 使用内置默认空策略。
safety_policy_path: str = ""
safety_always_confirm_high_risk: bool = True # 高风险动作是否总是确认
# ==================== MCP / 联网工具网络超时 ====================
mcp_list_timeout_seconds: float = 10.0 # MCP tools/list 请求超时(秒)
mcp_call_timeout_seconds: float = 30.0 # MCP tools/call 请求超时(秒)
tool_web_http_timeout_seconds: float = 15.0 # fetch_url 的 HTTP 超时(秒)
# ==================== MCP stdio 子进程 ====================
mcp_stdio_start_timeout_seconds: float = 15.0 # stdio 子进程启动超时(秒)
mcp_stdio_call_timeout_seconds: float = 30.0 # stdio 单次 tools/call 超时(秒)
mcp_stdio_stop_timeout_seconds: float = 5.0 # SIGTERM → SIGKILL 等待(秒)
# ==================== 向量记忆检索 ====================
vector_db_enabled: bool = False # 是否启用向量数据库(关则纯 SQL 检索)
vector_db_type: str = "chroma" # 向量库类型:chroma / milvus
chromadb_host: str = "" # ChromaDB HTTP 主机(空则内存模式)
chromadb_port: int = 8000 # ChromaDB HTTP 端口
chroma_collection: str = "hunjing_memories" # ChromaDB collection 名
chroma_persist_directory: str = "../db/chroma" # ChromaDB 持久化目录(相对 backend)
embedding_model_hint: str = "bge-m3" # embedding 模型逻辑名
# 叩心约束(PRD §6.6:总开关/静默窗口/频率/预算;候选默认不自动发送)
knocking_enabled: bool = True
knocking_quiet_start: str = "23:00" # HH:MM(本地时区)
knocking_quiet_end: str = "07:00"
knocking_daily_limit: int = 3 # 24 小时派发上限
knocking_timezone: str = "Asia/Shanghai" # 静默窗口判定时区
knocking_budget_total: int = 0 # 累计预算(0=不限)
# 元炁本地推理引擎(ASR 语音转写;未配置=多模态关闭,文字链路不受影响)
yuanqi_base_url: str = ""
yuanqi_asr_model: str = "iic/SenseVoiceSmall"
# ==================== 自动记忆候选 [PLANNED] ====================
enable_memory_candidates: bool = False # 是否启用自动记忆候选抽取
memory_candidate_min_importance: float = 0.7 # 候选记忆重要性阈值(0.0~1.0)
# ==================== 审计日志(司命枢·起居注)[PLANNED] ====================
enable_audit_log: bool = True # 是否启用审计日志
# ==================== 感知(烛照枢)[PLANNED] ====================
enable_camera: bool = False # 是否启用摄像头(视觉感知)
enable_microphone: bool = False # 是否启用麦克风(听觉感知)
perception_save_raw_media: bool = False # 是否保存原始感知文件
perception_max_media_mb: int = 20 # 感知文件最大大小(MB)
# ==================== 语音服务 [PLANNED] ====================
enable_asr: bool = False # 是否启用 ASR(语音转文字)
enable_tts: bool = False # 是否启用 TTS(文字转语音)
enable_voice_training: bool = False # 是否启用语音训练(微调 TTS 音色)
enable_voice_chat: bool = False # 是否启用实时语音对话
# ==================== 导入限制 [PLANNED] ====================
import_max_file_mb: int = 20 # 人物资料导入文件最大大小(MB)
import_audio_max_duration_seconds: int = 600 # 语音样本最大时长(秒)
import_allowed_audio_mime_types: str = (
"audio/wav,audio/mpeg,audio/mp4,audio/flac" # 允许上传的语音样本 MIME 类型
)
-29
View File
@@ -1,29 +0,0 @@
"""乾坤沙箱配置。
从环境变量加载(与根目录 ``.env`` 统一)。字段名与 env 名一一对应(去除前缀后 snake_case
即 env 名),由 ``pydantic-settings`` 自动绑定,无需手动 ``os.getenv``。
- ``rust_sandbox_url`` 取自 ``RUST_SANDBOX_URL``(后端连沙箱的地址)。
- ``sandbox_auth_secret`` 与沙箱端 ``SANDBOX_AUTH_SECRET`` 共享,用于 HMAC 授权凭证签发。
"""
from __future__ import annotations
from pydantic_settings import BaseSettings
class SandboxSettings(BaseSettings):
"""沙箱客户端运行配置。"""
rust_sandbox_url: str = "http://127.0.0.1:8090" # RUST_SANDBOX_URL:后端连沙箱的地址
sandbox_auth_enabled: bool = True # SANDBOX_AUTH_ENABLED:是否启用 HMAC 授权
sandbox_auth_secret: str = "" # SANDBOX_AUTH_SECRET:与沙箱端共享的 HMAC 密钥
sandbox_default_timeout_ms: int = 30_000 # SANDBOX_DEFAULT_TIMEOUT_MS:默认执行超时
sandbox_auth_ttl_seconds: int = 30 # SANDBOX_AUTH_TTL_SECONDS:授权凭证有效期
def http_timeout(self, exec_timeout_ms: int | None) -> float:
"""HTTP 读取超时:执行超时 + 5s 余量,下限 10s。"""
exec_ms = (
exec_timeout_ms if exec_timeout_ms is not None else self.sandbox_default_timeout_ms
)
return max(exec_ms / 1000 + 5.0, 10.0)
-120
View File
@@ -1,120 +0,0 @@
"""主 Settings:多继承所有子配置,统一以绝对路径 ``_env_file`` 加载 ``.env``。
子配置见同目录下的 ``app_config`` / ``auth_config`` / ``database_config`` / ``log_config`` /
``runtime_config`` / ``sandbox_config``。本文件聚合字段,并承载
跨子配置的路径解析与计算属性。
配置优先级(自高到低):命令行参数 > 系统环境变量 > ``.env`` > 类属性默认值。
"""
from __future__ import annotations
from functools import cached_property
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.core.config.app_config import AppConfig
from app.core.config.auth_config import AuthConfig
from app.core.config.database_config import DatabaseConfig
from app.core.config.log_config import LogConfig
from app.core.config.runtime_config import RuntimeConfig
from app.core.config.sandbox_config import SandboxSettings
from app.core.config.time_config import TimeConfig
# backend 目录:app/core/config/settings.py → parents[3] = backend/
_BACKEND_DIR = Path(__file__).resolve().parents[3]
# .env 永远在项目根目录(backend 父级),以绝对路径定位
_ENV_FILE = _BACKEND_DIR.parent / ".env"
class Settings(
AppConfig,
AuthConfig,
DatabaseConfig,
LogConfig,
RuntimeConfig,
SandboxSettings,
TimeConfig,
BaseSettings,
):
"""应用配置类。
所有配置项可通过 ``.env`` 文件或环境变量覆盖。路径配置会自动解析为绝对路径。
"""
model_config = SettingsConfigDict(
env_file=str(_ENV_FILE), # 绝对路径,不使用 python-dotenv
env_file_encoding="utf-8",
case_sensitive=False, # 环境变量名不区分大小写
extra="ignore", # 忽略未定义的额外字段
)
# ==================== 路径解析 ====================
@cached_property
def backend_dir(self) -> Path:
"""backend 目录路径。"""
return _BACKEND_DIR
@cached_property
def project_root(self) -> Path:
"""项目根目录路径(backend 的父级)。"""
return _BACKEND_DIR.parent
def resolve_from_backend(self, value: str) -> Path:
"""解析相对于 backend 目录的路径为绝对路径。
Args:
value: 相对路径或绝对路径字符串
Returns:
解析后的绝对路径
"""
path = Path(value)
if path.is_absolute():
return path
return (self.backend_dir / path).resolve()
# ==================== 计算属性 ====================
@property
def resolved_database_url(self) -> str:
"""解析数据库连接 URL。
如果设置了 ``database_url`` 则直接使用,否则根据 ``database_type`` 构建。
"""
if self.database_url:
return self.database_url
if self.database_type == "sqlite":
db_path = self.resolve_from_backend(self.sqlite_database_path)
return f"sqlite+aiosqlite:///{db_path}"
password = self.postgres_password
ssl = "?ssl=require" if self.postgres_ssl else ""
return (
f"postgresql+asyncpg://{self.postgres_user}:{password}"
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}{ssl}"
)
@cached_property
def resolved_log_dir(self) -> Path:
"""解析日志目录为绝对路径。"""
return self.resolve_from_backend(self.log_dir)
@cached_property
def resolved_character_templates_path(self) -> Path:
"""解析人物模板配置文件路径。
如果配置文件不存在,尝试使用示例配置文件。
"""
configured = self.resolve_from_backend(self.character_templates_path)
if configured.exists():
return configured
example = self.project_root / "config" / "character_templates.example.yaml"
if example.exists():
return example
return configured
# 全局配置实例
settings = Settings()
-49
View File
@@ -1,49 +0,0 @@
"""辰正时态配置。
所有字段可通过环境变量或 ``.env`` 覆盖(字段名即环境变量名,由 ``Settings.model_config``
统一加载,无前缀,与既有 ``AppConfig`` 等同级配置一致)。默认值对齐辰正设计 §7.1
离线档位与 §7.2/§7.3 触发阈值。v1 输入无 character 维度的时区/位置(character 模型
未持有时区字段),统一用此处的默认值;v2 从档案/会话覆盖。
"""
from __future__ import annotations
from pydantic_settings import BaseSettings
class TimeConfig(BaseSettings):
"""辰正时态服务配置。"""
# 时区与位置(v1 全局默认;v2 从档案/会话覆盖)
user_timezone: str = "Asia/Shanghai"
home_timezone: str = "Asia/Shanghai"
default_city: str = "上海"
default_locale: str = "zh-CN"
# 离线档位阈值(分钟),对齐设计 §7.1 / duration.py 默认
offline_active_minutes: float = 60
offline_short_absence_minutes: float = 720
offline_long_absence_minutes: float = 4320
offline_reunion_impact_minutes: float = 4320
# 感慨触发(§7.2)
sentiment_prewarm_days: int = 7
# 认识天数有感节点(逗号分隔;§7.2 duration_node)
sentiment_duration_nodes: str = "100,200,365,500,730,1000"
# 强节气名(§7.2 solar_term;立春/冬至/清明/中秋/除夕等)
sentiment_strong_solar_terms: str = "立春,清明,冬至,立夏,立秋,立冬"
# 关怀触发(§7.3)
care_enabled: bool = True
jetlag_threshold_minutes: int = 180
late_night_min_turns: int = 3
@property
def duration_nodes(self) -> set[int]:
"""解析 ``sentiment_duration_nodes`` 为整数集合。"""
return {int(x) for x in self.sentiment_duration_nodes.split(",") if x.strip()}
@property
def strong_solar_terms(self) -> set[str]:
"""解析强节气名集合。"""
return {s.strip() for s in self.sentiment_strong_solar_terms.split(",") if s.strip()}
+43
View File
@@ -0,0 +1,43 @@
"""通用 DTO:与 openapi/openapi.yaml 契约对齐的 Pydantic 类型。
对齐由 tests/test_dto_contract.py 用 bundled 契约校验关键字段;
禁止在此复制会漂移的完整值集(以契约为准)。
"""
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field
def _id_pattern(prefix: str, version: int) -> str:
ver = "7" if version == 7 else "4"
tail = r"[0-9a-f]{12}-[0-9a-f]{4}-" + ver + r"[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
return f"^{prefix}" + tail
UserId = Annotated[str, Field(pattern=_id_pattern("user_", 7))]
PersonalDomainId = Annotated[str, Field(pattern=_id_pattern("domain_", 7))]
ConversationId = Annotated[str, Field(pattern=_id_pattern("conversation_", 7))]
BranchId = Annotated[str, Field(pattern=_id_pattern("branch_", 7))]
MessageId = Annotated[str, Field(pattern=_id_pattern("message_", 7))]
ContentBlockId = Annotated[str, Field(pattern=_id_pattern("block_", 7))]
RunId = Annotated[str, Field(pattern=_id_pattern("run_", 7))]
TurnId = Annotated[str, Field(pattern=_id_pattern("turn_", 7))]
RequestId = Annotated[str, Field(pattern=_id_pattern("request_", 4))]
class Money(BaseModel):
"""金额:最小货币单位整数 + ISO 4217 币种;禁止浮点(契约第 4 节)。"""
model_config = ConfigDict(extra="forbid")
amount_minor: int = Field(ge=0)
currency: str = Field(pattern=r"^[A-Z]{3}$")
class CursorPage[T](BaseModel):
"""opaque cursor 分页(契约第 4.1 节);客户端不得解析 cursor。"""
data: list[T]
next_cursor: str | None
has_more: bool
+58 -114
View File
@@ -1,132 +1,76 @@
"""统一错误响应与异常处理。""" """统一错误体与领域异常(契约见 docs/contracts/api-conventions.md 第 7 节)。
`code` 是稳定机器值;`message` 可本地化;`details` 不稳定但必须脱敏。
"""
from contextvars import ContextVar
from typing import Any from typing import Any
from fastapi import FastAPI, Request, status from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException from pydantic import BaseModel
from app.core.ids import new_trace_id
from app.core.logging import log_with_context
from app.core.security import redact_value
class SafetyError(Exception):
"""司命决策异常(错误码映射契约字典 §15)。"""
def __init__(self, code: str, message: str) -> None: class ErrorBody(BaseModel):
code: str
message: str
trace_id: str | None = None
request_id: str | None = None
details: dict[str, Any] | None = None
class ErrorEnvelope(BaseModel):
error: ErrorBody
class HunjingError(Exception):
"""领域异常基类:映射为稳定错误码的 HTTP 响应。"""
status_code: int = 500
code: str = "internal"
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
super().__init__(message) super().__init__(message)
self.code = code
self.message = message self.message = message
self.details = details
def __str__(self) -> str:
return self.message
trace_id_var: ContextVar[str | None] = ContextVar("trace_id", default=None)
ERROR_HTTP_STATUS: dict[str, int] = {
"invalid_request": status.HTTP_400_BAD_REQUEST,
"unauthorized": status.HTTP_401_UNAUTHORIZED,
"permission_denied": status.HTTP_403_FORBIDDEN,
"requires_confirmation": status.HTTP_403_FORBIDDEN,
"not_found": status.HTTP_404_NOT_FOUND,
"conflict": status.HTTP_409_CONFLICT,
"payload_too_large": status.HTTP_413_CONTENT_TOO_LARGE,
"unsupported_media_type": status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
"validation_failed": status.HTTP_422_UNPROCESSABLE_CONTENT,
"context_overflow": status.HTTP_400_BAD_REQUEST,
"run_timeout": status.HTTP_504_GATEWAY_TIMEOUT,
"model_error": status.HTTP_502_BAD_GATEWAY,
"model_rate_limited": status.HTTP_429_TOO_MANY_REQUESTS,
"model_timeout": status.HTTP_504_GATEWAY_TIMEOUT,
"model_config_error": status.HTTP_500_INTERNAL_SERVER_ERROR,
"model_unavailable": status.HTTP_503_SERVICE_UNAVAILABLE,
"model_provider_key_missing": status.HTTP_500_INTERNAL_SERVER_ERROR,
"tool_error": status.HTTP_502_BAD_GATEWAY,
"safety_rejected": status.HTTP_403_FORBIDDEN,
"rate_limited": status.HTTP_429_TOO_MANY_REQUESTS,
"internal_error": status.HTTP_500_INTERNAL_SERVER_ERROR,
}
class AppError(Exception): class ValidationFailed(HunjingError):
def __init__( status_code = 422
self, code = "validation_failed"
code: str,
message: str,
*,
status_code: int | None = None,
details: dict[str, Any] | None = None,
) -> None:
self.code = code
self.message = message
self.status_code = status_code or ERROR_HTTP_STATUS.get(code, status.HTTP_400_BAD_REQUEST)
self.details = details or {}
def current_trace_id() -> str: def _envelope(
trace_id = trace_id_var.get() code: str, message: str, request: Request, details: dict[str, Any] | None
if trace_id is None: ) -> dict[str, Any]:
trace_id = new_trace_id() return {
trace_id_var.set(trace_id) "error": {
return trace_id "code": code,
"message": message,
"trace_id": None,
"request_id": request.headers.get("Hunjing-Request-ID"),
"details": details,
}
}
def error_response(error: AppError) -> JSONResponse: def install_error_handlers(app: FastAPI) -> None:
return JSONResponse( @app.exception_handler(HunjingError)
status_code=error.status_code, async def _hunjing_error(request: Request, exc: HunjingError) -> JSONResponse:
content={ return JSONResponse(
"error": { status_code=exc.status_code,
"code": error.code, content=_envelope(exc.code, exc.message, request, exc.details),
"message": error.message, )
"trace_id": current_trace_id(),
"details": redact_value(error.details),
}
},
)
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def handle_app_error(_request: Request, exc: AppError) -> JSONResponse:
return error_response(exc)
@app.exception_handler(SafetyError)
async def handle_safety_error(_request: Request, exc: SafetyError) -> JSONResponse:
# 司命异常自带契约错误码(如 permission_denied → 403),按错误码映射 HTTP 状态。
return error_response(AppError(exc.code, exc.message))
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def handle_validation_error( async def _validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
_request: Request, exc: RequestValidationError field_errors = [
) -> JSONResponse: {"path": ".".join(str(p) for p in err["loc"]), "reason": err["type"]}
return error_response( for err in exc.errors()
AppError( ]
"validation_failed", return JSONResponse(
"请求字段校验失败。", status_code=422,
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content=_envelope(
details={"errors": exc.errors()}, "validation_failed", "请求字段不合法。", request, {"field_errors": field_errors}
) ),
)
@app.exception_handler(StarletteHTTPException)
async def handle_http_error(_request: Request, exc: StarletteHTTPException) -> JSONResponse:
code = "not_found" if exc.status_code == status.HTTP_404_NOT_FOUND else "invalid_request"
return error_response(AppError(code, str(exc.detail), status_code=exc.status_code))
@app.exception_handler(Exception)
async def handle_unexpected_error(_request: Request, exc: Exception) -> JSONResponse:
log_with_context(
"未处理异常",
level="ERROR",
trace_id=current_trace_id(),
error_type=exc.__class__.__name__,
)
return error_response(
AppError(
"internal_error",
"服务端内部错误。",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
details={"type": exc.__class__.__name__},
)
) )
+29 -170
View File
@@ -1,180 +1,39 @@
"""统一 ID 生成。""" """平台 ID 生成与校验(ADR-015 与 docs/contracts/README.md 生成规则)。
from uuid import uuid4 格式固定为 ``<prefix><lowercase-canonical-uuid>``;解析时必须校验前缀与
UUID version。UUID 生成使用成熟实现 uuid-utils,不自行拼位。
"""
import re
from uuid import UUID
def new_id(prefix: str) -> str: from uuid_utils import uuid4, uuid7
return f"{prefix}{uuid4().hex}"
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
def new_character_id() -> str:
return new_id("character_")
def new_id(prefix: str, *, version: int = 7) -> str:
"""生成带登记前缀的平台 ID;持久化资源默认 UUIDv7。"""
value = uuid7() if version == 7 else uuid4()
return f"{prefix}{value}"
def new_session_id() -> str:
return new_id("session_")
def parse_id(value: str, prefix: str, *, version: int = 7) -> UUID:
"""校验并解析平台 ID:前缀与 UUID version 均须匹配,否则抛 ValueError。"""
if not value.startswith(prefix):
raise ValueError(f"ID 前缀不合法:期望 {prefix!r}")
raw = value[len(prefix) :]
if not _UUID_RE.match(raw):
raise ValueError("ID 不是 canonical 小写 UUID 文本")
parsed = UUID(raw)
if parsed.version != version:
raise ValueError(f"ID version 不合法:期望 UUIDv{version}")
return parsed
def new_message_id() -> str:
return new_id("message_")
def is_valid_id(value: str, prefix: str, *, version: int = 7) -> bool:
def new_run_id() -> str: try:
return new_id("run_") parse_id(value, prefix, version=version)
except ValueError:
return False
def new_event_id() -> str: return True
return new_id("event_")
def new_trace_id() -> str:
return new_id("trace_")
def new_memory_id() -> str:
return new_id("memory_")
def new_memory_link_id() -> str:
return new_id("memory_link_")
def new_personality_id() -> str:
return new_id("personality_")
def new_personality_proposal_id() -> str:
return new_id("personality_proposal_")
def new_confirmation_id() -> str:
"""确认项 ID(前缀 confirm_,契约词典 §6)。"""
return new_id("confirm")
def new_audit_id() -> str:
return new_id("audit_")
def new_goal_id() -> str:
return new_id("goal_")
def new_world_entity_id() -> str:
return new_id("world_entity_")
def new_world_relation_id() -> str:
return new_id("world_relation_")
def new_plan_id() -> str:
return new_id("plan_")
def new_import_job_id() -> str:
return new_id("import_")
def new_import_item_id() -> str:
return new_id("import_item_")
def new_profile_candidate_id() -> str:
return new_id("profile_candidate_")
def new_memory_candidate_id() -> str:
return new_id("memory_candidate_")
def new_cycle_id() -> str:
return new_id("cycle_")
def new_evolution_candidate_id() -> str:
return new_id("evolution_")
def new_curiosity_id() -> str:
return new_id("curiosity_")
def new_subgoal_id() -> str:
return new_id("subgoal_")
def new_skill_id() -> str:
return new_id("skill_")
def new_mcp_server_id() -> str:
return new_id("mcp_")
def new_anniversary_id() -> str:
return new_id("anniv_")
def new_user_id() -> str:
return new_id("user_")
def new_embryo_id() -> str:
return new_id("embryo_")
def new_custodianship_id() -> str:
return new_id("cust_")
def new_seat_id() -> str:
return new_id("seat_")
def new_identity_record_id() -> str:
return new_id("identity_")
def new_mirror_tree_id() -> str:
return new_id("tree_")
def new_tree_node_id() -> str:
return new_id("node_")
def new_citation_id() -> str:
return new_id("cite_")
def new_canonization_id() -> str:
return new_id("canon_")
def new_transplant_id() -> str:
return new_id("transplant_")
def new_memory_trigger_id() -> str:
return new_id("memory_trigger_")
def new_factual_record_id() -> str:
return new_id("frecord_")
def new_role_id() -> str:
return new_id("rl_")
def new_permission_id() -> str:
return new_id("pm_")
def new_lorebook_id() -> str:
return new_id("lorebook_")
def new_lorebook_entry_id() -> str:
return new_id("lorebook_entry_")
+57
View File
@@ -0,0 +1,57 @@
"""灵台引擎薄封装(Python 宿主接入,ADR-016)。
- 懒初始化进程级单例;初始化或调用失败一律抛 LingtaiUnavailable,
由调用方 fail closed(禁止在宿主内用第二实现降级)。
- 仅做类型转换与错误映射,不缓存权威状态、不复制业务语义。
"""
import json
import threading
from typing import Any
import lingtai
_lock = threading.Lock()
_engine: lingtai.LingtaiEngine | None = None
class LingtaiUnavailable(RuntimeError):
"""引擎缺失、初始化失败或调用失败:依赖方必须显式失败。"""
def get_engine() -> lingtai.LingtaiEngine:
global _engine
with _lock:
if _engine is None:
try:
_engine = lingtai.LingtaiEngine()
except Exception as exc: # nanobind LingtaiError 等
raise LingtaiUnavailable(f"灵台引擎初始化失败:{exc}") from exc
return _engine
def reset_engine() -> None:
"""测试辅助:丢弃单例,下次访问重新初始化。"""
global _engine
with _lock:
_engine = None
def transform_markdown(text: str) -> dict[str, Any]:
"""Markdown → canonical ContentBlock(dict:parser_version + blocks)。"""
try:
engine = get_engine()
except LingtaiUnavailable:
raise
try:
raw = engine.transform_markdown(text)
except Exception as exc:
raise LingtaiUnavailable(f"灵台引擎调用失败:{exc}") from exc
parsed: dict[str, Any] = json.loads(raw)
for block in parsed["blocks"]:
block.setdefault("id", None)
return parsed
def transform_version() -> str:
return str(lingtai.transform_version())
-235
View File
@@ -1,235 +0,0 @@
"""loguru 日志配置(后端日志系统的唯一出口)。
提供全局日志配置、标准 logging 接管、上下文管理和便捷的日志记录函数。
所有日志(含 uvicorn / SQLAlchemy 等第三方库)统一汇入 loguru,
文件统一存放在项目根目录的 logs 文件夹下。
"""
import logging
import sys
from contextvars import ContextVar
from pathlib import Path
from typing import Any
from loguru import logger
from app.core.config import settings
# ==================== 上下文变量 ====================
# 用于在异步请求中传递日志上下文
_request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
"""请求 ID 上下文变量,用于追踪单个请求的完整链路。"""
_user_id_var: ContextVar[str | None] = ContextVar("user_id", default=None)
"""用户 ID 上下文变量,用于记录当前操作用户。"""
class InterceptHandler(logging.Handler):
"""标准 ``logging`` → loguru 转发器。
uvicorn / SQLAlchemy / httpx / langchain 等第三方库一律使用标准 ``logging`` 模块;
把本 handler 装到 root logger 后,它们的日志会统一汇入 loguru,与应用自有日志
走同一 sink、格式与落盘策略,实现整个后端日志系统的单一出口。
注:此处 ``import logging`` 与「避免使用 logging 模块」规范并不冲突——那条规范
针对的是「用 logging 记业务日志」,而本 handler 的职责恰是把 logging 的输出
导入 loguru,是统一日志出口的必要接管机制。
"""
def emit(self, record: logging.LogRecord) -> None:
# 将标准 logging 的级别名映射为 loguru 级别
try:
level: str | int = logger.level(record.levelname).name
except ValueError:
level = record.levelno
# 沿调用栈回溯到真正的日志发出处,让 loguru 记录正确的文件名 / 行号
frame, depth = logging.currentframe(), 2
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def setup_logging() -> None:
"""配置全局日志系统。
配置内容:
1. 创建日志目录
2. 移除 loguru 默认处理器
3. 添加控制台输出(彩色,级别跟随 debug 开关与 LOG_LEVEL)
4. 添加主日志文件(按大小轮转、压缩)
5. 添加错误日志单独文件(保留更久)
6. 添加访问日志(可选)
7. 接管标准 logging,使 uvicorn / SQLAlchemy 等第三方日志汇入 loguru
日志文件位置:
- {project_root}/logs/app.log # 主日志
- {project_root}/logs/errors.log # 错误日志
- {project_root}/logs/access.log # 访问日志(可选)
"""
# 获取并创建日志目录
log_dir: Path = settings.resolved_log_dir
log_dir.mkdir(parents=True, exist_ok=True)
# 移除 loguru 默认的 stderr 处理器
logger.remove()
# 判断是否为开发环境。开发 reload 进程不使用异步队列,避免退出时残留 multiprocessing semaphore。
is_dev = settings.is_development
enqueue_logs = not is_dev
# 控制台输出格式(带颜色,便于阅读)
console_format = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan>"
"{extra} - <level>{message}</level>\n{exception}"
)
# 文件输出格式(纯文本,便于解析)
file_format = (
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | "
"{name}:{function}:{line}{extra} | {message}\n{exception}"
)
# ----- 控制台输出 -----
# 控制台级别跟随 debug 开关与 LOG_LEVEL:debug=true → DEBUG,否则用 LOG_LEVEL。
console_level = "DEBUG" if settings.debug else settings.log_level
logger.add(
sys.stderr, # 输出到标准错误
level=console_level, # 日志级别
format=console_format, # 彩色格式
colorize=is_dev, # 开发环境启用颜色
enqueue=enqueue_logs, # 生产环境异步写入,提高性能
backtrace=is_dev, # 开发环境显示完整回溯
diagnose=is_dev, # 开发环境显示诊断信息
)
# ----- 主日志文件 -----
# 记录所有级别日志,按大小轮转,自动压缩
logger.add(
log_dir / "app.log", # 日志文件路径
level=settings.log_level, # 日志级别
format=file_format, # 纯文本格式
rotation=settings.log_rotation_size, # 轮转大小:10 MB
retention=f"{settings.log_retention_days} days", # 保留天数:14天
compression="gz", # 压缩旧日志
encoding="utf-8", # 编码
enqueue=enqueue_logs, # 生产环境异步写入
backtrace=False, # 不保存完整回溯
diagnose=False, # 不保存诊断信息
)
# ----- 错误日志单独文件 -----
# 只记录 ERROR 及以上级别,保留更久,便于故障排查
logger.add(
log_dir / "errors.log", # 错误日志文件路径
level="ERROR", # 只记录 ERROR 及以上
format=file_format, # 纯文本格式
rotation="50 MB", # 轮转大小:50 MB
retention="30 days", # 保留天数:30天
compression="gz", # 压缩旧日志
encoding="utf-8", # 编码
enqueue=enqueue_logs, # 生产环境异步写入
backtrace=True, # 错误时保留完整堆栈
diagnose=True, # 错误时保留诊断信息
)
# ----- 访问日志(可选) -----
# 通过 filter 只记录标记为 access 类型的日志
if settings.log_access:
logger.add(
log_dir / "access.log", # 访问日志文件路径
level="INFO", # INFO 及以上
format=file_format, # 纯文本格式
rotation="100 MB", # 轮转大小:100 MB
retention="7 days", # 保留天数:7天
compression="gz", # 压缩旧日志
encoding="utf-8", # 编码
enqueue=enqueue_logs, # 生产环境异步写入
filter=lambda r: r["extra"].get("type") == "access", # 只记录访问日志
)
# ----- 接管标准 logging -----
# force=True 清掉第三方库自带的 handler(含 uvicorn 默认 dictConfig);
# level=0 让记录全量进入,最终级别过滤交由上方 loguru sink 完成。
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
# 应用已有自研访问日志中间件(trace_middleware + log_access,含 trace_id /
# method / path / status / duration 等结构化字段),关闭 uvicorn.access 以
# 避免访问日志重复落盘。
logging.getLogger("uvicorn.access").disabled = True
# ==================== 日志上下文管理 ====================
def set_request_id(request_id: str) -> None:
"""设置当前请求的 ID。
Args:
request_id: 请求唯一标识,用于链路追踪
"""
_request_id_var.set(request_id)
def set_user_id(user_id: str) -> None:
"""设置当前操作用户的 ID。
Args:
user_id: 用户唯一标识
"""
_user_id_var.set(user_id)
def clear_log_context() -> None:
"""清除当前日志上下文。
在请求结束后调用,避免上下文污染。
"""
_request_id_var.set(None)
_user_id_var.set(None)
def get_log_context() -> dict[str, Any]:
"""获取当前日志上下文。
Returns:
包含 request_id 和 user_id 的字典(如果已设置)
"""
context = {}
if rid := _request_id_var.get():
context["request_id"] = rid
if uid := _user_id_var.get():
context["user_id"] = uid
return context
def log_with_context(message: str, level: str = "INFO", **kwargs: Any) -> None:
"""带上下文记录日志。
自动附加当前请求的 request_id 和 user_id。
Args:
message: 日志消息
level: 日志级别(DEBUG/INFO/WARNING/ERROR)
**kwargs: 额外的上下文字段
"""
context = get_log_context()
context.update(kwargs)
logger.bind(**context).log(level, message)
def log_access(message: str, **kwargs: Any) -> None:
"""记录访问日志。
自动添加 type="access" 标记,会被 access.log 过滤器捕获。
Args:
message: 日志消息
**kwargs: 额外的上下文字段(如 method, path, status, duration 等)
"""
logger.bind(type="access", **kwargs).info(message)
+80
View File
@@ -0,0 +1,80 @@
"""请求上下文中间件与结构化日志。
- `Hunjing-Request-ID`:合法则回显,缺失由服务端生成(UUIDv4,request_ 前缀)。
- `traceparent`:透传 W3C Trace Context;缺失生成新 trace-id(不与业务请求 ID 混用)。
- JSON 结构化日志:Authorization/Cookie/Set-Cookie 等敏感头脱敏。
"""
import json
import logging
import re
import uuid
from typing import Any
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from app.core.ids import new_id
REDACTED_HEADERS = {"authorization", "cookie", "set-cookie", "hunjing-csrf-token", "x-api-key"}
_TRACEPARENT_RE = re.compile(r"^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$")
def _sanitize_headers(headers: Any) -> dict[str, str]:
return {
k: ("[REDACTED]" if k.lower() in REDACTED_HEADERS else v) for k, v in headers.items()
}
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
for key in ("request_id", "trace_id", "method", "path", "status"):
value = getattr(record, key, None)
if value is not None:
payload[key] = value
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)
def configure_logging(level: str = "info") -> None:
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers = [handler]
root.setLevel(level.upper())
class RequestContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Any:
request_id = request.headers.get("Hunjing-Request-ID") or new_id("request_", version=4)
traceparent = request.headers.get("traceparent")
if traceparent is None or not _TRACEPARENT_RE.match(traceparent):
traceparent = f"00-{uuid.uuid4().hex}-{uuid.uuid4().hex[:16]}-01"
trace_id = traceparent.split("-")[1]
request.state.request_id = request_id
request.state.trace_id = trace_id
response = await call_next(request)
response.headers["Hunjing-Request-ID"] = request_id
response.headers["traceparent"] = traceparent
logging.getLogger("hunjing.access").info(
"request",
extra={
"request_id": request_id,
"trace_id": trace_id,
"method": request.method,
"path": request.url.path,
"status": response.status_code,
},
)
return response
__all__ = ["RequestContextMiddleware", "configure_logging", "_sanitize_headers"]
-44
View File
@@ -1,44 +0,0 @@
"""敏感信息脱敏工具。"""
import re
from collections.abc import Mapping
from typing import Any
SENSITIVE_KEYS = {
"api_key",
"apikey",
"authorization",
"password",
"secret",
"token",
"access_token",
"refresh_token",
"private_key",
}
_REDACTED = "[REDACTED]"
_BEARER_RE = re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]+=*", re.IGNORECASE)
def redact_text(value: str) -> str:
return _BEARER_RE.sub("Bearer [REDACTED]", value)
def redact_mapping(data: Mapping[str, Any]) -> dict[str, Any]:
redacted: dict[str, Any] = {}
for key, value in data.items():
normalized = key.lower().replace("-", "_")
if normalized in SENSITIVE_KEYS or any(part in normalized for part in SENSITIVE_KEYS):
redacted[key] = _REDACTED
else:
redacted[key] = redact_value(value)
return redacted
def redact_value(value: Any) -> Any:
if isinstance(value, str):
return redact_text(value)
if isinstance(value, Mapping):
return redact_mapping(value)
if isinstance(value, list):
return [redact_value(item) for item in value]
return value
-20
View File
@@ -1,20 +0,0 @@
"""时间工具。"""
from datetime import UTC, datetime
def utc_now() -> datetime:
return datetime.now(UTC)
def to_iso(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
def as_utc(value: datetime) -> datetime:
"""SQLite 读回 naive datetime 时补 UTC;PG(timezone=True) 读回 aware 则原样返回。"""
return value if value.tzinfo else value.replace(tzinfo=UTC)
+1 -1
View File
@@ -1 +1 @@
"""数据库基础设施。""" """数据库层:基础、引擎与命名约定。"""
+44 -4
View File
@@ -1,11 +1,51 @@
"""SQLAlchemy declarative base。""" """数据库基础:命名约定、异步引擎与会话。
索引/约束命名按 AGENTS.md 8.6(pk_/uk_/idx_/fk_ + 表名);
主库统一 PostgreSQL(ADR-024),不提供 SQLite 档。
"""
from collections.abc import AsyncIterator
from sqlalchemy import MetaData
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from app.core.config import get_settings
naming_convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass metadata = MetaData(naming_convention=naming_convention)
# 导入模型,确保 Alembic autogenerate 能发现 metadata。 def create_engine() -> AsyncEngine:
from app import models as _models # noqa: E402,F401 return create_async_engine(get_settings().database_url, pool_pre_ping=True)
def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(engine, expire_on_commit=False)
async def session_scope(
factory: async_sessionmaker[AsyncSession],
) -> AsyncIterator[AsyncSession]:
"""事务作用域:成功提交、异常回滚(权威状态与 Outbox 同事务,契约第 4.4 节)。"""
async with factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
-93
View File
@@ -1,93 +0,0 @@
"""百工能力种子(内置能力幂等登记;旧枢治理种子随 M8 退役)。"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domains.baigong.models import Capability
from app.domains.baigong.service import PLATFORM_SCOPE
_BUILTIN_CAPABILITIES = [
# name, 描述, 执行类型, 执行器, 四维风险, 数据范围, 目的地, consent
(
"builtin.echo",
"回显输入(无副作用演示能力)",
"builtin",
"echo",
{"capability": "L0", "data": "L0", "consequence": "L0", "context": "L0"},
"",
"",
"allow",
),
(
"builtin.datetime",
"读取当前时间",
"builtin",
"datetime.now",
{"capability": "L0", "data": "L0", "consequence": "L0", "context": "L0"},
"",
"",
"allow",
),
(
"qiankun.shell",
"在乾坤沙箱中执行命令(隔离执行,HMAC 授契)",
"qiankun",
"",
{"capability": "L2", "data": "L1", "consequence": "L3", "context": "L1"},
"沙箱工作目录",
"本机沙箱进程",
"ask",
),
(
"web.fetch",
"抓取指定 URL 内容(受限出站)",
"http",
"",
{"capability": "L1", "data": "L1", "consequence": "L2", "context": "L1"},
"目标 URL 响应体",
"目标站点",
"allow",
),
]
async def _seed_capabilities(session: AsyncSession) -> None:
"""内置能力按 (owner, name) 幂等登记(source=builtin,四维风险标注随代码演进)。"""
import json as _json
from sqlalchemy import select
existing = set(
(
await session.execute(
select(Capability.name).where(Capability.source == "builtin")
)
)
.scalars()
.all()
)
for name, desc, executor_type, ref, dims, scope, dests, consent in _BUILTIN_CAPABILITIES:
if name in existing:
continue
session.add(
Capability(
name=name,
owner_scope=PLATFORM_SCOPE,
description=desc,
executor_type=executor_type,
executor_ref=ref,
risk_dims_json=_json.dumps(dims),
data_scope=scope,
destinations=dests,
consent_mode=consent,
source="builtin",
)
)
async def ensure_governance_seed(session: AsyncSession) -> None:
"""启动幂等登记内置能力。"""
await _seed_capabilities(session)
await session.commit()
Loaded 100 of 872 files, more files were not shown because too many files have changed in this diff. Show more