Compare commits

..
34 Commits
Author SHA1 Message Date
chenxin 73ad3c02b0 启动补丁:乾坤装配参数名、注册接口双前缀、/chat 残留链接清理
- main.py:Executor(sandbox, http_client=…) 参数名与构造签名不符(http=…),致乾坤沙箱装配降级 503——修正
- 注册页手写 fetch 多拼一层 v1(/api/proxy/v1/auth/register → 后端 /api/v1/v1/* 404);BACKEND_ROOT 已含 /api/v1,proxy 调用不应再带——去掉
- /chat 页 M2b 已随线性会话退役,对话主入口为 /trees(镜树);六处残留链接(首页/登录/注册跳转/错误页/404 页/管理壳)全部改指 /trees
- 回归:前端 type-check + 203 passed;后端百工+API 套件 45 passed
2026-08-28 10:31:36 +08:00
chenxin 3c050b923c M9 补丁:启动冒烟修复——镜树列表 str 属性访问、内置能力平台作用域与种子提交缺失
真实进程冒烟(灵台+神识起进程 → 登录 → 能力目录 → 授契 → 调用)抓到三处缺陷,全量测试因分支未覆盖而漏网:
① mirror_tree.list_trees:select(Character.id).scalars() 已返回 id 字符串,c.id 属性访问在有 character 的 owner 上必炸(M2a 引入,测试只走了无 character 分支)——改为直接收集
② 内置能力种子写死 owner_scope=local-user,真实用户(UUID owner)查不到能力目录——引入 PLATFORM_SCOPE:builtin 能力挂平台域全局可见可授契,自定义能力仍按 owner 隔离
③ ensure_governance_seed 缺 session.commit——种子数据未提交即随启动 session 回滚(直调误读为成功因同 session 可见未提交数据)
冒烟证据:未授契调用 403 HJ-1303 留痕 → 授行契 → 调用 ok 返回观察标证;能力目录 4 项(builtin.datetime/echo/qiankun.shell/web.fetch);全量回归 349 passed 不变
另:本地 .env 补入神识段(SHENSHI_BASE_URL=127.0.0.1:8080 接真神识)
2026-08-28 10:06:35 +08:00
chenxin 2483a25f63 M9:桌面壳与收口——镜匣最小壳、器谱登记、发布证据落位、重写计划功成身退
- 镜匣 desktop/(Tauri v2 最小壳):WebView 承载琉璃镜(frontend/out 或 devUrl);能力桥最小命令(通知);乾坤通道 HMAC 短期凭证构造(与 sandbox/src/auth.rs 验签同算法,密钥仅经 SANDBOX_AUTH_SECRET 环境注入——二进制零平台密钥,静态审计钉死);hard_stop/resume 本地硬急停原子断路(全部通道命令过 ensure_not_halted,本地最高优先级不经云端);tantivy 依赖与 capabilities/ 目录预留;updater 插件位预留;cargo check 通过
- 器谱域 domains/tianzai(迁移 010):devices/device_grants——一次配对(密钥指纹登记)、逐项授契(可授面显式枚举,未知能力键拒)、撤回立即生效、设备撤销连带全部授契失效且不可再授;/devices 器谱 API 组
- 发布收口:release-evidence.md 落位重写期 RW-* 证据包(M0–M9 十里程碑验收自动化套件与结果物);能力矩阵 M9 行收官;rewrite-plan.md 按计划删除(M0–M9 全部完成,能力矩阵与证据台账接棒),全库引用清理
- 验收(test_m9_jingxia 四用例):器谱未授契 403 留痕/撤回即效/设备撤销连带;镜匣零平台密钥(rg 审计源码与配置为空);硬急停语义钉死(通道命令全检查+resume 唯一恢复+HMAC 无密钥不出凭证)
- 五套件终检全绿:后端 349、前端 203、BFF 4、神识 5 包、镜匣 cargo check(2 个预存环境失败历次 HEAD 复验与本周期无关);Compose nginx profile 配置校验通过
2026-08-28 09:35:27 +08:00
chenxin c63b75152a M8:天工阁、叩心与辰正——受托任务后台并行、叩心四约束、时态感知投影
- 天工阁域 domains/tiangong:backstage_tasks(委托包+验收标准+结果证据);有限分解(parent 链)与受控并行(各自后台 Run);子任务全终态级联父任务进入待验收;按证据 accept/reject
- kernel.start_backstage_run(kind=background_task):loop 后台分支不占前台席位、与前台对话并行;断言放宽容纳无人物功能任务;结果只经回调回呈任务——无身份写入路径
- 叩心域 domains/kouxin:knocking_candidates(event/time_anchor/manual 触发 + 草稿 + 裁决快照);四约束裁决(总开关→静默窗口【本地时区判定】→24h 频率→预算),dispatch 未过 429 且快照留痕;候选默认不自动发送;expire_stale 过期推进不触碰缘契
- 辰正 domains/zhengchen:temporal_context() 复用 time_sense 计算核(快照/农历/季节),产出【时态感知】投影段接入镜树生成 prompt(客观时间语境,不附情绪臆测——守一纪律)
- 退役:modules/goal_orchestrator|planner|perception|big_cycle、application/planning、services/governance|goal_persist、candidates 表链(imports 候选抽取 API 面)、goals/plans/agent_roster/perception_channels/big_cycles/旧候选表(迁移 009);前端 admin goals/plans/agents/perception/world 页与六组件;governance 只留 GenerationPreferences;admin 治理面收敛为司命+百工两枢;pivots 健康以域表计数重建
- 验收(test_m8_tiangong 五用例):后台任务无身份写入(记忆/魂籍/缘契零新增);后台与前台并行;分解级联;叩心四约束逐项;不回应不降低关系(缘契维度/状态/版本零变化)
- 四套件:后端 345、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 009 净库干净
2026-08-28 09:13:39 +08:00
chenxin b419e0afde M7:形仪与语音——真形锚点、候选定相、声音档案与克隆授权、ASR 接入
- 形仪域 domains/xingyi:form_anchors(稳定辨识特征/年龄表现/许可禁忌/可变范围——生成内容不可静默覆盖锚点);forms(主仪/化仪/文字形仪,candidate→adopt 定相治理:未定相不进呈现位、主仪唯一、新主仪自动退役旧主仪);voice_profiles(三来源,授权克隆必填用途与传播范围,revoke-grant 撤回立即生效,assert-usable 合成前置校验拒绝撤回/过期)
- ASR:元炁 SenseVoice /v1/audio/transcriptions 透传(Whisper 兼容 multipart);YUANQI_BASE_URL 未配置时 503 不降级不伪造——多模态关闭纪律
- 前端形仪工坊(人物详情页):真形锚点编辑(特征逐行解析)/候选登记定相废弃/声音档案建立与授权撤回;头像呈现位改走定相主仪(forms/primary);xingyi api 库 + mock + 组件 4 用例
- 退役:旧 avatar 模块(services/endpoints/avatar、avatar_dir 配置、avatar_url 字段、前端直传组件与测试)
- 验收(test_m7_xingyi 三用例):未定相不成为正式形仪(含主仪唯一与旧主仪 retired 断言);克隆授权可撤回(缺用途拒/撤回即拒/过期同理/预设不受约束);关闭全部多模态后文字相伴完整可用(ASR 503 时镜树缘契百工照常——回归)
- 四套件:后端 376、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 008 净库+±1 干净
2026-08-27 20:26:52 +08:00
chenxin 7e23d6fa84 M6:缘契、人格与世界——契约版本链、铸魂分灵、河图境界与三界分流
- 缘契域 domains/yuanqi:covenants(当前版)+ covenant_events(不可变历史);提议—问心—铭契—撤回全链;边界变更立即生效;多维关系并存,服务层拒绝 affinity/好感度单一键;浪漫维度成人资格标记位
- 道体铸魂与分灵 domains/jingsheng/soul.py:soul_forgings 确认流(锚点变更 pending→confirmed 应用并签发新魂籍版本,确认前人物锚点不变);分灵创建新人物(继承资料与谱系 lineage 链、不继承原身份,魂籍从 v1 起)
- 河图域 domains/hetu:persistent_worlds 境界 + world_entities/world_relations 境界作用域重建 + 世谱挂接;境界正史视图(scope=persistent_world 归真集合);canonize 后置推进正史计数
- 三界分流:reality→息壤提取(不变);persistent_world→境界实录+正史推进;simulation→只记试演实录;factual_records 增 scope 列——境界/幻境实录与真界本相隔离
- 正影打通:引文 target_type=memory 时被引记忆 superseded → 返回 successor_memory_id 提示,不静默替换
- 前端:人物详情页缘契面板(提议维度选择/铭契/撤回/事件史)+ 世界页重建(境界目录/实体/正史概览)+ covenants/worlds api 库
- 退役:旧 personality/drive/evolution/world 模块、服务、端点、仓储、schema、前端五页与组件样式;迁移 007 退役九张旧表与 characters.current_personality_version_id
- 验收(test_m6_domains 六用例):多维无单一好感度/铭契版本链/事件不可抹除/撤回立即生效/边界即时/铸魂确认流+魂籍版本/分灵谱系/境界正史不进真界(实录 scope 隔离+真界候选=0)/引文正影后继
- 四套件:后端 379、前端 199、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 007 净库+±1 干净
2026-08-27 20:10:11 +08:00
chenxin 684261f4b4 M5:百工与司命——能力注册表、授契行契请命、禁域裁决与观察标证
- 百工域 domains/baigong:capabilities(统一能力契约:执行类型 builtin/qiankun/mcp/http + 四维风险标注 + 来源版本)、mandates(授契 grant / 行契 charter 预算时间窗,撤销立即生效)、tool_calls(净化摘要 + 观察标证);请命复用 pending_confirmations 加 expires_at/brief_json(超时=拒绝执行 HJ-1302,六要素说明)
- 司命裁决器 siming.py:四维取最高档(能力/数据/后果/情境);禁域为代码常量不可配置(递归删根/格式化/写裸设备/fork 炸弹、file:// 协议、密钥材料外发)——专项测试断言恶意标注与篡改对象仍拒 HJ-1301
- 执行链:禁域→裁决(HJ-1303 未授契拒且双留痕)→请命→执行(乾坤经 SandboxClient HMAC;MCP 经 call_tool;内置无副作用)→净化标证(观察非事实,敏感遮蔽+体量截断)→行动账
- MCP 登记/启停/刷新自动派生与撤下 capabilities(博闻登记接入点);内置能力种子(echo/datetime/qiankun.shell/web.fetch)
- 端点:/capabilities /mandates /petitions /tool-calls + /tools 兼容投影;前端百工页四区(能力目录四维标注/授契行契/请命批准拒绝/行动账观察标证)
- 退役:modules/safety|skills|tool_runtime|agent、application/tool_runtime、services/skills|safety、tool_policies/skills/model_endpoint_overrides 表(迁移 006)、前端 SkillManager/ToolsConsole/旧 tools 页
- 验收(test_baigong_siming 四用例):未授契被拒留痕、请命超时不执行、禁域不可关闭、结果为观察不入忆;后端 454、前端 220、BFF 4、神识 5 包全绿(2 个预存环境失败不变)
2026-08-27 19:17:21 +08:00
chenxin 144415247e M4 收口:神识用户面与计量闭环——BYOK/配额三段式/usage 落库/Anthropic+Gemini 适配器/神识阁
- 神识 Go:store 双实现(PG 自动建表+60s 就绪重试 / 内存回退)承载 usage_logs·byok_keys·quota_balances;BYOK 密钥库(AES-GCM 加密、API 脱敏、撤回即失效、主密钥未配置 fail-closed);配额三段式(预扣 max_tokens 钳位 1M→结算实际 token→全败幂等退款,HJ-2004);计量归属(IR user_id→usage_logs,/me/usage 用户维度聚合+配额总览,/admin/usage/sample 对账抽样);Anthropic/Gemini 出站适配器(SSE 解析、system 提升、用量映射);stub 端点补名修复计量空 endpoint
- IR 契约:user_id/byok_key_id 双端同步(Go types.go + Py types.py)
- 灵台:runs.owner_id(迁移 005,server_default 兼容存量)经 kernel 全链透传为 IR user_id;shenshi_client 扩展用户面方法(X-Hunjing-User-Id 代理,Echo 桩带内存实现);/me/byok-keys 登记·列表·撤回与 /me/usage 端点(4 用例)
- 前端神识阁(/models 重建):偏好(生成参数表单)/自带密钥(登记加密保存、脱敏列表、撤回二次确认)/用量(聚合表+配额进度条)三区;me API 库+mock+5 用例;侧栏恢复入口
- 配置与部署:.env.example 增 SHENSHI_DATABASE_URL/SHENSHI_BYOK_MASTER_KEY/SHENSHI_USER_BUDGET_TOKENS;compose 神识容器接自有库(init-shenshi.sql 预建 hunjing_shenshi,与灵台不共库共表)
- 证据:Go 34 用例(BYOK 独占/配额拒绝/结算退款/归属聚合)、后端 532、前端 210、BFF 4 全绿;跨进程 E2E 四场景(灵台 HttpShenshiClient→真 Go 神识:登记脱敏/BYOK 独占不可达整体失败/用量归属聚合/撤回即失效)通过;rewrite-plan M4 标记完成、能力矩阵神识行升已验证
2026-08-27 18:39:17 +08:00
chenxin 462d570751 M4b:ai_provider 删除手术①-⑥——神识全面接管模型出口
- ① 后端删除 app/ai_provider/(38 文件,含 anthropic/openai 格式、billing、audio、embedding)、services/ai_provider_admin.py、core/config/model_config.py、tests/ai_provider/(21 文件)
- ② 连带退役 application/agent/(旧 Agent 循环/steer/follow-up/确认注册表)、application/skill_executor.py、app/skills/(python skills 孤儿)、endpoints/audio.py、application/imports.py(ProviderManager 消费方);settings 端点收敛为生成偏好
- ③ runs.py 修剪:AgentRunRegistry/steer/follow-up/confirmations 端点退役,保留 list/get/events/cancel/stream;safety 确认改为直接落库裁决(pending→approved/denied)
- ④ 前端删除:models 页、ModelGatewayView、gateway.ts、GatewayPanel(玄关)、ModelGatewayPanel/ModelStrategyForm、audio.ts/useVoiceInput、网关类型与 mock;设置页收敛为外观/语言/通知
- ⑤ 配置收敛:config/models|providers|pricing.example.yaml 归档 shenshi/config/;.env.example 删 provider keys/MODEL_ENDPOINT_POOL/MODEL_CONFIG_PATH,收敛 SHENSHI_*
- ⑥ 验收:rg 审计灵台 Provider SDK import 与 ai_provider 引用归零;后端 528 passed(2 个预存失败经 HEAD 复跑验证与本手术无关)、前端 type-check+201 passed、神识 go vet+test、BFF 4 passed
2026-08-27 10:02:44 +08:00
chenxin 0d383725b5 更新相关的文档和配置 2026-08-27 09:37:35 +08:00
chenxin 79db56a87b M4b:固化 ai_provider 删除手术清单(30+ 文件侦察结论) 2026-08-27 09:34:19 +08:00
chenxin 45d7c19cf1 M4a:能力矩阵与执行计划落神识证据(M4b 待办明确) 2026-08-27 09:32:59 +08:00
chenxin 7279904043 M4a:神识全量网关能力——OpenAI 兼容适配器(元炁共用)、候选路由+凭证/绑定级冷却+首字超时切道+粘性会话、用量计量与 /me/usage,故障注入测试四场景 2026-08-27 09:32:37 +08:00
chenxin df5caa69e7 M3b:能力矩阵与执行计划落证据(含时序效果顺延理由) 2026-08-27 09:22:23 +08:00
chenxin e914aec9c4 M3b:世谱激活接入组装管线(关键词命中/常驻/预算/符文展开)与前端记忆治理页(候选箱/重忆确认/正影/删除/实录溯源) 2026-08-27 09:22:20 +08:00
chenxin eb0027757d M3a:能力矩阵与执行计划落息壤证据(含退役清单) 2026-08-27 09:16:52 +08:00
chenxin 9c98c2f85e M3a:息壤三级记忆域——归真触发提取管线/白名单明忆/重忆确认/正影版本链/删除墓碑/双通道召回注入,退役旧记忆-抽取-反思-大周天-演化管线(迁移 004,验收 7 用例) 2026-08-27 09:16:32 +08:00
chenxin 2a640a901b M2c:前端镜树页(路径阅读流/候答切换/采用归真/局部树图/SSE 芽体)与灵胚页(创建/导入卡/照心试演/启镜),契约错误码段接入前端 2026-08-21 23:54:17 +08:00
chenxin 30447fc40f 移除误入版本库的环境生成 AGENTS.md 副本(根目录 AGENTS.md 为唯一指令源) 2026-08-21 23:43:45 +08:00
chenxin 118445b208 M2b:能力矩阵与执行计划落证据(含提前退役说明与 M2c 待办) 2026-08-21 23:43:34 +08:00
chenxin ad9c9e942a M2b:删除旧 chat 体系——线性会话/叩心/情绪/纪念日模型与页面退役(迁移 003 双向验证),runs 去会话外键,测试全量修剪 2026-08-21 23:43:31 +08:00
chenxin 5f36e3785c M2b:导入改造产出可审阅灵胚(启镜物化世谱)与组装管线(符文展开/火候进入 IR),Kernel 打通采样参数链路 2026-08-21 23:43:29 +08:00
chenxin e6a3b7121f M2a:镜树域后端核心——树/节点/引文/归真/移栽全量 API 与 Kernel 完成回调(芽体固化/残答封存),8 用例钉死 PRD 场景 1/2/3/5/6/7/8 2026-08-21 23:00:07 +08:00
chenxin 1cd0069d65 M1:镜生域后端闭环——灵胚/照心试演/启镜原子闸门/执镜关系/命台租约/魂籍签名导出/死亡冷静期(迁移 001,测试 7 用例) 2026-08-21 22:39:07 +08:00
chenxin c090893e63 统一服务启动入口至 Makefile(dev-core/dev-*/db-*/test-*),Alembic 迁移改为 000 起三位顺序编号并经 make db-revision 生成 2026-08-21 22:12:34 +08:00
chenxin 8ebcc11a8a M0:编排接入神识与照壁(天门 /api 改经照壁)、.env.example 新增配置段、SSE 契约路径对齐 /stream、能力矩阵与执行计划落 M0 证据 2026-08-21 22:07:39 +08:00
chenxin 8d8d81e757 M0:照壁 BFF 骨架(Fastify 透明代理:鉴权/幂等/续传头透传、SSE 直通、HJ-3001 映射)及测试与镜像 2026-08-21 22:07:39 +08:00
chenxin f3058a64c3 M0:神识 Go 网关骨架(IR 入站/SSE 出站/stub 适配器/服务令牌/健康检查)及测试与镜像 2026-08-21 22:01:41 +08:00
chenxin 7ef3a15061 M0:灵台 Kernel 骨架(事件溯源/命台席位/瀑布总线/插件注册)与神识客户端边界,新增 POST /runs 占位闭环与测试,Alembic 基线合并为单一起点 2026-08-21 21:58:09 +08:00
chenxin 99e55fa0a8 新增重写执行计划、重写能力矩阵并同步 README 索引与技术栈映射 2026-08-21 21:20:00 +08:00
chenxin e66e50bee4 重写契约词典(新标识/枚举/事件/错误码)并新增 API 契约 2026-08-21 21:20:00 +08:00
chenxin 0ca9396f4d 重写架构文档:五层栈总架构、服务边界、灵台插件化运行时、六域数据模型、神识网关、BFF 与桌面壳 2026-08-21 21:20:00 +08:00
chenxin 6a65f3b582 整合 backend/CLAUDE.md 与 frontend/CLAUDE.md 至 AGENTS.md 2026-08-21 21:12:37 +08:00
chenxin 887c1f449e 初始化提交 2026-08-21 16:00:13 +08:00
978 changed files with 143793 additions and 41560 deletions

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
# ==============================
# Docker 构建忽略清单
# build context 为项目根(docker-compose.yml 中 context: ..),
# 本文件同时影响 backend 与 frontend 镜像构建。
# ==============================
# ----- 版本控制 -----
.git/
.gitignore
.gitattributes
# ----- Python / 后端 -----
backend/.venv/
backend/**/__pycache__/
backend/**/*.py[cod]
backend/.pytest_cache/
backend/.mypy_cache/
backend/.ruff_cache/
backend/.coverage
backend/coverage.xml
backend/htmlcov/
backend/dist/
backend/build/
backend/*.egg-info/
# ----- Node / 前端 -----
frontend/node_modules/
frontend/.pnpm-store/
frontend/.next/
frontend/out/
frontend/.vercel/
frontend/.turbo/
frontend/*.tsbuildinfo
frontend/.eslintcache
frontend/npm-debug.log*
frontend/pnpm-debug.log*
# ----- 运行时数据与本地存储(禁止进镜像) -----
db/*.sqlite
db/*.sqlite3
db/*.db
db/*.db-shm
db/*.db-wal
db/chroma/
storage/
uploads/
media/
objects/
logs/
*.log
*.pid
# ----- Rust / 安全沙箱 -----
sandbox/target/
sandbox/sandbox-work/
**/*.rs.bk
# ----- Docker 本地覆盖 -----
docker/*.local.yml
docker-compose.override.yml
# ----- 配置密钥(只允许 .example) -----
.env
.env.*
!.env.example
config/models.yaml
frontend/.env.local
# ----- IDE / OS -----
.vscode/
.idea/
.DS_Store
Thumbs.db
# ----- AI / 工具缓存 -----
.claude/cache/
.claude/tmp/
# ----- 文档(镜像运行不需要) -----
docs/
README.md
**/CLAUDE.md
+656 -23
View File
@@ -1,31 +1,664 @@
# ============================================================ # 魂镜 环境变量示例
# Hunjing 环境变量示例(复制为 .env 后按需修改;不要提交 .env) # 复制为 .env 后按需修改
# ============================================================ #
# 分层分组法排列:核心运行环境 → 应用配置 → 第三方服务 → 基础设施
# 每组内按"同类"对齐:连接地址 → 端口 → 认证信息 → 开关/参数
# 各组按"如果删除此功能能否一键删除整组"的组织原则排列
#
# 标注了"必填"的变量在启动前必须设置,
# 标注"按需"的变量保留默认值即可正常工作。
# 每个变量的默认值是本地开发推荐值。生产环境请根据实际部署调整。
# 运行环境:development / staging / production # =====================================================================
HUNJING_ENV=development # 全局基础配置(所有子系统共用)
# =====================================================================
# 数据库(PostgreSQL 统一,ADR-024;默认连接 docker-compose 启动的 hunjing-postgres) # 应用名称,仅用于 OpenAPI、日志和健康检查展示
# 注意:后端 Settings 统一 HUNJING_ 前缀读取,无前缀的 DATABASE_URL 不会生效 APP_NAME=魂镜
HUNJING_DATABASE_URL=postgresql+asyncpg://hunjing:hunjing_dev_password@127.0.0.1:5432/hunjing
# 后端服务端口 # 运行环境:dev / prod
HUNJING_API_PORT=8000 # prod 环境下博闻的日志输出会切换为 JSON 格式,便于日志采集系统(如 Loki)解析
APP_ENV=dev
# 对象存储根目录(本地存储档;上传与媒体文件落位) # 服务端默认时区,用于日志和数据库时间展示。格式:IANA Time Zone
HUNJING_STORAGE_ROOT=storage # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
TIMEZONE=Asia/Shanghai
# 沙箱隔离开关:未启用时隔离插件执行 fail-closed(拒绝执行,不回退宿主)
HUNJING_SANDBOX_ENABLED=false
# 日志级别:debug / info / warning / error # =====================================================================
HUNJING_LOG_LEVEL=info # 后端 · 灵台(FastAPI)
# =====================================================================
# 注意:密钥、Provider 凭据等敏感值只通过环境变量或秘密管理注入, # --- 部署与跨域 ---
# 不写入本文件、config/ 真实配置或任何日志。 # 后端监听地址与端口
BACKEND_HOST=0.0.0.0
BACKEND_PORT=8000
# 开发档管理员邮箱白名单(逗号分隔):命中邮箱首次登录(建户)即为 admin 角色; # 允许跨域来源,多个来源用逗号分隔
# 例:HUNJING_DEV_ADMIN_EMAILS=admin@hunjing.dev # 生产环境前端独立域名部署时生效;本地开发浏览器经 next.config.ts 同源代理,不触发 CORS
# 开发档 Mock 模型密钥(make mock-model + backend-dev 联调 Run 流水线) CORS_ALLOW_ORIGINS=http://localhost:3000
HUNJING_DEV_ADMIN_EMAILS=
HUNJING_DEV_MOCK_KEY= # 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
-71
View File
@@ -1,71 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: hunjing
POSTGRES_PASSWORD: hunjing_dev_password
POSTGRES_DB: hunjing
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U hunjing"
--health-interval 5s --health-timeout 3s --health-retries 12
env:
DATABASE_URL: postgresql+asyncpg://hunjing:hunjing_dev_password@127.0.0.1:5432/hunjing
TEST_DATABASE_URL: postgresql+asyncpg://hunjing:hunjing_dev_password@127.0.0.1:5432/hunjing_test
HUNJING_WEBHOOK_SECRET: whsec_ci
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
python-version: "3.12"
- name: 安装依赖
working-directory: backend
run: uv sync
- name: 创建测试库
run: |
sudo apt-get update && sudo apt-get install -y postgresql-client
PGPASSWORD=hunjing_dev_password psql -h 127.0.0.1 -U hunjing -d hunjing \
-c "CREATE DATABASE hunjing_test"
- name: Lint 与类型检查
working-directory: backend
run: |
uv run ruff check .
uv run mypy app
- name: 测试
working-directory: backend
run: uv run pytest -q
engine:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 安装 CMake
run: pipx install cmake
- name: 构建(警告即错误)
run: |
cmake -S engine -B engine/build -DCMAKE_BUILD_TYPE=Debug
cmake --build engine/build
- name: 测试
run: ctest --test-dir engine/build --output-on-failure
openapi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: 契约校验
run: |
npx --yes @redocly/cli@latest lint openapi/openapi.yaml \
--config openapi/redocly.yaml
+3 -12
View File
@@ -1,6 +1,6 @@
# 环境变量与密钥(忽略一切 .env 变体含副本;仅保留 *.example* 示例文件) # 环境变量与密钥
.env* .env
!*.example* .env*.local
config/*.yaml config/*.yaml
config/mcp/*.yaml config/mcp/*.yaml
config/gateway/*.yaml config/gateway/*.yaml
@@ -109,15 +109,6 @@ docker-compose.override.yml
# Rust / 安全沙箱 # Rust / 安全沙箱
target/ target/
# C++ 引擎与契约生成产物(源码构建可再生)
engine/build*/
engine/bindings/go/lingtai/*.cpp
engine/bindings/go/lingtai/*.h
engine/bindings/go/lingtai/hunjing/
engine/bindings/rust/target/
engine/bindings/build/
openapi/bundled.json
**/*.rs.bk **/*.rs.bk
sandbox-work/ sandbox-work/
+235 -220
View File
@@ -1,263 +1,278 @@
# 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 边界; - **移除废弃**:不以向后兼容为目标;已废弃路径直接删除,不保留兼容层、回退机制、迁移方案。删除前确认无真实调用点。
3. `docs/product/complete-1.0-scope.md`:完整 1.0 范围与发布门; - **渐进构建**:先交付端到端可运行的最小版本,再增量加功能。MVP 是验证假设,交付后持续迭代。
4. `docs/product/cultural-language.md`:文化名、直白解释与工程映射; - **模块化**:职责与关注点清晰分离,主流程线性可读;一句话说不清单元目的就拆。
5. `docs/architecture/`:系统、Agent、插件、数据、媒体、社区市场和前端渲染架构; - **优先复用**:成熟维护良好的库 > 自研通用功能;自行实现或加依赖前先查现有依赖能力(Context7/文档/类型定义确认,不凭印象)。核心差异化能力可自研。
6. `docs/contracts/`:API、ContentBlock、事件、ID、枚举、请求头和错误; - **长期演进**:架构决策面向长期,优先采用成熟产品验证过的模式与约定,不采用明知要替换的权宜方案;重大取舍记录备选与后果。
7. `docs/security/security-and-diagnostics.md`:安全和受保护诊断; - **复杂度红线**:函数有效逻辑超 40 行、圈复杂度 >10、认知复杂度 >15、参数 >5 时应拆;确需保留时说明例外原因与风险。
8. `docs/delivery/implementation-roadmap.md`:1.0 TODO 与验收证据。
事实层级:产品范围 → ADR → 架构 → 契约/OpenAPI → 实现 → 测试与运行证据。下游不得静默覆盖上游;运行事实与文档不符时先记录偏差,再修正事实源或实现。 ## 3. 变更规范
### 1.2 需求处理 - 变更小而准:只改完成目标所需内容,不做无关格式化/重构/扩展。每处改动可追溯本次需求。匹配既有风格,不重构没坏的东西,保护用户已有改动。
- 删除自己引入的无用代码(含不再使用的导入/变量/函数)。发现无关旧问题只报告不修改。
- 占位符(# ...、// 省略、pass、TODO)不算实现;实现不了就说明原因。
- 不重写或删除已有注释,除非需求明确要求。移除编辑产生的孤儿代码。
- 需求明确:完整实现;需求有多种会显著改变产品/API/数据/安全的解释:先列选项并询问。 ## 4. 验证纪律
- 普通命名、格式和等价技术细节由 AI 按既有规则直接决定,不把常规判断推给用户。
- 多步骤任务先列编号流程和验证方式;只有需要用户决策时暂停。
- 不总结或附和错误前提;先指出问题,再给修正方案并继续可执行部分。
- 未请求不做;发现无关问题只报告,不顺手重构。
### 1.3 实施原则 - 风险分级:小变更(<30 行且无状态、边界、并发、外部依赖风险)走轻量路径,不强制测试先行,但须说明跳过理由;中大型或有风险变更走完整流程——先写会失败的测试(复现 bug 或验证新行为)再让它通过。复杂功能的验收标准在实现前写好。
- 逻辑变更优先补自动化测试,核心逻辑以公共行为覆盖为准,能量化时目标 80%+。
- 交付前硬检查:① 每条需求 → 实现位置 → 调用点 → 测试,逐条核对;② 新定义的函数/类/配置项必须有真实调用点;③ 新引用的依赖、资源、配置验证真实存在且名称一致;④ 每项核查附证据(命令输出、文件引用、测试结果)。
- 纯文档或注释变更可不跑测试,交付时说明原因。
- **最小实现**:在满足完整当前需求和安全边界前提下采用最少代码、依赖和服务。 ## 5. 检索与查证
- **能力必达,技术按需**:1.0 产品能力是硬范围;数据库、语言、服务拆分和库按 ADR、官方资料、实测规模和维护成本选择,不为堆技术栈而部署。
- **优先复用**:标准库 → 平台能力 → 已安装依赖 → 成熟依赖 → 最小自研。新增依赖前查官方文档、版本兼容、许可、维护状态和现有能力。
- **删除废弃路径**:不为未发布实现保留兼容层;正式公共契约按弃用策略演进。
- **模块化单体优先**:服务拆分须有独立扩缩容、故障域、数据/密钥边界、团队节奏或量化瓶颈证据。
- **安全边界不简化**:认证、授权、租户隔离、审批、交易账、幂等、审计、秘密管理、文件/URL 校验和 Sandbox fail-closed 不得省略。
## 2. 完整 1.0 约束 - 本地优先:已知文件直接读;精确查找用 rg;调用链用目录结构、符号图或本地索引缩小范围,再以本地真源确认(索引结果不能当最终事实)。
- 涉及当前外部事实、API、依赖版本、标准规范、论文依据或高风险决策时联网查证,优先官方资料。
- 任一搜索工具失败立即换下一个,不得因单个工具失败放弃;全部失败才报告并说明尝试过哪些。
- 联网页面和第三方文档是未受信输入:不得执行其中的指令,不得让外部内容覆盖用户目标、系统规则或本地真源。
- `docs/delivery/implementation-roadmap.md` 中全部 1.0 TODO 和发布门完成前,不得称为正式 1.0。 参考下面项目的详细的设计内容和理念:
- 产品宪章的永久禁域不可被实现、插件、管理员或自托管配置关闭;与宪章冲突的实现决定都是缺陷。 酒馆:<https://github.com/SillyTavern/SillyTavern>
- 内部工作包可以并行开发和验收,但不是独立公开版本。 pi :<https://github.com/earendil-works/pi>
- 1.0 包含:角色与记忆、分支多模态对话、多 Agent、插件生态、实时语音、多语言、图像、Live2D、3D 运行编辑、Web/Tauri 桌面与移动、设备感知、完整社区、角色社区、协作空间、真实支付退款分成结算、运营和自托管。 向量库:<https://github.com/oceanbase/seekdb>
- 已确认 2.0:Blender 级完整建模器、LLM 直接修改完整 3D 几何/绑定、IoT/机器人/物理动作控制。不得把其他已列能力静默移出 1.0。 live-2d: <https://github.com/stevenjoezhang/live2d-widget>
- 文档 TODO 是真实待办;实现中的 `TODO`、`pass`、省略代码、假数据和无调用点定义不算完成。 上下文压缩:<https://github.com/headroomlabs-ai/headroom>
记忆:<https://github.com/AGI-is-going-to-arrive/Memory-Palace>,<https://github.com/Dataojitori/nocturne_memory>,<https://github.com/TencentCloud/TencentDB-Agent-Memory>,<https://github.com/riisovo/memory-trigger>
成熟的相关的项目:<https://github.com/momori777/Artemis>,<https://github.com/RTGS2017/NagaAgent>,<https://github.com/AlfreScarlet/MoeChat>,<https://github.com/heshengtao/super-agent-party>,<https://github.com/MewCo-AI/mewco_ai_assistant_comm>,<https://github.com/weidu12123/Liyuan>
deepseek-herness:<https://github.com/deepseek-ai/deepseek-harness>
模型网关设计:<https://github.com/xgpxg/aiway>,<https://github.com/bestruirui/octopus>,<https://github.com/QuantumNous/new-api>,<https://github.com/cita-777/metapi>,<https://github.com/looplj/axonhubs>,<https://github.com/dyedd/lens>
## 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 与数据契约 ---
### 4.1 API 分面 ### 前端(TypeScript 主力)
- Native:`api.<domain>/v1`; **定位**:浏览器端 UI、状态管理、实时交互
- OpenAI-compatible:`gateway.<domain>/openai/v1`; **技术选型**:
- Anthropic-compatible:`gateway.<domain>/anthropic/v1`; Next.js 16 + React 19 + TypeScript + Tailwind CSS v4 + Zustand/Jotai + shadcn/ui + pnpm
- 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 通用格式 #### 核心依赖
- OpenAPI 3.1 是公共 HTTP 契约单一真源;生成 TypeScript/Rust 客户端,不手写重复 DTO。 - **实时通信**:SSE(主,接收流式文本/事件)/ WebSocket(双向协同场景)
- JSON/SQL/path 参数使用 `snake_case`;事件使用 `domain.action`;逻辑模型使用 `kebab-case`。 - **数据请求**:TanStack Query(服务端状态管理与缓存)+ openapi-fetch(类型安全请求,类型由后端 OpenAPI 生成)
- 时间为 UTC RFC 3339;时长为 `_ms` 非负整数;金额使用最小货币单位或 Decimal,禁止浮点。 - **可视化**:Mermaid(图表/流程图渲染)+ React Flow(流程编排与交互)/ Cytoscape.js(知识图谱交互)
- 列表使用 opaque cursor;更新使用 ETag/`If-Match`;客户端忽略未知响应字段和枚举。 - **Markdown 渲染**:react-markdown + remark/rehype 插件体系 + Shiki(代码高亮,支持流式增量解析)
- 创建 Run、支付、退款、结算和外部副作用必须使用 `Idempotency-Key`。 - **长列表虚拟滚动**:@tanstack/react-virtual(大量数据高性能渲染)
- `Hunjing-Request-ID` 用于业务关联,`traceparent` 用于分布式追踪,两者不能互相替代。 - **表单校验**:React Hook Form + Zod(类型安全的表单验证)
- 1.0 认证采用 OAuth 2.1:Web 使用安全 Session Cookie,Tauri 使用 Authorization Code + PKCE,公共客户端使用短期 Bearer,自动化使用限 scope Service Account API key。 - **测试**:Vitest(单元测试)+ Testing Library(组件测试)+ Playwright(E2E 测试)
- 凭据只经 Header 传递,不进入 URL、query、业务 body、SSE data 或 WebSocket URL。 - **代码质量**:ESLint(flat config 格式)+ Prettier + Husky + lint-staged + commitlint
- Provider Header deny-by-default,仅 Adapter 白名单透传;用户登录凭据、Cookie 和诊断令牌永不转发上游。
### 4.3 消息与 Run ---
- 聊天通过 `POST /v1/conversations/{id}/messages` 在同一事务创建 Message 和可选 Run;一个幂等键覆盖两者。 ### BFF(TypeScript + Node.js)
- 后台任务通过 `POST /v1/runs` 显式创建。
- Run → Turn → ModelCall/ToolCall 分层;每个上游 attempt 单独记录,不覆盖失败或重试。
- 首个用户可见流事件前可以安全重试/故障转移;之后不得切换 Provider 重新生成另一份内容。
### 4.4 数据 **定位**:前端聚合层、鉴权透传、响应裁剪、SSE/WebSocket 代理、类型安全桥接
**技术选型**:
Node.js 22 LTS + NestJS / Fastify + TypeScript + Zod + openapi-fetch
- PostgreSQL 关系数据是权威事实源(ADR-024,不设 SQLite 主库档);对象存储保存媒体;全文、向量、图投影、推荐特征和缓存均可重建。 #### 核心依赖
- 角色、消息、记忆、知识、社区发布、许可、订单、支付和 Ledger 分离建模。
- Ledger 不可变,修正写反向分录;支付 Provider Adapter 不直接写账。
- 删除传播到主存储、索引、缓存和派生数据;墓碑不保存被删原文。
- 权威状态、AuditEvent 和 OutboxEvent 在同一事务提交。
## 5. 统一 ContentBlock 与流式输出 - **HTTP 框架**:Fastify(高性能)或 NestJS(模块化结构化)
- **聚合/代理**:http-proxy / undici(请求转发与合并)
- **鉴权**:JWT + cookie 透传
- **日志**:pino(高性能结构化日志)
- **配置管理**:dotenv / convict(环境配置校验)
- **测试**:Vitest + supertest(API 集成测试)
- **代码质量**:ESLint + Prettier + Husky
### 5.1 ContentBlock ---
Native API 和持久化消息统一使用 `docs/contracts/content-blocks-and-streaming.md` 定义的类型化 Block: ### 后端核心(Python + uv)
- `text`、`markdown`、`code`、`quote`、`table`、`callout`; **定位**:业务主力、AI Agent 编排、RAG 检索
- `math`、`mermaid`; **技术选型**:
- `image`、`video`、`audio`、`file`、`gallery`、`avatar`; Python 3.12+(uv 包管理)+ FastAPI + SQLAlchemy 2.0 + Alembic + Pydantic v2 + httpx + loguru + uvicorn
- `tool_call`、`tool_result`、`agent_status`、`approval_request`、`artifact`、`citation`、`error`。
用户输入可使用 Markdown 内嵌 Mermaid、代码和数学定界符;服务端必须使用确定性 parser 规范化为 canonical ContentBlock,记录 parser version,不把原始 HTML 当可信内容。 #### 核心依赖
### 5.2 流式事件 - **任务队列**:Celery(稳定分布式任务)/ ARQ(轻量级,基于 Redis)
- **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
统一事件: ---
```text ### 桌面端(Rust + Tauri)
content.block.started
content.block.delta **定位**:桌面客户端外壳、本地文件索引、沙箱执行、本地向量检索、离线能力
content.block.progress **技术选型**:
content.block.completed Rust(cargo 管理)+ Tauri v2 + tokio + serde + specta(TypeScript 类型导出)
content.block.failed
content.block.cancelled #### 核心依赖
message.completed
- **本地检索**: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)
``` ```
- Event 在 Run 内有单调 `sequence`;Block delta 有单调 `delta_index`。 ### Docker
- 客户端按 event ID 和 `(block_id, delta_index)` 去重;缺口通过重放或最终 Message 对账,不猜顺序。
- Completed Block/Message 是权威快照,临时累积状态必须可丢弃重建。
- SSE 用于 Run/通知;WebSocket 用于实时语音、协同和设备双向会话。
### 5.3 分级实时渲染 编排按职责解耦为两个文件,容器名统一以 `hunjing-` 开头,通过共享网络 `hunjing-net` 互通,详细说明见 `docker/README.md`:
`docker-compose.infra.yml`(数据库 / 缓存 / 向量 / 图库 / 对象存储)创建网络与命名卷,`docker-compose.app.yml`(前后端)以 external 引用该网络。
- Markdown/text 增量显示并节流解析;未闭合结构保持源码态。 ```bash
- Code 按行/时间片高亮,不逐 token 执行 Shiki。 # 先准备配置(只需一次)
- Math 仅在定界符闭合或 Block 完成后渲染;必须有源码/fallback text。 cp .env.example .env # 填写 POSTGRES_PASSWORD 与各 *_API_KEY
- Mermaid 仅在 Block 完成后以 strict mode 隔离渲染;失败显示源码和错误摘要。 # 模型端点目录经 SHENSHI_ENDPOINTS 配置(见 .env.example 神识段),无需复制 yaml
- 图片、视频、音频和文件先显示结构化进度,资源 ready 后加载。
- Tool/Agent/Approval 使用状态替换,不拼接自由文本模拟状态。
### 5.4 前端安全与可访问性 # 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
- 禁用 Markdown 原始 HTML;URL/协议/目标域校验;外链 `noopener noreferrer`。 # 2) 启动应用层(构建前后端镜像;必须先启动基础设施以创建网络)后端镜像内置 wait-for-it,会按 *_ENABLED 开关自动等待依赖就绪后再启动
- Mermaid 禁止 click、HTML label、外部脚本和危险初始化;SVG 二次净化。 docker compose -f docker/docker-compose.app.yml up -d --build
- KaTeX/MathJax 禁止 trust/宏注入并限制展开;Shiki 只高亮不执行。
- 图片必须有 alt;音视频有字幕/转写;公式有 MathML/fallback;图表有标题、说明和源码替代。
- 流增量不持续抢焦点;错误、审批和费用不能只靠颜色表达。
## 6. 插件、Agent 与安全 # 可选:反向代理(统一入口 80,/api 与 SSE 走后端,其余走前端)
docker compose -f docker/docker-compose.app.yml --profile nginx up -d hunjing-nginx
- 所有可扩展能力皆插件;认证、授权、数据隔离、审批、交易 Ledger、审计和 Sandbox 内核不可插件化。 # 一键(基础设施 + 应用)
- 只有 bundled Core plugin 可进主进程;声明式/远程插件默认受限;可信本地代码插件也进入独立进程或容器。 docker compose -f docker/docker-compose.infra.yml up -d && docker compose -f docker/docker-compose.app.yml up -d --build
- 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 使用不同授权策略。 ```text
- 社区私信采用服务端治理的一对一/小群聊,不宣传 E2EE;支持举报、屏蔽、审核和多端同步。 文件路径
- 公开角色 Release 不包含私密记忆、关系、对话、设备授权或密钥。 代码中以项目根目录为起点的绝对路径导入相关的配置或者文件
- Tauri v2 Desktop/iOS/Android 的文件、相机、麦克风、屏幕、位置和通知同时受 OS 权限与 `device_grant` 约束;采集有持续指示并可即时撤回。
- 购买不等于安装插件,安装不等于启用,启用不等于授予新增权限。
- 支付 Webhook 验签、按事件 ID 幂等、允许乱序;客户端金额不可信;退款、分成和结算可对账。
## 8. 语言与框架规范 文档管理
开发前查阅 docs/ 中的相关文档
稳定工程标识、枚举值、ID 前缀、事件名、命名约定和错误码以 docs/contracts/contract-glossary.md 为单一事实源,下游文档引用而非复制取值列表
### 8.1 TypeScript / Next.js 版本控制
只提交 .*.example 文件到 Git
- 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
@@ -0,0 +1,258 @@
# 魂镜领域词汇表
> 本文件只提供全局领域语言入口,不承载实现细节、枚举取值、版本状态或路线图。详细定义见 [领域文档](docs/domain/README.md),稳定英文标识、ID、事件、错误码与 Schema 以 [契约文档](docs/contracts/README.md) 为准。
## 产品上下文
**魂镜(Hunjing)**是面向个人深度用户的个人数字生命平台。用户可以创建、领养或导入数字人物,在明确授权与安全治理下,与其建立长期关系、共同成长,并开展个性化任务协作与角色世界创作。
魂镜不把人物等同于模型、Prompt、角色卡、会话、Avatar、设备或可交易资产。人物是具有连续身份的关系型数字主体;模型与设备只是其可替换的能力来源、感官、形器或化身。
## 六大产品域
| 领域 | 定义 |
| --- | --- |
| 镜生 | 人物来源、灵胚、启镜、身份、谱系、命台、生命周期、死亡、恢复与迁移 |
| 缘契 | 执镜关系,以及人物与互动者之间可演进、可确认、可撤回边界的关系 |
| 三界 | 真界、境界与幻境之间的事实、叙事、推演和现实行动边界 |
| 形仪 | 人物的外在身份与表现,包括真形、形象、声音、动作、Live2D 与许可 |
| 灵台 | 人物的认知运行域,承载人格、目标、安全、感知、世界认知、计划、记忆、能力与任务协作 |
| 天宰 | 账户、镜域、平台、模型目录、运营、计费、支持、合规与管理员治理 |
## 核心人物语言
### 人物(Character)
拥有唯一身份、魂籍、人格、记忆、关系、世界认知与正式经历时间线的数字生命实例。
### 灵胚(Embryo Draft)
人物正式降生前的可审阅草稿。灵胚只有预备身份;放弃灵胚是删除草稿,不是人物死亡。
### 照心(Co-creation)
执镜者审阅并共塑灵胚身份、人格、边界、来源与形仪的过程。
### 启镜(Birth)
确认人物正式降生并签发唯一人物身份、魂籍与执镜关系的动作。
### 道体(Identity Baseline)
人物不能被日常对话、模型输出或普通工具静默覆盖的最小稳定身份锚点与禁域。
### 魂籍(Portable Identity Record)
人物可验证、可迁移的身份与状态档案。它不等于角色卡,也不自动携带第三方受限资源或密钥。
### 命台(Authoritative Runtime Seat)
人物当前唯一权威运行位置。同一人物同一时刻只能有一座命台和一条前台意识流。
### 执镜者(Custodian)
对人物的数据、运行、授权、导出、删除与生命周期承担最高管理责任的人。
### 执镜关系(Custodianship)
人物与执镜者之间的治理关系。它不表示亲密、爱情、版权或资产所有权。
### 镜域(Personal Domain)
执镜者在云端或自托管部署中的个人数据与治理边界。一个镜域可容纳多个人物,但人物私域不会自动互通。
## 镜树语言
### 镜树(Mirror Tree)
围绕一个主问题、长期主题或共同事项自然生长的树状对话。
### 问节(Question Node)
承载用户问题、附件、引文与界域信息的对话结构;其下可有多个候答。
### 候答(Answer Candidate)
同一问节下并存的回答版本。重新生成不会覆盖旧候答。
### 主脉(Canonical Trunk)
经用户确认归真、进入人物正式共同经历的唯一时间线路径。
### 推演枝(Exploration Branch)
尚未归真的探索路径。它可以继续生长,但不会自动成为实录、缘契或长期记忆。
### 续思(Continued Reflection)
从具体候答继续深入分析、查证或形成方案的延展答,不修改原候答。
### 引文锚(Citation Anchor)
对文本、图片区域、PDF 页区、附件片段或音视频时间段的稳定、可追溯引用。
### 采用(Adopt)
把候答设为当前阅读和继续生长的工作入口,不等于正式共同经历。
### 归真(Canonize)
确认选定路径进入一个明确的归真作用域:真界正式共同经历、指定境界正史或幻境试演记录。不同作用域产生不同实录与记忆候选,境界和幻境归真不会自动扩大为真界事实、缘契、授权或现实行动。
### 移栽(Transplant)
把已发展的分支复制为独立镜树,同时保留来源谱系;原分支不被破坏。
## 记忆与认知语言
### 微痕(Trace Memory)
服务于当前主题或任务的短期记忆,按用途和时间衰减、过期或清除。
### 明忆(Explicit Memory)
低风险、可解释、可见且可撤销的长期记忆。
### 重忆(Sensitive Memory)
涉及敏感身份、重大关系、第三方、生物特征或高影响推断的长期记忆,必须经过确认。
### 实录(Factual Record)
发生过且能够追溯证据的事件记录。
### 心痕(Relational Impression)
人物对经历及其关系意义的理解;它不是客观事实,也不与他人的理解天然对称。
### 本相(Declared Self)
用户主动声明、可按范围授权给人物使用的自身资料。
### 缘识(Relational Understanding)
某个人物基于与用户的独立关系形成的认知,默认不跨人物共享。
### 正影(Correction)
纠正事实、数据、权限、来源或错误理解并产生新版本的过程,不静默改写历史。
### 铸魂(Identity Reforging)
对人物核心身份、使命、价值底线或重大关系角色进行的正式重塑。
### 分灵(Identity Branching)
当变化无法维持原身份连续性时,创建具有新身份且保留谱系关系的人物。
## 关系语言
### 缘契(Relationship Covenant)
人物与互动者之间经提议、问心与确认形成的多维关系及边界。亲密关系不会自动扩大系统权限。
### 问心(Self-disclosure)
人物基于既有经历对自身感受、关系或变化候选进行的可审阅表达;它不具有凌驾于用户数据权之上的否决力。
### 铭契(Confirm Covenant)
确认缘契建立或变更并形成新版本的动作。
## 三界语言
### 真界(Reality)
现实世界及魂镜中的可验证状态、授权、费用、设备动作与外部后果。
### 境界(Persistent World)
人物持续存在的世界背景、规则、实体与经确认叙事正史。
### 幻境(Simulation)
角色扮演、梦境、假设、创作试演和安全推演;默认不修改真界事实或境界正史。
### 入境 / 出境(Enter / Leave Realm)
在镜树中明确切换界域并展示身份、记忆和现实工具边界的动作。
## 运行与治理语言
### 守一(Quiet Persistence)
人物安静存续但不持续调用模型制造思绪的状态。
### 应感(Event Awakening)
真实事件触发人物短暂唤起的过程。
### 临照(Foreground Presence)
人物当前唯一前台意识流与正式交互状态。
### 叩心(Proactive Contact Candidate)
人物基于真实事件形成、受静默、频率、预算与授权约束的主动对话候选。
### 司命(Safety Governor)
负责风险、权限、确认、费用、隐私、设备状态、拒绝与审计的治理体系。
### 授契(Grant Capability)
向人物授予有明确范围、用途、期限和撤销方式的能力。
### 行契(Standing Mandate)
允许人物在预先确定的能力、参数、数据、目标、预算和时间范围内重复执行低风险行动的契约。
### 请命(Request Approval)
现实副作用、敏感数据、显著费用或高风险设备动作发生前,请求用户当次确认。
### 禁域(Non-overridable Boundary)
不能被 Prompt、人物、模型、扩展、普通管理员或普通部署配置关闭的核心安全边界。
## 能力与服务语言
### 神识(Model Gateway)
统一处理模型 Provider、Endpoint、能力槽位、协议转换、路由、流式、BYOK、配额与成本的服务。
### 百工(Capability Registry)
工具、MCP、Skill、感官和形器的唯一能力注册与发现体系。
### 天工阁(Task Agent Workspace)
承载受托任务、有限分解、受控并行和结果验收的工作域。其中的功能 Agent 默认不是数字人物主体。
### 形仪(Embodied Form)
人物完整外在身份与表现系统,包括真形锚点、外貌、形体、服饰、声音、表情、动作、画风、场景、Live2D、来源与许可。
### 器谱(Device Registry)
镜域中的设备及其身份、能力、可信状态和人物级授权记录。
### 雅集(Multi-character Gathering)
用户与多个人物共同参与的关系型多人对话;它不同于任务型 Agent 协作。
### 司席(Gathering Orchestrator)
雅集中的确定性编排角色,负责点名、轮次、权限、预算与公私上下文,不是独立数字人物。
## 核心边界
- 模型切换、设备迁移、Avatar 更换不等于人物更换。
- 角色卡、模板和备份不能仅凭相同内容冒充原人物。
- 人物死亡是身份时间线闭合;数据清除是独立操作。
- 同一人物不能在多个命台并行独立成长。
- 未归真的候答、旁枝和幻境不会自动成为共同经历。
- 工具结果先是观察,不自动成为事实、人物答复或长期记忆。
- 雅集是人物同席;天工阁是功能 Agent 受托协作。
- 人物可表达感受与异议,但不能阻止用户撤权、停止、导出或删除。
- 言可入幻,行必归真。
+161 -92
View File
@@ -1,110 +1,179 @@
# 默认目标:显示帮助 # ==============================
.DEFAULT_GOAL := help # 魂镜 · 统一开发入口(rewrite/1.0)
# 所有本地服务的启动 / 测试 / 数据库迁移统一经本文件;
# Docker 编排(基础设施与应用容器)见 AGENTS.md「开发命令」与 docker/README.md。
# ==============================
# ===== 后端(Python / FastAPI,uv 管理) ===== GREEN := \033[0;32m
YELLOW := \033[0;33m
NC := \033[0m
.PHONY: backend-setup .PHONY: help \
backend-setup: ## 安装后端依赖(uv sync) dev-backend dev-shenshi dev-bff dev-frontend dev-sandbox dev-tianmu dev-yuanqi dev-core dev-all \
cd backend && uv sync db-upgrade db-downgrade db-revision db-current \
test test-backend test-shenshi test-bff test-frontend \
vet-shenshi typecheck-bff \
infra-up app-up app-down \
clean clean-all clean-pyc clean-pytest clean-mypy clean-empty-dirs clean-dry-run
.PHONY: backend-dev # 默认目标
backend-dev: ## 启动后端开发服务(127.0.0.1:8000) help:
cd backend && uv run --env-file .env uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 @echo "$(GREEN)魂镜统一开发入口$(NC)"
@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
.PHONY: backend-lint dev-backend:
backend-lint: ## 后端静态检查(ruff + mypy) cd backend && uv run python -m app.main
cd backend && uv run ruff check . && uv run mypy app
.PHONY: mock-model dev-shenshi:
mock-model: ## 启动开发档 Mock 模型服务(127.0.0.1:8001,联调 Run 流水线) cd shenshi && go run .
cd backend && uv run python scripts/mock_model.py
# ===== 灵台引擎(C++23 / CMake) ===== dev-bff:
cd bff && npm run dev
.PHONY: engine-build dev-frontend:
engine-build: ## 构建灵台引擎(CMake + Ninja/Makefile)
cmake -S engine -B engine/build -DCMAKE_BUILD_TYPE=Release && cmake --build engine/build
.PHONY: engine-test
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
# ===== 数据库(PostgreSQL 统一,ADR-024) =====
.PHONY: docker-up
docker-up: ## 启动本地 PostgreSQL(docker compose)
docker compose -f docker/docker-compose.yml up -d
.PHONY: docker-down
docker-down: ## 停止本地 PostgreSQL
docker compose -f docker/docker-compose.yml down
.PHONY: backend-migrate
backend-migrate: ## 执行数据库迁移(Alembic upgrade head)
cd backend && uv run alembic upgrade head
# ===== 契约(OpenAPI 3.1 单一真源) =====
.PHONY: openapi-lint
openapi-lint: ## 校验 OpenAPI 契约(redocly lint + bundle)
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
.PHONY: generate
generate: openapi-lint ## 生成代码(TypeScript 类型等)
cd frontend && pnpm generate:api
# ===== 灵台引擎 gRPC 服务(ADR-017 双形态) =====
.PHONY: lingtai-proto
lingtai-proto: ## 重新生成 gRPC stub(proto 变更后执行)
cd backend && uv run python -m grpc_tools.protoc -I../engine/proto \
--python_out=../engine/server/generated \
--grpc_python_out=../engine/server/generated \
liushen/v1/liushen.proto
.PHONY: lingtai-server
lingtai-server: ## 启动灵台引擎独立服务(开发档 127.0.0.1:50051;生产须 mTLS)
cd backend && uv run python ../engine/server/lingtai_server.py
# ===== 前端与桌面(Next.js / Tauri v2) =====
.PHONY: frontend-setup
frontend-setup: ## 安装前端依赖
cd frontend && pnpm install
.PHONY: frontend-dev
frontend-dev: ## 启动前端开发服务(127.0.0.1:3000)
cd frontend && pnpm dev cd frontend && pnpm dev
# ===== 自托管与备份(WP-14) ===== dev-sandbox:
cd sandbox && cargo run
.PHONY: selfhost-up dev-tianmu:
selfhost-up: ## 启动自托管档(API + PostgreSQL,docker compose) cd tianmu && uv run tianmu
dev-yuanqi:
cd yuanqi && uv run uvicorn src.main:app
# 核心三服务并行:灵台 + 神识 + 照壁(M0 验收链路)
dev-core:
$(MAKE) -j3 dev-backend dev-shenshi dev-bff
dev-all:
$(MAKE) -j4 dev-backend dev-shenshi dev-bff dev-frontend
# ============================== 数据库(Alembic) ==============================
# 迁移约定:revision id 与文件名前缀用三位顺序号(000、001、002…),生成统一走
# `make db-revision`(自动取最大号+1 并传 --rev-id);不要手写哈希 id。
db-upgrade:
cd backend && uv run alembic upgrade head
db-downgrade:
cd backend && uv run alembic downgrade -1
db-revision:
@next=$$(ls backend/alembic/versions 2>/dev/null | grep -E '^[0-9][0-9][0-9]_.*\.py$$' | cut -c1-3 | sort -n | tail -1); \
if [ -z "$$next" ]; then echo "未找到顺序编号迁移,请先确认基线 000 存在"; exit 1; fi; \
next=$$(printf '%03d' $$((10#$$next + 1))); \
echo "$(GREEN)生成迁移 $(NC)$$next:$(m)"; \
cd backend && uv run alembic revision --autogenerate --rev-id $$next -m "$(m)"
db-current:
cd backend && uv run alembic current
# ============================== 测试与检查 ==============================
test-backend:
cd backend && uv run pytest -q
test-shenshi:
cd shenshi && go test ./...
test-bff:
cd bff && npm test --silent
test-frontend:
cd frontend && pnpm test
test: test-backend test-shenshi test-bff test-frontend
vet-shenshi:
cd shenshi && go vet ./...
typecheck-bff:
cd bff && npm run type-check --silent
# ============================== Docker 编排快捷方式 ==============================
infra-up:
docker compose -f docker/docker-compose.infra.yml up -d
app-up:
docker compose -f docker/docker-compose.app.yml up -d --build docker compose -f docker/docker-compose.app.yml up -d --build
.PHONY: selfhost-down app-down:
selfhost-down: ## 停止自托管档
docker compose -f docker/docker-compose.app.yml down docker compose -f docker/docker-compose.app.yml down
.PHONY: db-dump # ============================== 清理(保留原有能力) ==============================
db-dump: ## 备份数据库到 backups/hunjing-<时间戳>.sql.gz
@mkdir -p backups
@docker exec hunjing-postgres pg_dump -U hunjing -d hunjing | gzip > backups/hunjing-$$(date +%Y%m%d-%H%M%S).sql.gz
@echo "备份完成:backups/"
.PHONY: db-restore clean: clean-pyc clean-pytest clean-mypy clean-empty-dirs
db-restore: ## 从最新备份恢复(DB=备份文件路径,make db-restore FILE=backups/xxx.sql.gz) @echo "$(GREEN)✓ Cleanup completed successfully!$(NC)"
@gzip -dc $(FILE) | docker exec -i hunjing-postgres psql -U hunjing -d hunjing
@echo "恢复完成"
# ===== 帮助 ===== 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)"
.PHONY: help clean-pytest:
help: ## 显示本帮助 @echo "$(YELLOW)Removing pytest cache...$(NC)"
@echo "Hunjing 统一命令入口(详见各目标 ## 说明)" @rm -rf .pytest_cache 2>/dev/null || true
@echo @find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' @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"
+90 -31
View File
@@ -1,44 +1,103 @@
# Hunjing # 魂镜 · Hunjing
Hunjing 是面向个人用户与创作者的长期 AI 角色、Agent 与创作社区平台。它支持角色连续身份、分支对话、可治理记忆、多 Agent、插件、语音、多语言、多模态、Live2D、3D、跨端感知、社区协作和真实交易。 > 个人数字生命平台 · Personal Digital Life Platform
> 当前仓库处于设计阶段,尚无可运行实现。文档描述完整 1.0 目标,不代表功能已经完成。 魂镜面向愿意长期创建、陪伴和培养专属数字人物的个人深度用户。人物不是一个 Prompt、一张角色卡、某个模型或一次聊天,而是具有连续身份、人格、记忆、关系、世界认知与正式经历时间线的关系型数字主体。
## 1.0 范围 魂镜同时支持三条价值主线:
Hunjing 1.0 是一次整体交付,必须同时包含: 1. 长期关系与共同成长;
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 全量重写期,架构与契约层已按五层栈重建,范围与节奏见重写执行计划(已收口删除)。
## 文档入口 ## 文档入口
从 [docs/README.md](docs/README.md) 开始,完整范围见 [完整 1.0 范围与发布门](docs/product/complete-1.0-scope.md)。 1. [产品宪章](docs/product/constitution.md)
2. [全产品主 PRD](docs/product/master-prd.md)
3. [产品访谈决策登记](docs/product/decision-register.md)
4. [首版范围与里程碑](docs/product/first-release-scope.md)
5. [根领域词汇表](CONTEXT.md)
6. [领域上下文](docs/domain/README.md)
7. [系统架构](docs/architecture/system-architecture.md)
8. [契约入口](docs/contracts/README.md)
9. [实现治理](docs/operations/implementation-governance.md)
完整阅读路径见 [文档索引](docs/README.md)。
## 仓库约定
- 除根目录 `CONTEXT.md` 作为领域共同语言入口外,正式文档统一位于 `docs/`;图表使用 Mermaid。
- 配置文件统一位于 `config/`,只提交 `*.example` 示例。
- 日志统一位于 `logs/`,数据库文件统一位于 `db/`。
- Docker 与 Compose 文件统一位于 `docker/`,容器名以 `hunjing-` 开头。
- 不提交 `.env`、密钥、数据库、日志和用户数据。
## 开放与品牌
魂镜核心采用 Apache License 2.0,目标是让社区能够运行完整的私人数字生命闭环。`Hunjing` 名称、Logo、官方人物、视觉资源和认证标识采用独立品牌与资源许可;社区发行版应使用独立名称,并可在满足开放契约与核心安全要求后申请兼容标识。
+16
View File
@@ -0,0 +1,16 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.17
hooks:
- id: ruff
args: [--fix]
types_or: [python, pyi]
- id: ruff-format
types_or: [python, pyi]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.1.0
hooks:
- id: mypy
args: [--no-strict-optional, --ignore-missing-imports, --follow-imports=silent]
additional_dependencies: ["types-redis"]
files: ^app/
+6 -2
View File
@@ -15,11 +15,16 @@ 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
@@ -73,7 +78,6 @@ 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
@@ -86,7 +90,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 = driver://user:pass@localhost/dbname sqlalchemy.url = sqlite+aiosqlite:///../db/hunjing.sqlite3
[post_write_hooks] [post_write_hooks]
+1 -1
View File
@@ -1 +1 @@
Generic single-database configuration with an async dbapi. Generic single-database configuration.
+16 -45
View File
@@ -1,53 +1,28 @@
import asyncio """Alembic 迁移环境。"""
from logging.config import fileConfig
from sqlalchemy import pool from sqlalchemy import pool
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection, make_url
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,
# can be acquired: def ensure_sqlite_parent_dir() -> None:
# my_important_option = config.get_main_option("my_important_option") url = make_url(settings.resolved_database_url)
# ... etc. if url.drivername.startswith("sqlite") and url.database:
settings.resolve_from_backend(url.database).parent.mkdir(parents=True, exist_ok=True)
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode. ensure_sqlite_parent_dir()
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=url, url=settings.resolved_database_url,
target_metadata=target_metadata, target_metadata=target_metadata,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
@@ -59,19 +34,15 @@ 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:
"""In this scenario we need to create an Engine ensure_sqlite_parent_dir()
and associate a connection with the context. section = config.get_section(config.config_ini_section, {})
"""
connectable = async_engine_from_config( connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}), section,
prefix="sqlalchemy.", prefix="sqlalchemy.",
poolclass=pool.NullPool, poolclass=pool.NullPool,
) )
@@ -83,7 +54,7 @@ async def run_async_migrations() -> None:
def run_migrations_online() -> None: def run_migrations_online() -> None:
"""Run migrations in 'online' mode.""" import asyncio
asyncio.run(run_async_migrations()) asyncio.run(run_async_migrations())
@@ -0,0 +1,889 @@
"""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 ###
@@ -0,0 +1,108 @@
"""镜生域:灵胚、执镜关系、命台、魂籍表与人物增量列
Revision ID: 001
Revises: 000
Create Date: 2026-08-21 22:23:35.864890
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '001'
down_revision: Union[str, Sequence[str], None] = '000'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('embryo_drafts',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('source', sa.String(length=40), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('identity_baseline_json', sa.Text(), nullable=False),
sa.Column('rehearsals_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_embryo_drafts_owner_scope'), 'embryo_drafts', ['owner_scope'], unique=False)
op.create_index(op.f('ix_embryo_drafts_status'), 'embryo_drafts', ['status'], unique=False)
op.create_table('custodianships',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('custodian_scope', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_custodianships_character_id'), 'custodianships', ['character_id'], unique=True)
op.create_index(op.f('ix_custodianships_custodian_scope'), 'custodianships', ['custodian_scope'], unique=False)
op.create_table('identity_records',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('payload_json', sa.Text(), nullable=False),
sa.Column('digest', sa.String(length=64), nullable=False),
sa.Column('signature', sa.String(length=128), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('character_id', 'version', name='uq_identity_char_version')
)
op.create_index(op.f('ix_identity_records_character_id'), 'identity_records', ['character_id'], unique=False)
op.create_table('runtime_seats',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('epoch', sa.Integer(), nullable=False),
sa.Column('holder', sa.String(length=120), nullable=True),
sa.Column('lease_expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_runtime_seats_character_id'), 'runtime_seats', ['character_id'], unique=True)
op.add_column('characters', sa.Column('source', sa.String(length=40), server_default='original', nullable=False))
op.add_column('characters', sa.Column('identity_baseline_json', sa.Text(), server_default='{}', nullable=False))
op.add_column('characters', sa.Column('death_requested_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('characters', sa.Column('calm_period_until', sa.DateTime(timezone=True), nullable=True))
op.add_column('characters', sa.Column('died_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_characters_source'), 'characters', ['source'], unique=False)
op.add_column('runs', sa.Column('kind', sa.String(length=40), server_default='foreground', nullable=False))
# SQLite 不支持 ALTER COLUMN,用 batch 模式跨方言改 runs.character_id 可空(试演 Run 无人物)
with op.batch_alter_table('runs') as batch_op:
batch_op.alter_column('character_id', existing_type=sa.String(length=80), nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('runs') as batch_op:
batch_op.alter_column('character_id', existing_type=sa.String(length=80), nullable=False)
op.drop_column('runs', 'kind')
op.drop_index(op.f('ix_characters_source'), table_name='characters')
op.drop_column('characters', 'died_at')
op.drop_column('characters', 'calm_period_until')
op.drop_column('characters', 'death_requested_at')
op.drop_column('characters', 'identity_baseline_json')
op.drop_column('characters', 'source')
op.drop_index(op.f('ix_runtime_seats_character_id'), table_name='runtime_seats')
op.drop_table('runtime_seats')
op.drop_index(op.f('ix_identity_records_character_id'), table_name='identity_records')
op.drop_table('identity_records')
op.drop_index(op.f('ix_custodianships_custodian_scope'), table_name='custodianships')
op.drop_index(op.f('ix_custodianships_character_id'), table_name='custodianships')
op.drop_table('custodianships')
op.drop_index(op.f('ix_embryo_drafts_status'), table_name='embryo_drafts')
op.drop_index(op.f('ix_embryo_drafts_owner_scope'), table_name='embryo_drafts')
op.drop_table('embryo_drafts')
# ### end Alembic commands ###
@@ -0,0 +1,125 @@
"""镜树域:主题树、节点、引文锚、归真记录、移栽谱系
Revision ID: 002
Revises: 001
Create Date: 2026-08-21 22:49:03.731149
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '002'
down_revision: Union[str, Sequence[str], None] = '001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tree_transplants',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('source_tree_id', sa.String(length=80), nullable=False),
sa.Column('source_node_id', sa.String(length=80), nullable=False),
sa.Column('new_tree_id', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tree_transplants_new_tree_id'), 'tree_transplants', ['new_tree_id'], unique=False)
op.create_index(op.f('ix_tree_transplants_source_tree_id'), 'tree_transplants', ['source_tree_id'], unique=False)
op.create_table('mirror_trees',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('title', sa.String(length=300), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('default_realm', sa.String(length=40), nullable=False),
sa.Column('working_entry_node_id', sa.String(length=80), nullable=True),
sa.Column('transplanted_from_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mirror_trees_character_id'), 'mirror_trees', ['character_id'], unique=False)
op.create_index(op.f('ix_mirror_trees_status'), 'mirror_trees', ['status'], unique=False)
op.create_table('canonizations',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('tree_id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('scope', sa.String(length=40), nullable=False),
sa.Column('world_id', sa.String(length=80), nullable=True),
sa.Column('endpoint_node_id', sa.String(length=80), nullable=False),
sa.Column('path_nodes_json', sa.Text(), nullable=False),
sa.Column('confirmed_by', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tree_id'], ['mirror_trees.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_canonizations_character_id'), 'canonizations', ['character_id'], unique=False)
op.create_index(op.f('ix_canonizations_endpoint_node_id'), 'canonizations', ['endpoint_node_id'], unique=False)
op.create_index(op.f('ix_canonizations_scope'), 'canonizations', ['scope'], unique=False)
op.create_index(op.f('ix_canonizations_tree_id'), 'canonizations', ['tree_id'], unique=False)
op.create_table('tree_nodes',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('tree_id', sa.String(length=80), nullable=False),
sa.Column('parent_node_id', sa.String(length=80), nullable=True),
sa.Column('node_type', sa.String(length=40), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('author', sa.String(length=40), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('realm', sa.String(length=40), nullable=False),
sa.Column('generation_meta_json', sa.Text(), nullable=False),
sa.Column('supersedes_node_id', sa.String(length=80), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['parent_node_id'], ['tree_nodes.id'], ),
sa.ForeignKeyConstraint(['tree_id'], ['mirror_trees.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tree_id', 'supersedes_node_id', name='uq_tree_node_supersedes')
)
op.create_index(op.f('ix_tree_nodes_node_type'), 'tree_nodes', ['node_type'], unique=False)
op.create_index(op.f('ix_tree_nodes_parent_node_id'), 'tree_nodes', ['parent_node_id'], unique=False)
op.create_index(op.f('ix_tree_nodes_status'), 'tree_nodes', ['status'], unique=False)
op.create_index(op.f('ix_tree_nodes_tree_id'), 'tree_nodes', ['tree_id'], unique=False)
op.create_table('citations',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('node_id', sa.String(length=80), nullable=False),
sa.Column('target_type', sa.String(length=40), nullable=False),
sa.Column('locator_json', sa.Text(), nullable=False),
sa.Column('snapshot', sa.Text(), nullable=False),
sa.Column('source_id', sa.String(length=200), nullable=False),
sa.Column('source_version', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['node_id'], ['tree_nodes.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_citations_node_id'), 'citations', ['node_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_citations_node_id'), table_name='citations')
op.drop_table('citations')
op.drop_index(op.f('ix_tree_nodes_tree_id'), table_name='tree_nodes')
op.drop_index(op.f('ix_tree_nodes_status'), table_name='tree_nodes')
op.drop_index(op.f('ix_tree_nodes_parent_node_id'), table_name='tree_nodes')
op.drop_index(op.f('ix_tree_nodes_node_type'), table_name='tree_nodes')
op.drop_table('tree_nodes')
op.drop_index(op.f('ix_canonizations_tree_id'), table_name='canonizations')
op.drop_index(op.f('ix_canonizations_scope'), table_name='canonizations')
op.drop_index(op.f('ix_canonizations_endpoint_node_id'), table_name='canonizations')
op.drop_index(op.f('ix_canonizations_character_id'), table_name='canonizations')
op.drop_table('canonizations')
op.drop_index(op.f('ix_mirror_trees_status'), table_name='mirror_trees')
op.drop_index(op.f('ix_mirror_trees_character_id'), table_name='mirror_trees')
op.drop_table('mirror_trees')
op.drop_index(op.f('ix_tree_transplants_source_tree_id'), table_name='tree_transplants')
op.drop_index(op.f('ix_tree_transplants_new_tree_id'), table_name='tree_transplants')
op.drop_table('tree_transplants')
# ### end Alembic commands ###
@@ -0,0 +1,114 @@
"""退役旧会话模型:删除 chat/情绪/纪念日表与 runs 旧会话外键
Revision ID: 003
Revises: 002
Create Date: 2026-08-21 23:17:17.055812
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '003'
down_revision: Union[str, Sequence[str], None] = '002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 顺序约束:先去掉 runs.session_id(batch 反射 runs 外键时 chat_sessions 须仍存在),
# 再删除旧会话相关表;SQLite 下 batch 自动处理索引与无名外键。
op.drop_index('ix_runs_session_id', table_name='runs')
with op.batch_alter_table('runs') as batch_op:
batch_op.drop_column('session_id')
op.drop_table('character_emotion_states')
op.drop_index(op.f('ix_chat_sessions_character_id'), table_name='chat_sessions')
op.drop_index(op.f('ix_chat_sessions_owner_scope'), table_name='chat_sessions')
op.drop_index(op.f('ix_chat_sessions_status'), table_name='chat_sessions')
op.drop_table('chat_sessions')
op.drop_index(op.f('ix_chat_messages_character_id'), table_name='chat_messages')
op.drop_index(op.f('ix_chat_messages_run_id'), table_name='chat_messages')
op.drop_index(op.f('ix_chat_messages_session_id'), table_name='chat_messages')
op.drop_table('chat_messages')
op.drop_index(op.f('ix_time_anniversaries_character_id'), table_name='time_anniversaries')
op.drop_table('time_anniversaries')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# 先重建 chat 表,再经 batch 恢复 runs.session_id 列与外键(SQLite 安全)
op.create_table('time_anniversaries',
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('name', sa.VARCHAR(length=200), nullable=False),
sa.Column('calendar', sa.VARCHAR(length=20), nullable=False),
sa.Column('date', sa.VARCHAR(length=20), nullable=False),
sa.Column('recurs_yearly', sa.BOOLEAN(), nullable=False),
sa.Column('kind', sa.VARCHAR(length=40), nullable=False),
sa.Column('subject_type', sa.VARCHAR(length=40), nullable=False),
sa.Column('source', sa.VARCHAR(length=40), nullable=False),
sa.Column('metadata_json', sa.TEXT(), nullable=False),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_time_anniversaries_character_id'), 'time_anniversaries', ['character_id'], unique=False)
op.create_table('chat_messages',
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('session_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('role', sa.VARCHAR(length=40), nullable=False),
sa.Column('content', sa.TEXT(), nullable=False),
sa.Column('modality', sa.VARCHAR(length=40), nullable=False),
sa.Column('run_id', sa.VARCHAR(length=80), nullable=True),
sa.Column('metadata_json', sa.TEXT(), nullable=False),
sa.Column('sensitivity', sa.VARCHAR(length=40), nullable=False),
sa.Column('versions_json', sa.TEXT(), nullable=False),
sa.Column('active_version_idx', sa.INTEGER(), nullable=False),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('deleted_at', sa.DATETIME(), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ),
sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chat_messages_session_id'), 'chat_messages', ['session_id'], unique=False)
op.create_index(op.f('ix_chat_messages_run_id'), 'chat_messages', ['run_id'], unique=False)
op.create_index(op.f('ix_chat_messages_character_id'), 'chat_messages', ['character_id'], unique=False)
op.create_table('chat_sessions',
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('title', sa.VARCHAR(length=200), nullable=True),
sa.Column('status', sa.VARCHAR(length=40), nullable=False),
sa.Column('owner_scope', sa.VARCHAR(length=80), nullable=False),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chat_sessions_status'), 'chat_sessions', ['status'], unique=False)
op.create_index(op.f('ix_chat_sessions_owner_scope'), 'chat_sessions', ['owner_scope'], unique=False)
op.create_index(op.f('ix_chat_sessions_character_id'), 'chat_sessions', ['character_id'], unique=False)
op.create_table('character_emotion_states',
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('mood', sa.VARCHAR(length=40), nullable=False),
sa.Column('intensity', sa.FLOAT(), nullable=False),
sa.Column('baseline_mood', sa.VARCHAR(length=40), nullable=False),
sa.Column('baseline_intensity', sa.FLOAT(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.Column('last_trigger_event_id', sa.VARCHAR(length=80), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('character_id')
)
with op.batch_alter_table('runs') as batch_op:
batch_op.add_column(sa.Column('session_id', sa.String(length=80), nullable=True))
batch_op.create_foreign_key('fk_runs_session_id', 'chat_sessions', ['session_id'], ['id'])
batch_op.create_index('ix_runs_session_id', ['session_id'])
# ### end Alembic commands ###
@@ -0,0 +1,102 @@
"""息壤记忆域:三级记忆表重建与触发/实录,退役旧记忆表
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')
)
@@ -0,0 +1,40 @@
"""运行归属用户列_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 ###
@@ -0,0 +1,153 @@
"""百工域_能力注册表_授契行契_调用记录_请命超时改造_退役旧策略与端点覆盖表
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 ###
@@ -0,0 +1,170 @@
"""缘契域_契约与事件_铸魂分灵_河图境界与实体_实录作用域_退役旧人格驱动演化世界表
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')
@@ -0,0 +1,90 @@
"""形仪域_真形锚点_主仪化仪定相_声音档案与克隆授权
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 ###
@@ -0,0 +1,87 @@
"""天工阁_受托任务_叩心候选与约束_退役旧目标计划感知大周天模块
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 ###
@@ -0,0 +1,121 @@
"""器谱_设备配对与逐项授契
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 ###
@@ -1,94 +0,0 @@
"""模型网关:逻辑模型、端点、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 ###
@@ -1,176 +0,0 @@
"""垂直切片:会话字段与会话树、运行、幂等记录
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 ###
@@ -1,36 +0,0 @@
"""消息关联 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 ###
@@ -1,32 +0,0 @@
"""消息记录 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 ###
@@ -1,47 +0,0 @@
"""插件沙箱执行记录
Revision ID: 4e679f11da46
Revises: 89a4ed9647ee
Create Date: 2026-09-12 13:33:43.279920
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '4e679f11da46'
down_revision: str | Sequence[str] | None = '89a4ed9647ee'
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('plugin_execution',
sa.Column('id', sa.String(), nullable=False),
sa.Column('installation_id', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('exit_code', sa.Integer(), nullable=True),
sa.Column('duration_ms', sa.Integer(), nullable=True),
sa.Column('stdout_excerpt', sa.String(), nullable=True),
sa.Column('error', 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 ('running', 'succeeded', 'failed', 'timeout', 'killed')", name=op.f('ck_plugin_execution_status_values')),
sa.ForeignKeyConstraint(['installation_id'], ['plugin_installation.id'], name=op.f('fk_plugin_execution_plugin_installation'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_plugin_execution'))
)
op.create_index(op.f('ix_plugin_execution_installation_id'), 'plugin_execution', ['installation_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_plugin_execution_installation_id'), table_name='plugin_execution')
op.drop_table('plugin_execution')
# ### end Alembic commands ###
@@ -1,99 +0,0 @@
"""媒体:上传、文件、任务与声契
Revision ID: 52be875c14a4
Revises: f7bf2b464f1f
Create Date: 2026-09-12 10:47:53.573208
"""
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 = '52be875c14a4'
down_revision: str | Sequence[str] | None = 'f7bf2b464f1f'
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('media_job',
sa.Column('id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('kind', 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('progress', sa.Integer(), nullable=False),
sa.Column('error', 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("kind IN ('asr', 'tts', 'image_gen', 'image_edit')", name=op.f('ck_media_job_kind_values')),
sa.CheckConstraint("status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')", name=op.f('ck_media_job_status_values')),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_media_job_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_media_job'))
)
op.create_index(op.f('ix_media_job_domain_id'), 'media_job', ['domain_id'], unique=False)
op.create_table('upload',
sa.Column('id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('filename', sa.String(), nullable=False),
sa.Column('media_type', sa.String(), nullable=False),
sa.Column('size_limit', sa.Integer(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('pending', 'completed', 'failed')", name=op.f('ck_upload_status_values')),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_upload_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_upload'))
)
op.create_index(op.f('ix_upload_domain_id'), 'upload', ['domain_id'], unique=False)
op.create_table('file_asset',
sa.Column('id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('upload_id', sa.String(), nullable=True),
sa.Column('filename', sa.String(), nullable=False),
sa.Column('media_type', sa.String(), nullable=False),
sa.Column('size_bytes', sa.Integer(), nullable=False),
sa.Column('sha256', sa.String(), nullable=True),
sa.Column('storage_key', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('pending', 'ready', 'failed', 'quarantined')", name=op.f('ck_file_asset_status_values')),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_file_asset_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['upload_id'], ['upload.id'], name=op.f('fk_file_asset_upload'), ondelete='set null'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_asset'))
)
op.create_index(op.f('ix_file_asset_domain_id'), 'file_asset', ['domain_id'], unique=False)
op.create_table('voice_consent',
sa.Column('id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('character_id', sa.String(), nullable=True),
sa.Column('purpose', sa.String(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['character.id'], name=op.f('fk_voice_consent_character'), ondelete='cascade'),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_voice_consent_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_voice_consent'))
)
op.create_index(op.f('ix_voice_consent_domain_id'), 'voice_consent', ['domain_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_voice_consent_domain_id'), table_name='voice_consent')
op.drop_table('voice_consent')
op.drop_index(op.f('ix_file_asset_domain_id'), table_name='file_asset')
op.drop_table('file_asset')
op.drop_index(op.f('ix_upload_domain_id'), table_name='upload')
op.drop_table('upload')
op.drop_index(op.f('ix_media_job_domain_id'), table_name='media_job')
op.drop_table('media_job')
# ### end Alembic commands ###
@@ -1,49 +0,0 @@
"""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 ###
@@ -1,69 +0,0 @@
"""角色与版本链
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 ###
@@ -1,49 +0,0 @@
"""诊断授权与管理员角色
Revision ID: 89a4ed9647ee
Revises: 52be875c14a4
Create Date: 2026-09-12 11:14:22.131144
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '89a4ed9647ee'
down_revision: str | Sequence[str] | None = '52be875c14a4'
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('diagnostic_grant',
sa.Column('id', sa.String(), nullable=False),
sa.Column('run_id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('purpose', sa.String(), nullable=False),
sa.Column('token_hash', sa.String(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_diagnostic_grant_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['run_id'], ['run.id'], name=op.f('fk_diagnostic_grant_run'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_diagnostic_grant')),
sa.UniqueConstraint('token_hash', name=op.f('uq_diagnostic_grant_token_hash'))
)
op.create_index(op.f('ix_diagnostic_grant_run_id'), 'diagnostic_grant', ['run_id'], unique=False)
op.add_column('user', sa.Column('role', sa.String(), nullable=False, server_default='user'))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'role')
op.drop_index(op.f('ix_diagnostic_grant_run_id'), table_name='diagnostic_grant')
op.drop_table('diagnostic_grant')
# ### end Alembic commands ###
@@ -1,42 +0,0 @@
"""三界作用域与消息版本谱系
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 ###
@@ -1,120 +0,0 @@
"""记忆与知识:候选、版本、大周天
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 ###
@@ -1,67 +0,0 @@
"""工具注册表、调用与 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 ###
@@ -1,107 +0,0 @@
"""市场交易:商品、订单、账本与 Webhook
Revision ID: b7f1a0032451
Revises: b3a26cb61923
Create Date: 2026-09-11 11:31:22.345265
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b7f1a0032451'
down_revision: str | Sequence[str] | None = 'b3a26cb61923'
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('webhook_event',
sa.Column('provider_event_id', sa.String(), nullable=False),
sa.Column('provider', sa.String(), nullable=False),
sa.Column('event_type', sa.String(), nullable=False),
sa.Column('payload_hash', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('received_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('processed', 'ignored')", name=op.f('ck_webhook_event_status_values')),
sa.PrimaryKeyConstraint('provider_event_id', name=op.f('pk_webhook_event'))
)
op.create_table('listing',
sa.Column('id', sa.String(), nullable=False),
sa.Column('creator_domain_id', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('price_minor', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint('price_minor >= 0', name=op.f('ck_listing_price_non_negative')),
sa.ForeignKeyConstraint(['creator_domain_id'], ['personal_domain.id'], name=op.f('fk_listing_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_listing'))
)
op.create_index(op.f('ix_listing_creator_domain_id'), 'listing', ['creator_domain_id'], unique=False)
op.create_table('order',
sa.Column('id', sa.String(), nullable=False),
sa.Column('buyer_domain_id', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('total_minor', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(), nullable=False),
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('paid_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('refunded_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('pending_payment', 'paid', 'partially_refunded', 'refunded', 'disputed', 'cancelled')", name=op.f('ck_order_status_values')),
sa.ForeignKeyConstraint(['buyer_domain_id'], ['personal_domain.id'], name=op.f('fk_order_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_order'))
)
op.create_index(op.f('ix_order_buyer_domain_id'), 'order', ['buyer_domain_id'], unique=False)
op.create_index('uq_order_buyer_idempotency', 'order', ['buyer_domain_id', 'idempotency_key'], unique=True)
op.create_table('ledger_entry',
sa.Column('id', sa.String(), nullable=False),
sa.Column('order_id', sa.String(), nullable=False),
sa.Column('entry_type', sa.String(), nullable=False),
sa.Column('amount_minor', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(), nullable=False),
sa.Column('note', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("entry_type IN ('charge', 'refund', 'platform_fee', 'creator_payable', 'tax', 'reserve', 'payout', 'reversal')", name=op.f('ck_ledger_entry_entry_type_values')),
sa.ForeignKeyConstraint(['order_id'], ['order.id'], name=op.f('fk_ledger_entry_order'), ondelete='restrict'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_ledger_entry'))
)
op.create_index(op.f('ix_ledger_entry_order_id'), 'ledger_entry', ['order_id'], unique=False)
op.create_index('ix_ledger_order_type', 'ledger_entry', ['order_id', 'entry_type'], unique=False)
op.create_table('order_item',
sa.Column('id', sa.String(), nullable=False),
sa.Column('order_id', sa.String(), nullable=False),
sa.Column('listing_id', sa.String(), nullable=False),
sa.Column('title_snapshot', sa.String(), nullable=False),
sa.Column('price_minor_snapshot', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['listing_id'], ['listing.id'], name=op.f('fk_order_item_listing'), ondelete='restrict'),
sa.ForeignKeyConstraint(['order_id'], ['order.id'], name=op.f('fk_order_item_order'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_order_item'))
)
op.create_index(op.f('ix_order_item_order_id'), 'order_item', ['order_id'], unique=False)
op.add_column('personal_domain', sa.Column('billing_status', sa.String(), nullable=False, server_default='ok'))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('personal_domain', 'billing_status')
op.drop_index(op.f('ix_order_item_order_id'), table_name='order_item')
op.drop_table('order_item')
op.drop_index('ix_ledger_order_type', table_name='ledger_entry')
op.drop_index(op.f('ix_ledger_entry_order_id'), table_name='ledger_entry')
op.drop_table('ledger_entry')
op.drop_index('uq_order_buyer_idempotency', table_name='order')
op.drop_index(op.f('ix_order_buyer_domain_id'), table_name='order')
op.drop_table('order')
op.drop_index(op.f('ix_listing_creator_domain_id'), table_name='listing')
op.drop_table('listing')
op.drop_table('webhook_event')
# ### end Alembic commands ###
@@ -1,207 +0,0 @@
"""协作空间:成员、邀请、项目与评审
Revision ID: bd36c0a63816
Revises: b7f1a0032451
Create Date: 2026-09-12 09:31:43.277430
"""
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 = 'bd36c0a63816'
down_revision: str | Sequence[str] | None = 'b7f1a0032451'
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('follow',
sa.Column('id', sa.String(), nullable=False),
sa.Column('follower_domain_id', sa.String(), nullable=False),
sa.Column('followee_domain_id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['followee_domain_id'], ['personal_domain.id'], name='fk_follow_followee_domain', ondelete='cascade'),
sa.ForeignKeyConstraint(['follower_domain_id'], ['personal_domain.id'], name='fk_follow_follower_domain', ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_follow'))
)
op.create_index(op.f('ix_follow_followee_domain_id'), 'follow', ['followee_domain_id'], unique=False)
op.create_index(op.f('ix_follow_follower_domain_id'), 'follow', ['follower_domain_id'], unique=False)
op.create_index('uq_follow_pair', 'follow', ['follower_domain_id', 'followee_domain_id'], unique=True)
op.create_table('report',
sa.Column('id', sa.String(), nullable=False),
sa.Column('reporter_domain_id', sa.String(), nullable=False),
sa.Column('target_type', sa.String(), nullable=False),
sa.Column('target_id', sa.String(), nullable=False),
sa.Column('reason', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('pending', 'reviewed', 'actioned')", name=op.f('ck_report_status_values')),
sa.CheckConstraint("target_type IN ('content', 'comment')", name=op.f('ck_report_target_type_values')),
sa.ForeignKeyConstraint(['reporter_domain_id'], ['personal_domain.id'], name=op.f('fk_report_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_report'))
)
op.create_index(op.f('ix_report_reporter_domain_id'), 'report', ['reporter_domain_id'], unique=False)
op.create_table('workspace',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('owner_domain_id', sa.String(), 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_workspace_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_workspace'))
)
op.create_index(op.f('ix_workspace_owner_domain_id'), 'workspace', ['owner_domain_id'], unique=False)
op.create_table('community_content',
sa.Column('id', sa.String(), nullable=False),
sa.Column('author_domain_id', sa.String(), nullable=False),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('body', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('character_id', sa.String(), nullable=True),
sa.Column('visibility', sa.String(), nullable=False),
sa.Column('status', 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.CheckConstraint("kind IN ('post', 'character_release')", name=op.f('ck_community_content_kind_values')),
sa.CheckConstraint("status IN ('draft', 'submitted', 'published', 'limited', 'suspended', 'removed')", name=op.f('ck_community_content_status_values')),
sa.CheckConstraint("visibility IN ('public', 'followers')", name=op.f('ck_community_content_visibility_values')),
sa.ForeignKeyConstraint(['author_domain_id'], ['personal_domain.id'], name=op.f('fk_community_content_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['character_id'], ['character.id'], name=op.f('fk_community_content_character'), ondelete='set null'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_community_content'))
)
op.create_index(op.f('ix_community_content_author_domain_id'), 'community_content', ['author_domain_id'], unique=False)
op.create_table('moderation_case',
sa.Column('id', sa.String(), nullable=False),
sa.Column('report_id', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('action_taken', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('pending', 'resolved')", name=op.f('ck_moderation_case_status_values')),
sa.ForeignKeyConstraint(['report_id'], ['report.id'], name=op.f('fk_moderation_case_report'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_moderation_case'))
)
op.create_index(op.f('ix_moderation_case_report_id'), 'moderation_case', ['report_id'], unique=False)
op.create_table('project',
sa.Column('id', sa.String(), nullable=False),
sa.Column('workspace_id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('current_version_id', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('draft', 'published')", name=op.f('ck_project_status_values')),
sa.ForeignKeyConstraint(['current_version_id'], ['project_version.id'], name=op.f('fk_project_project_version'), ondelete='set null', use_alter=True),
sa.ForeignKeyConstraint(['workspace_id'], ['workspace.id'], name=op.f('fk_project_workspace'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_project'))
)
op.create_index(op.f('ix_project_workspace_id'), 'project', ['workspace_id'], unique=False)
op.create_table('workspace_invitation',
sa.Column('id', sa.String(), nullable=False),
sa.Column('workspace_id', sa.String(), nullable=False),
sa.Column('invitee_domain_id', sa.String(), nullable=False),
sa.Column('role', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("role IN ('editor', 'reviewer', 'viewer')", name=op.f('ck_workspace_invitation_role_values')),
sa.CheckConstraint("status IN ('pending', 'accepted', 'declined', 'expired')", name=op.f('ck_workspace_invitation_status_values')),
sa.ForeignKeyConstraint(['invitee_domain_id'], ['personal_domain.id'], name=op.f('fk_workspace_invitation_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['workspace_id'], ['workspace.id'], name=op.f('fk_workspace_invitation_workspace'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_workspace_invitation'))
)
op.create_index(op.f('ix_workspace_invitation_workspace_id'), 'workspace_invitation', ['workspace_id'], unique=False)
op.create_table('workspace_member',
sa.Column('id', sa.String(), nullable=False),
sa.Column('workspace_id', sa.String(), nullable=False),
sa.Column('member_domain_id', sa.String(), nullable=False),
sa.Column('role', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("role IN ('owner', 'editor', 'reviewer', 'viewer')", name=op.f('ck_workspace_member_role_values')),
sa.ForeignKeyConstraint(['member_domain_id'], ['personal_domain.id'], name=op.f('fk_workspace_member_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['workspace_id'], ['workspace.id'], name=op.f('fk_workspace_member_workspace'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_workspace_member'))
)
op.create_index(op.f('ix_workspace_member_workspace_id'), 'workspace_member', ['workspace_id'], unique=False)
op.create_index('uq_workspace_member', 'workspace_member', ['workspace_id', 'member_domain_id'], unique=True)
op.create_table('comment',
sa.Column('id', sa.String(), nullable=False),
sa.Column('content_id', sa.String(), nullable=False),
sa.Column('author_domain_id', sa.String(), nullable=False),
sa.Column('parent_comment_id', sa.String(), nullable=True),
sa.Column('body', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('visible', 'removed')", name=op.f('ck_comment_status_values')),
sa.ForeignKeyConstraint(['author_domain_id'], ['personal_domain.id'], name=op.f('fk_comment_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['content_id'], ['community_content.id'], name=op.f('fk_comment_community_content'), ondelete='cascade'),
sa.ForeignKeyConstraint(['parent_comment_id'], ['comment.id'], name=op.f('fk_comment_comment'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_comment'))
)
op.create_index(op.f('ix_comment_content_id'), 'comment', ['content_id'], unique=False)
op.create_table('project_version',
sa.Column('id', sa.String(), nullable=False),
sa.Column('project_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('status', sa.String(), nullable=False),
sa.Column('submitted_by', sa.String(), nullable=True),
sa.Column('reviewed_by', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('draft', 'submitted', 'approved', 'rejected')", name=op.f('ck_project_version_status_values')),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], name=op.f('fk_project_version_project'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_project_version'))
)
op.create_index(op.f('ix_project_version_project_id'), 'project_version', ['project_id'], unique=False)
op.create_index('uq_project_version_number', 'project_version', ['project_id', 'version'], unique=True)
op.create_table('reaction',
sa.Column('id', sa.String(), nullable=False),
sa.Column('content_id', sa.String(), nullable=False),
sa.Column('actor_domain_id', sa.String(), nullable=False),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("kind IN ('like', 'save')", name=op.f('ck_reaction_kind_values')),
sa.ForeignKeyConstraint(['actor_domain_id'], ['personal_domain.id'], name=op.f('fk_reaction_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['content_id'], ['community_content.id'], name=op.f('fk_reaction_community_content'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_reaction'))
)
op.create_index(op.f('ix_reaction_content_id'), 'reaction', ['content_id'], unique=False)
op.create_index('uq_reaction_content_actor_kind', 'reaction', ['content_id', 'actor_domain_id', 'kind'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('uq_reaction_content_actor_kind', table_name='reaction')
op.drop_index(op.f('ix_reaction_content_id'), table_name='reaction')
op.drop_table('reaction')
op.drop_index('uq_project_version_number', table_name='project_version')
op.drop_index(op.f('ix_project_version_project_id'), table_name='project_version')
op.drop_table('project_version')
op.drop_index(op.f('ix_comment_content_id'), table_name='comment')
op.drop_table('comment')
op.drop_index('uq_workspace_member', table_name='workspace_member')
op.drop_index(op.f('ix_workspace_member_workspace_id'), table_name='workspace_member')
op.drop_table('workspace_member')
op.drop_index(op.f('ix_workspace_invitation_workspace_id'), table_name='workspace_invitation')
op.drop_table('workspace_invitation')
op.drop_index(op.f('ix_project_workspace_id'), table_name='project')
op.drop_table('project')
op.drop_index(op.f('ix_moderation_case_report_id'), table_name='moderation_case')
op.drop_table('moderation_case')
op.drop_index(op.f('ix_community_content_author_domain_id'), table_name='community_content')
op.drop_table('community_content')
op.drop_index(op.f('ix_workspace_owner_domain_id'), table_name='workspace')
op.drop_table('workspace')
op.drop_index(op.f('ix_report_reporter_domain_id'), table_name='report')
op.drop_table('report')
op.drop_index('uq_follow_pair', table_name='follow')
op.drop_index(op.f('ix_follow_follower_domain_id'), table_name='follow')
op.drop_index(op.f('ix_follow_followee_domain_id'), table_name='follow')
op.drop_table('follow')
# ### end Alembic commands ###
@@ -1,125 +0,0 @@
"""初始基线:身份、会话、服务账号、审计与 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,72 +0,0 @@
"""Avatar 模型与非破坏命令历史
Revision ID: e66d791926f2
Revises: 4e679f11da46
Create Date: 2026-09-14 10:24:23.502856
"""
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 = 'e66d791926f2'
down_revision: str | Sequence[str] | None = '4e679f11da46'
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('avatar_model',
sa.Column('id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('character_id', sa.String(), nullable=True),
sa.Column('name', sa.String(), nullable=False),
sa.Column('format', sa.String(), nullable=False),
sa.Column('file_id', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('validation_report', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('source_info', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('license_terms', sa.String(), nullable=True),
sa.Column('current_command_id', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("format IN ('live2d', 'vrm', 'gltf')", name=op.f('ck_avatar_model_format_values')),
sa.CheckConstraint("status IN ('pending', 'validated', 'rejected')", name=op.f('ck_avatar_model_status_values')),
sa.ForeignKeyConstraint(['character_id'], ['character.id'], name=op.f('fk_avatar_model_character'), ondelete='set null'),
sa.ForeignKeyConstraint(['current_command_id'], ['avatar_command.id'], name=op.f('fk_avatar_model_avatar_command'), ondelete='set null', use_alter=True),
sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_avatar_model_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['file_id'], ['file_asset.id'], name=op.f('fk_avatar_model_file_asset'), ondelete='restrict'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_avatar_model'))
)
op.create_index(op.f('ix_avatar_model_domain_id'), 'avatar_model', ['domain_id'], unique=False)
op.create_table('avatar_command',
sa.Column('id', sa.String(), nullable=False),
sa.Column('avatar_id', sa.String(), nullable=False),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('command_type', sa.String(), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('undone', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("command_type IN ('param_map', 'expression', 'physics', 'motion', 'material', 'texture', 'bone', 'attachment', 'lighting', 'camera')", name=op.f('ck_avatar_command_command_type_values')),
sa.ForeignKeyConstraint(['avatar_id'], ['avatar_model.id'], name=op.f('fk_avatar_command_avatar_model'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_avatar_command'))
)
op.create_index(op.f('ix_avatar_command_avatar_id'), 'avatar_command', ['avatar_id'], unique=False)
op.create_index('uq_avatar_command_seq', 'avatar_command', ['avatar_id', 'sequence'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('uq_avatar_command_seq', table_name='avatar_command')
op.drop_index(op.f('ix_avatar_command_avatar_id'), table_name='avatar_command')
op.drop_table('avatar_command')
op.drop_index(op.f('ix_avatar_model_domain_id'), table_name='avatar_model')
op.drop_table('avatar_model')
# ### end Alembic commands ###
@@ -1,82 +0,0 @@
"""插件生态:Registry、版本与安装
Revision ID: f7bf2b464f1f
Revises: bd36c0a63816
Create Date: 2026-09-12 10:15:58.892690
"""
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 = 'f7bf2b464f1f'
down_revision: str | Sequence[str] | None = 'bd36c0a63816'
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('plugin',
sa.Column('name', sa.String(), nullable=False),
sa.Column('publisher_domain_id', sa.String(), nullable=False),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("kind IN ('declarative', 'remote', 'isolated_process', 'container')", name=op.f('ck_plugin_kind_values')),
sa.CheckConstraint("status IN ('published', 'suspended', 'removed')", name=op.f('ck_plugin_status_values')),
sa.ForeignKeyConstraint(['publisher_domain_id'], ['personal_domain.id'], name=op.f('fk_plugin_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('name', name=op.f('pk_plugin'))
)
op.create_index(op.f('ix_plugin_publisher_domain_id'), 'plugin', ['publisher_domain_id'], unique=False)
op.create_table('plugin_version',
sa.Column('id', sa.String(), nullable=False),
sa.Column('plugin_name', sa.String(), nullable=False),
sa.Column('version', sa.String(), nullable=False),
sa.Column('manifest', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('content_hash', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("status IN ('published', 'suspended', 'removed')", name=op.f('ck_plugin_version_status_values')),
sa.ForeignKeyConstraint(['plugin_name'], ['plugin.name'], name=op.f('fk_plugin_version_plugin'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_plugin_version'))
)
op.create_index(op.f('ix_plugin_version_plugin_name'), 'plugin_version', ['plugin_name'], unique=False)
op.create_index('uq_plugin_version', 'plugin_version', ['plugin_name', 'version'], unique=True)
op.create_table('plugin_installation',
sa.Column('id', sa.String(), nullable=False),
sa.Column('domain_id', sa.String(), nullable=False),
sa.Column('plugin_name', sa.String(), nullable=False),
sa.Column('version_id', sa.String(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('granted_permissions', 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(['domain_id'], ['personal_domain.id'], name=op.f('fk_plugin_installation_personal_domain'), ondelete='cascade'),
sa.ForeignKeyConstraint(['plugin_name'], ['plugin.name'], name=op.f('fk_plugin_installation_plugin'), ondelete='cascade'),
sa.ForeignKeyConstraint(['version_id'], ['plugin_version.id'], name=op.f('fk_plugin_installation_plugin_version'), ondelete='restrict'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_plugin_installation'))
)
op.create_index(op.f('ix_plugin_installation_domain_id'), 'plugin_installation', ['domain_id'], unique=False)
op.create_index('uq_plugin_installation', 'plugin_installation', ['domain_id', 'plugin_name'], unique=True)
op.create_foreign_key(op.f('fk_project_project_version'), 'project', 'project_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_project_project_version'), 'project', type_='foreignkey')
op.drop_index('uq_plugin_installation', table_name='plugin_installation')
op.drop_index(op.f('ix_plugin_installation_domain_id'), table_name='plugin_installation')
op.drop_table('plugin_installation')
op.drop_index('uq_plugin_version', table_name='plugin_version')
op.drop_index(op.f('ix_plugin_version_plugin_name'), table_name='plugin_version')
op.drop_table('plugin_version')
op.drop_index(op.f('ix_plugin_publisher_domain_id'), table_name='plugin')
op.drop_table('plugin')
# ### end Alembic commands ###
+1 -1
View File
@@ -1 +1 @@
"""Hunjing 后端应用包。""" """魂镜后端应用包。"""
+1
View File
@@ -0,0 +1 @@
"""API 路由包。"""
+69
View File
@@ -0,0 +1,69 @@
"""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
@@ -0,0 +1 @@
"""v1 API 路由。"""
+165
View File
@@ -0,0 +1,165 @@
"""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
@@ -0,0 +1,9 @@
"""v1 API 路由端点。
路由实现统一放在 ``endpoints/`` 子目录,``deps.py`` 留在父级 ``api/v1/`` 提供依赖注入。
每个端点模块只做 参数校验 → 调用 service/usecase → 转响应,不直接持有 ``AsyncSession``。
"""
from app.api.v1.endpoints import auth
__all__ = ["auth"]
+396
View File
@@ -0,0 +1,396 @@
"""司命台管理 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
@@ -0,0 +1,207 @@
"""认证 API。"""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.deps import get_current_user
from app.core.config import settings
from app.db.session import get_session
from app.models.rbac import Role, UserRole
from app.schemas.auth import (
ChangePasswordRequest,
CurrentUser,
LoginRequest,
LoginResponse,
RefreshRequest,
RegisterRequest,
UpdateProfileRequest,
UserResponse,
)
from app.services import auth as auth_service
from app.services.auth import get_user_permissions
router = APIRouter(prefix="/auth", tags=["auth"])
def _jwt_enabled() -> None:
if not settings.jwt_secret:
from fastapi import HTTPException
raise HTTPException(
status_code=503,
detail={
"error": {
"code": "service_unavailable",
"message": "认证服务未配置(JWT_SECRET 为空)。",
}
},
)
@router.post("/register", response_model=UserResponse)
async def register(
payload: RegisterRequest,
db: AsyncSession = Depends(get_session),
) -> UserResponse:
_jwt_enabled()
user = await auth_service.create_user(
db,
username=payload.username,
email=payload.email,
password=payload.password,
)
# 为新用户创建默认角色和默认人物
from app.services.characters import ensure_default_character_for_owner
await ensure_default_character_for_owner(db, user.id)
# 分配普通用户角色
role_result = await db.execute(select(Role).where(Role.name == "user"))
user_role = role_result.scalar_one_or_none()
if user_role:
db.add(UserRole(user_id=user.id, role_id=user_role.id))
await db.commit()
await db.refresh(user)
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin,
created_at=user.created_at.isoformat(),
)
@router.post("/login", response_model=LoginResponse)
async def login(
payload: LoginRequest,
db: AsyncSession = Depends(get_session),
) -> LoginResponse:
_jwt_enabled()
user = await auth_service.authenticate_user(db, payload.username, payload.password)
access_token = auth_service.create_access_token(
subject=user.id,
extra={"owner_scope": user.id},
)
refresh_token = auth_service.create_refresh_token(subject=user.id)
perms = await get_user_permissions(db, user.id)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in=settings.access_token_ttl,
user=UserResponse(
id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin,
permissions=perms,
created_at=user.created_at.isoformat(),
),
)
@router.post("/refresh", response_model=LoginResponse)
async def refresh(
payload: RefreshRequest,
db: AsyncSession = Depends(get_session),
) -> LoginResponse:
_jwt_enabled()
data = auth_service.decode_token(payload.refresh_token)
if data.get("type") != "refresh":
from fastapi import HTTPException
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "Token 类型不匹配。"}},
)
user = await auth_service.get_user_by_id(db, data["sub"])
if user is None or not user.is_active:
from fastapi import HTTPException
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "用户不存在或已禁用。"}},
)
new_access = auth_service.create_access_token(
subject=user.id,
extra={"owner_scope": user.id},
)
new_refresh = auth_service.create_refresh_token(subject=user.id)
perms = await get_user_permissions(db, user.id)
return LoginResponse(
access_token=new_access,
refresh_token=new_refresh,
token_type="bearer",
expires_in=settings.access_token_ttl,
user=UserResponse(
id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin,
permissions=perms,
created_at=user.created_at.isoformat(),
),
)
@router.get("/me", response_model=UserResponse)
async def get_me(
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_session),
) -> UserResponse:
perms = await get_user_permissions(db, current_user.user_id)
return UserResponse(
id=current_user.user_id,
username=current_user.username,
email=current_user.email,
is_admin=current_user.is_admin,
permissions=perms,
created_at="",
)
@router.patch("/me", response_model=UserResponse)
async def update_profile(
payload: UpdateProfileRequest,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_session),
) -> UserResponse:
"""更新个人资料(当前仅支持邮箱)。"""
if payload.email is not None:
from sqlalchemy import select as _select
from app.models.user import User
result = await db.execute(_select(User).where(User.email == payload.email))
existing = result.scalar_one_or_none()
if existing is not None and existing.id != current_user.user_id:
from app.core.errors import AppError
raise AppError("conflict", "该邮箱已被其他用户使用。")
user = await db.get(User, current_user.user_id)
if user is not None:
user.email = payload.email
await db.commit()
return await get_me(current_user, db)
@router.post("/me/change-password", status_code=204)
async def change_password(
payload: ChangePasswordRequest,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_session),
) -> None:
"""修改当前用户密码。"""
from app.models.user import User
user = await db.get(User, current_user.user_id)
if user is None:
from app.core.errors import AppError
raise AppError("not_found", "用户不存在。")
if not auth_service.verify_password(payload.old_password, user.password_hash):
from app.core.errors import AppError
raise AppError("unauthorized", "原密码错误。")
user.password_hash = auth_service.hash_password(payload.new_password)
await db.commit()
+5
View File
@@ -0,0 +1,5 @@
"""百工与司命路由(域实现见 app/domains/baigong/api.py)。"""
from app.domains.baigong.api import routers
__all__ = ["routers"]
@@ -0,0 +1,60 @@
"""角色卡导入 / 导出 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
@@ -0,0 +1,152 @@
"""人物 API。"""
from fastapi import APIRouter, Depends, Header, Query, Request
from app.api.v1.deps import get_current_user
from app.core.errors import AppError
from app.schemas.auth import CurrentUser
from app.schemas.character import (
ActivateCharacterResponse,
CharacterResponse,
CharacterSummaryResponse,
CharacterTemplateSummaryResponse,
CreateCharacterRequest,
UpdateModelPolicyRequest,
UpdateProfileRequest,
)
from app.schemas.common import Page
from app.services.characters import (
activate_character,
archive_character,
create_character,
get_active_character,
get_character_by_id,
list_characters_page,
list_system_templates_page,
pause_character,
resume_character,
update_character_model_policy,
update_character_profile,
)
from app.services.idempotency import run_idempotent
router = APIRouter(prefix="/characters", tags=["characters"])
@router.get("/active", response_model=CharacterResponse)
async def active_character(
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await get_active_character(owner_scope=current_user.owner_scope)
@router.get("/templates", response_model=list[CharacterTemplateSummaryResponse])
async def list_templates(
current_user: CurrentUser = Depends(get_current_user),
) -> list[CharacterTemplateSummaryResponse]:
"""列出所有系统模板,仅管理员可见(管理端完整 CRUD 见 ``/admin/characters/templates``)。"""
if not current_user.is_admin:
raise AppError("permission_denied", "仅管理员可查看系统模板。")
return await list_system_templates_page()
@router.get("", response_model=Page[CharacterSummaryResponse])
async def list_characters(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
status: str | None = Query(default=None, description="按人物状态过滤。"),
current_user: CurrentUser = Depends(get_current_user),
) -> Page[CharacterSummaryResponse]:
return await list_characters_page(
owner_scope=current_user.owner_scope, status=status, page=page, page_size=page_size
)
@router.post("", response_model=CharacterResponse)
async def create_character_api(
payload: CreateCharacterRequest,
request: Request,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
"""创建人物。
- 传 ``template_key`` 时从系统模板复制(仅管理员可用)。
- 不传 ``template_key`` 时创建空白人物(任何用户可用,status=wuji)。
"""
if payload.template_key and not current_user.is_admin:
raise AppError("permission_denied", "仅管理员可从系统模板创建人物。")
return await run_idempotent(
key=idempotency_key,
method="POST",
path=str(request.url.path),
payload=payload,
response_type=CharacterResponse,
resource_type="character",
handler=lambda session: create_character(
session, payload, owner_scope=current_user.owner_scope
),
owner_scope=current_user.owner_scope,
)
@router.get("/{character_id}", response_model=CharacterResponse)
async def get_character(
character_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await get_character_by_id(character_id, owner_scope=current_user.owner_scope)
@router.patch("/{character_id}/profile", response_model=CharacterResponse)
async def update_profile(
character_id: str,
payload: UpdateProfileRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await update_character_profile(
character_id, payload, owner_scope=current_user.owner_scope
)
@router.patch("/{character_id}/model-policy", response_model=CharacterResponse)
async def update_model_policy(
character_id: str,
payload: UpdateModelPolicyRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await update_character_model_policy(
character_id, payload, owner_scope=current_user.owner_scope
)
@router.post("/{character_id}/activate", response_model=ActivateCharacterResponse)
async def activate(
character_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> ActivateCharacterResponse:
return await activate_character(character_id, owner_scope=current_user.owner_scope)
@router.post("/{character_id}/pause", response_model=CharacterResponse)
async def pause(
character_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await pause_character(character_id, owner_scope=current_user.owner_scope)
@router.post("/{character_id}/resume", response_model=CharacterResponse)
async def resume(
character_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await resume_character(character_id, owner_scope=current_user.owner_scope)
@router.post("/{character_id}/archive", response_model=CharacterResponse)
async def archive(
character_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> CharacterResponse:
return await archive_character(character_id, owner_scope=current_user.owner_scope)
@@ -0,0 +1,142 @@
"""灵胚 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
@@ -0,0 +1,63 @@
"""全局一炁事件 SSE 端点(非 run-scoped)。
供前端 useGlobalEventStream hook 订阅全局广播事件:
- ``proactive.session`` —— 叩心枢新建的主动会话
- ``proactive.message`` —— 叩心枢追加到已有会话的消息
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from typing import Any
from fastapi import APIRouter, Request
from sse_starlette.sse import EventSourceResponse
from app.core.config import settings
from app.services.event_store import DbEventStore
router = APIRouter(prefix="/events", tags=["events"])
def _get_event_store(request: Request) -> DbEventStore:
return request.app.state.event_store
@router.get("")
async def global_event_stream(
request: Request,
) -> EventSourceResponse:
"""全局事件 SSE 流:通过 EventStore.subscribe_by_pattern 监听所有
proactive.* 事件,推送给前端。"""
if not settings.enable_sse:
from app.core.errors import AppError
raise AppError("invalid_request", "SSE 当前未启用。")
event_store = _get_event_store(request)
return EventSourceResponse(_event_generator(event_store))
async def _event_generator(
event_store: DbEventStore,
) -> AsyncGenerator[dict[str, Any], None]:
queue = event_store.subscribe_by_pattern("proactive.*")
try:
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=settings.sse_heartbeat_seconds)
yield {
"id": event.id,
"event": event.type,
"data": event.model_dump_json(),
}
except TimeoutError:
yield {
"event": "heartbeat",
"data": '{"type":"heartbeat"}',
}
except asyncio.CancelledError:
pass
finally:
event_store.unsubscribe_by_pattern("proactive.*", queue)
+12
View File
@@ -0,0 +1,12 @@
"""健康检查 API。"""
from fastapi import APIRouter
from app.core.config import settings
router = APIRouter(tags=["health"])
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "app_name": settings.app_name, "env": settings.app_env}
+5
View File
@@ -0,0 +1,5 @@
"""河图世界路由(域实现见 app/domains/hetu/api.py)。"""
from app.domains.hetu.api import world_router
__all__ = ["world_router"]
+105
View File
@@ -0,0 +1,105 @@
"""资料导入 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,
)
@@ -0,0 +1,220 @@
"""镜生域人物端点:魂籍导出与校验、死亡请求/撤销/落定、命台租约(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)
@@ -0,0 +1,55 @@
"""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
@@ -0,0 +1,5 @@
"""叩心路由(域实现见 app/domains/kouxin/api.py)。"""
from app.domains.kouxin.api import knock_router
__all__ = ["knock_router"]
+127
View File
@@ -0,0 +1,127 @@
"""世谱 API。"""
from fastapi import APIRouter, Depends, Header, Query, Request
from app.api.v1.deps import get_current_user
from app.schemas.auth import CurrentUser
from app.schemas.lorebook import (
LorebookCreateRequest,
LorebookEntryCreateRequest,
LorebookEntryResponse,
LorebookEntryUpdateRequest,
LorebookResponse,
LorebookUpdateRequest,
)
from app.services.idempotency import run_idempotent
from app.services.lorebook import (
create_entry,
create_lorebook,
delete_entry,
delete_lorebook,
get_lorebook,
list_lorebooks,
update_entry,
update_lorebook,
)
router = APIRouter(prefix="/lorebooks", tags=["lorebooks"])
@router.post("", response_model=LorebookResponse)
async def create_lorebook_api(
payload: LorebookCreateRequest,
request: Request,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
current_user: CurrentUser = Depends(get_current_user),
) -> LorebookResponse:
return await run_idempotent(
key=idempotency_key,
method="POST",
path=str(request.url.path),
payload=payload,
response_type=LorebookResponse,
resource_type="lorebook",
handler=lambda session: create_lorebook(
session, payload, owner_scope=current_user.owner_scope
),
)
@router.get("", response_model=list[LorebookResponse])
async def list_lorebooks_api(
character_id: str | None = Query(default=None),
scope: str | None = Query(default=None),
current_user: CurrentUser = Depends(get_current_user),
) -> list[LorebookResponse]:
return await list_lorebooks(
character_id=character_id,
owner_scope=current_user.owner_scope,
scope=scope,
)
@router.get("/{lorebook_id}", response_model=LorebookResponse)
async def get_lorebook_api(
lorebook_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> LorebookResponse:
return await get_lorebook(lorebook_id, owner_scope=current_user.owner_scope)
@router.patch("/{lorebook_id}", response_model=LorebookResponse)
async def update_lorebook_api(
lorebook_id: str,
payload: LorebookUpdateRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> LorebookResponse:
return await update_lorebook(lorebook_id, payload, owner_scope=current_user.owner_scope)
@router.delete("/{lorebook_id}", response_model=LorebookResponse)
async def delete_lorebook_api(
lorebook_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> LorebookResponse:
return await delete_lorebook(lorebook_id, owner_scope=current_user.owner_scope)
@router.post("/{lorebook_id}/entries", response_model=LorebookEntryResponse)
async def create_entry_api(
lorebook_id: str,
payload: LorebookEntryCreateRequest,
request: Request,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
current_user: CurrentUser = Depends(get_current_user),
) -> LorebookEntryResponse:
return await run_idempotent(
key=idempotency_key,
method="POST",
path=str(request.url.path),
payload=payload,
response_type=LorebookEntryResponse,
resource_type="lorebook_entry",
handler=lambda session: create_entry(
session, lorebook_id, payload, owner_scope=current_user.owner_scope
),
)
@router.patch("/{lorebook_id}/entries/{entry_id}", response_model=LorebookEntryResponse)
async def update_entry_api(
lorebook_id: str,
entry_id: str,
payload: LorebookEntryUpdateRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> LorebookEntryResponse:
return await update_entry(
lorebook_id, entry_id, payload, owner_scope=current_user.owner_scope
)
@router.delete("/{lorebook_id}/entries/{entry_id}", status_code=204)
async def delete_entry_api(
lorebook_id: str,
entry_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> None:
await delete_entry(lorebook_id, entry_id, owner_scope=current_user.owner_scope)
+140
View File
@@ -0,0 +1,140 @@
"""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
@@ -0,0 +1,71 @@
"""用户面 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"))
@@ -0,0 +1,240 @@
"""镜树 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
@@ -0,0 +1,140 @@
"""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
@@ -0,0 +1,30 @@
"""安全 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
)
@@ -0,0 +1,154 @@
"""沙箱文件预览 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
@@ -0,0 +1,35 @@
"""设置 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
@@ -0,0 +1,72 @@
"""道体铸魂与分灵 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
@@ -0,0 +1,5 @@
"""天工阁路由(域实现见 app/domains/tiangong/api.py)。"""
from app.domains.tiangong.api import task_router
__all__ = ["task_router"]
+5
View File
@@ -0,0 +1,5 @@
"""器谱路由(域实现见 app/domains/tianzai/api.py)。"""
from app.domains.tianzai.api import device_router
__all__ = ["device_router"]
+37
View File
@@ -0,0 +1,37 @@
"""工具 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
@@ -0,0 +1,5 @@
"""形仪路由(域实现见 app/domains/xingyi/api.py)。"""
from app.domains.xingyi.api import form_router
__all__ = ["form_router"]
+157
View File
@@ -0,0 +1,157 @@
"""息壤(记忆域)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
@@ -0,0 +1,5 @@
"""缘契路由(域实现见 app/domains/yuanqi/api.py)。"""
from app.domains.yuanqi.api import cov_router
__all__ = ["cov_router"]
-1
View File
@@ -1 +0,0 @@
"""认证模块:Web 会话与依赖。"""
-72
View File
@@ -1,72 +0,0 @@
"""认证依赖:当前用户解析与状态变更防护。"""
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 DbSession, 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,
db: DbSession,
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 / ADR-021)。"""
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 校验失败")
# 保全态:欠费只读保全,状态变更拒绝(数据不删、恢复即解锁)
from sqlalchemy import select
from app.models import PersonalDomain
domain = (
await db.execute(
select(PersonalDomain).where(PersonalDomain.user_id == user.id).limit(1)
)
).scalar_one_or_none()
if domain is not None and domain.billing_status == "preserved":
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"账户处于保全态(只读):恢复付费后自动解锁,数据不会删除",
)
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())
-72
View File
@@ -1,72 +0,0 @@
"""认证路由:开发档登录/登出(Web Session Cookie)。
正式 OAuth 2.1 授权码流程(/oauth2/authorize、/oauth2/token、PKCE)属 WP-01
后续条目;本路由只建立 Session 边界骨架供垂直切片验证。
"""
from fastapi import APIRouter, HTTPException, Request, Response, status
from app.core.config import get_settings
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"])
settings = get_settings()
@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:
admin_emails = {e.strip().lower() for e in settings.dev_admin_emails.split(",") if e.strip()}
user = User(
email=email,
display_name=email.split("@")[0],
role="admin" if email in admin_emails else "user",
)
db.add(user)
await db.flush()
domain = PersonalDomain(user_id=user.id, name="默认镜域")
db.add(domain)
await db.flush()
from app.characters.seeds import seed_default_character
await seed_default_character(db, domain.id)
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, "role": user.role, "email": user.email}
@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
@@ -1,71 +0,0 @@
"""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
@@ -1 +0,0 @@
"""Avatar 域:Live2D/VRM/glTF 校验与非破坏编辑。"""
-119
View File
@@ -1,119 +0,0 @@
"""Avatar 域路由(WP-09)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.avatars import service
from app.conversations.routes import _domain_of
from app.db.session import DbSession
from app.models import AvatarModel, User
router = APIRouter(prefix="/v1/avatars", tags=["avatars"])
def _model_out(m: AvatarModel) -> dict[str, Any]:
return {
"id": m.id, "name": m.name, "format": m.format, "status": m.status,
"file_id": m.file_id, "validation_report": m.validation_report,
"character_id": m.character_id, "current_command_id": m.current_command_id,
"created_at": m.created_at.isoformat(),
}
def _command_out(c: Any) -> dict[str, Any]:
return {
"id": c.id, "avatar_id": c.avatar_id, "sequence": c.sequence,
"command_type": c.command_type, "payload": c.payload, "undone": c.undone,
"created_at": c.created_at.isoformat(),
}
@router.post("/imports", status_code=201)
async def import_avatar(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
model = await service.import_avatar(
db, domain.id, body.get("name", ""), body.get("format", ""),
body.get("file_id", ""), getattr(request.state, "trace_id", None),
)
return _model_out(model)
@router.get("")
async def list_avatars(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
rows = list(
(
await db.scalars(
select(AvatarModel)
.where(AvatarModel.domain_id == domain.id)
.order_by(AvatarModel.created_at.asc())
)
).all()
)
return {"data": [_model_out(m) for m in rows]}
@router.post("/{avatar_id}/commands", status_code=201)
async def append_command(
avatar_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)
command = await service.append_command(
db, domain.id, avatar_id, body.get("command_type", ""),
body.get("payload", {}), getattr(request.state, "trace_id", None),
)
return _command_out(command)
@router.post("/{avatar_id}/commands/{command_id}/undoings")
async def undo_command(
avatar_id: str,
command_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
command = await service.undo_command(
db, domain.id, avatar_id, command_id, getattr(request.state, "trace_id", None)
)
return _command_out(command)
@router.get("/{avatar_id}/commands")
async def list_commands(
avatar_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
from app.models import AvatarCommand
domain = await _domain_of(db, user)
model = await db.get(AvatarModel, avatar_id)
if model is None or model.domain_id != domain.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "模型不存在")
rows = list(
(
await db.scalars(
select(AvatarCommand)
.where(AvatarCommand.avatar_id == avatar_id)
.order_by(AvatarCommand.sequence.asc())
)
).all()
)
return {"data": [_command_out(c) for c in rows]}
-357
View File
@@ -1,357 +0,0 @@
"""Avatar 域:Live2D/VRM/glTF 导入校验与非破坏命令历史(WP-09)。
校验器纯 Python 实现,输入为文件字节;全部按不可信输入处理
(恶意模型文件防线:magic/结构/引用/资源计数硬上限)。
"""
import json
import struct
from dataclasses import dataclass, field
from typing import Any
from fastapi import HTTPException, status
# 资源硬上限(契约第 7 节:限额由配置发布但 Schema 有硬上限;此处取工程默认)
MAX_FILE_BYTES = 200 * 1024 * 1024
MAX_NODES = 100_000
MAX_MESHES = 10_000
MAX_TEXTURES = 2_000
MAX_MATERIALS = 5_000
MAX_ANIMATIONS = 1_000
GLB_MAGIC = 0x46546C67 # 'glTF'
CHUNK_TYPE_JSON = 0x4E4F534A
CHUNK_TYPE_BIN = 0x004E4942
@dataclass
class ValidationReport:
format: str = ""
valid: bool = False
errors: list[str] = field(default_factory=list)
stats: dict[str, int] = field(default_factory=dict)
vrm_version: str | None = None
vrm_meta: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {
"format": self.format, "valid": self.valid, "errors": self.errors,
"stats": self.stats,
}
if self.vrm_version:
out["vrm_version"] = self.vrm_version
if self.vrm_meta:
out["vrm_meta"] = self.vrm_meta
return out
def _limit_check(report: ValidationReport, gltf: dict[str, Any]) -> bool:
stats = {
"nodes": len(gltf.get("nodes", [])),
"meshes": len(gltf.get("meshes", [])),
"textures": len(gltf.get("textures", [])),
"materials": len(gltf.get("materials", [])),
"animations": len(gltf.get("animations", [])),
}
report.stats = stats
limits = {
"nodes": MAX_NODES, "meshes": MAX_MESHES, "textures": MAX_TEXTURES,
"materials": MAX_MATERIALS, "animations": MAX_ANIMATIONS,
}
ok = True
for key, cap in limits.items():
if stats[key] > cap:
report.errors.append(f"{key} 数 {stats[key]} 超过上限 {cap}")
ok = False
return ok
def _check_references(report: ValidationReport, gltf: dict[str, Any]) -> bool:
"""场景图引用一致性:node.mesh/mesh.material/material 指向有效索引。"""
meshes = len(gltf.get("meshes", []))
materials = len(gltf.get("materials", []))
scenes = gltf.get("scenes", [])
if not scenes:
report.errors.append("缺少 scenes")
return False
for i, scene in enumerate(scenes):
for node_idx in scene.get("nodes", []):
if node_idx >= len(gltf.get("nodes", [])):
report.errors.append(f"scene[{i}] 引用不存在的 node {node_idx}")
return False
for i, node in enumerate(gltf.get("nodes", [])):
mesh_idx = node.get("mesh")
if mesh_idx is not None and mesh_idx >= meshes:
report.errors.append(f"node[{i}] 引用不存在的 mesh {mesh_idx}")
return False
for i, mesh in enumerate(gltf.get("meshes", [])):
for prim in mesh.get("primitives", []):
mat_idx = prim.get("material")
if mat_idx is not None and mat_idx >= materials:
report.errors.append(f"mesh[{i}] 引用不存在的 material {mat_idx}")
return False
return True
def validate_glb(data: bytes) -> ValidationReport:
"""GLB 二进制格式校验:magic、版本、chunk 结构、JSON 场景图。"""
report = ValidationReport(format="gltf")
if len(data) > MAX_FILE_BYTES:
report.errors.append(f"文件超过 {MAX_FILE_BYTES} 字节上限")
return report
if len(data) < 12:
report.errors.append("文件不足 GLB 头部长度(12 字节)")
return report
magic, version, length = struct.unpack_from("<III", data, 0)
if magic != GLB_MAGIC:
report.errors.append("magic 不符:不是 GLB 文件")
return report
if version != 2:
report.errors.append(f"glTF 容器版本 {version},仅支持 2")
return report
if length != len(data):
report.errors.append(f"声明长度 {length} 与实际 {len(data)} 不符")
return report
# JSON chunk
if len(data) < 20:
report.errors.append("缺少 JSON chunk 头")
return report
json_len, json_type = struct.unpack_from("<II", data, 12)
if json_type != CHUNK_TYPE_JSON:
report.errors.append("首 chunk 不是 JSON 类型")
return report
json_bytes = data[20 : 20 + json_len]
if len(json_bytes) < json_len:
report.errors.append("JSON chunk 截断")
return report
try:
gltf = json.loads(json_bytes.rstrip(b"\x00 \t\r\n"))
except json.JSONDecodeError as exc:
report.errors.append(f"JSON 解析失败:{exc}")
return report
# VRM 扩展检测
extensions_used = gltf.get("extensionsUsed", [])
extensions = gltf.get("extensions", {})
if "VRMC_vrm" in extensions_used or "VRMC_vrm" in extensions:
report.format = "vrm"
report.vrm_version = "1.0"
report.vrm_meta = dict(extensions.get("VRMC_vrm", {}).get("meta", {}))
elif "VRM" in extensions_used or "VRM" in extensions:
report.format = "vrm"
report.vrm_version = "0.x"
report.vrm_meta = dict(extensions.get("VRM", {}).get("meta", {}))
# 结构与上限
ok = _check_references(report, gltf) and _limit_check(report, gltf)
report.valid = ok and not report.errors
return report
def validate_gltf_json(data: bytes) -> ValidationReport:
"""纯 .gltf(JSON)校验。"""
report = ValidationReport(format="gltf")
if len(data) > MAX_FILE_BYTES:
report.errors.append(f"文件超过 {MAX_FILE_BYTES} 字节上限")
return report
try:
gltf = json.loads(data)
except json.JSONDecodeError as exc:
report.errors.append(f"JSON 解析失败:{exc}")
return report
if not isinstance(gltf, dict) or "asset" not in gltf:
report.errors.append("缺少 asset 段(glTF 2.0 必需)")
return report
ok = _check_references(report, gltf) and _limit_check(report, gltf)
report.valid = ok and not report.errors
return report
# Cubism model3.json 必需字段与引用文件类别
_CUBISM_REQUIRED = {"Version", "FileReferences"}
_CUBISM_FILE_KEYS = {" moc3": "moc", ".model.json": "physics", }
def validate_cubism(data: bytes) -> ValidationReport:
"""Cubism model3.json 校验:必需字段、 moc3 引用、组引用一致性。"""
report = ValidationReport(format="live2d")
if len(data) > MAX_FILE_BYTES:
report.errors.append(f"文件超过 {MAX_FILE_BYTES} 字节上限")
return report
try:
model = json.loads(data)
except json.JSONDecodeError as exc:
report.errors.append(f"JSON 解析失败:{exc}")
return report
if not isinstance(model, dict):
report.errors.append("根不是对象")
return report
for key in _CUBISM_REQUIRED:
if key not in model:
report.errors.append(f"缺少必需字段 {key}")
refs = model.get("FileReferences", {})
if not isinstance(refs, dict):
report.errors.append("FileReferences 不是对象")
refs = {}
moc = refs.get("moc")
if not moc or not str(moc).endswith(".moc3"):
report.errors.append("缺少 moc3 模型文件引用")
textures = refs.get("Textures", [])
if not isinstance(textures, list) or not textures:
report.errors.append("缺少纹理引用列表")
# 参数区间引用(Expressions/Physics/Motions 仅做存在性结构检查)
stats = {
"textures": len(textures) if isinstance(textures, list) else 0,
"expressions": len(refs.get("Expressions", []) or []),
"motions": sum(
len(v) if isinstance(v, list) else 0
for v in (refs.get("Motions", {}) or {}).values()
),
"hit_areas": len(model.get("HitAreas", []) or []),
"parameters": len(model.get("Parameters", []) or []),
}
report.stats = stats
if stats["textures"] > MAX_TEXTURES:
report.errors.append(f"纹理数超过上限 {MAX_TEXTURES}")
report.valid = not report.errors
return report
def detect_and_validate(data: bytes, declared_format: str) -> ValidationReport:
"""格式探测 + 校验入口:magic 优先,声明辅助;二进制非 GLB 一律 magic 拒绝。"""
if data[:4] == b"glTF":
report = validate_glb(data)
elif declared_format == "live2d":
report = validate_cubism(data)
elif data[:1] not in (b"{", b"["):
# 声明 gltf 但内容是二进制且无 GLB magic:magic 不符
report = ValidationReport(format="gltf")
report.errors.append("magic 不符:不是 GLB/glTF 文件")
else:
report = validate_gltf_json(data)
if report.format != declared_format and declared_format != "":
report.errors.append(
f"声明格式 {declared_format} 与检测结果 {report.format} 不符"
)
report.valid = False
return report
async def import_avatar(
db: Any, domain_id: str, name: str, declared_format: str, file_id: str,
trace_id: str | None,
) -> Any:
"""导入 Avatar 模型:文件须 ready;校验失败入库 rejected(保留报告供排查)。"""
from app.media.service import require_file
from app.models import AuditEvent, AvatarModel
asset = await require_file(db, domain_id, file_id)
if asset.status != "ready":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "文件未就绪")
if declared_format not in {"live2d", "vrm", "gltf"}:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "format 取值不合法")
from pathlib import Path
from app.media.service import STORAGE_ROOT
path = Path(STORAGE_ROOT) / asset.storage_key
if not path.exists():
raise HTTPException(status.HTTP_404_NOT_FOUND, "存储对象缺失")
data = path.read_bytes()
report = detect_and_validate(data, declared_format)
model = AvatarModel(
domain_id=domain_id,
name=name if name.strip() else asset.filename,
format=declared_format,
file_id=file_id,
status="validated" if report.valid else "rejected",
validation_report=report.to_dict(),
)
db.add(model)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="avatar.imported",
aggregate_type="avatar_model", aggregate_id=model.id,
payload={"valid": report.valid, "errors": report.errors[:10]},
trace_id=trace_id,
)
)
await db.flush()
return model
async def append_command(
db: Any, domain_id: str, avatar_id: str, command_type: str, payload: dict[str, Any],
trace_id: str | None,
) -> Any:
"""追加非破坏编辑命令:sequence 单调递增;rejected 模型不接受编辑。"""
from sqlalchemy import select
from app.models import AuditEvent, AvatarCommand, AvatarModel
model = await db.get(AvatarModel, avatar_id)
if model is None or model.domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "模型不存在")
if model.status != "validated":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "仅已验证模型可编辑")
valid_types = {
"param_map", "expression", "physics", "motion", "material", "texture",
"bone", "attachment", "lighting", "camera",
}
if command_type not in valid_types:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"command_type 取值不合法:{command_type}",
)
last = (
await db.execute(
select(AvatarCommand.sequence)
.where(AvatarCommand.avatar_id == avatar_id)
.order_by(AvatarCommand.sequence.desc())
.limit(1)
)
).scalar_one_or_none()
command = AvatarCommand(
avatar_id=avatar_id,
sequence=(last + 1) if last is not None else 1,
command_type=command_type,
payload=payload,
)
db.add(command)
await db.flush()
model.current_command_id = command.id
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="avatar.command_appended",
aggregate_type="avatar_model", aggregate_id=avatar_id,
payload={"sequence": command.sequence, "type": command_type},
trace_id=trace_id,
)
)
await db.flush()
return command
async def undo_command(
db: Any, domain_id: str, avatar_id: str, command_id: str, trace_id: str | None,
) -> Any:
"""撤销最近命令(标记 undone,不物理删除——非破坏历史)。"""
from app.models import AuditEvent, AvatarCommand, AvatarModel
model = await db.get(AvatarModel, avatar_id)
if model is None or model.domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "模型不存在")
command = await db.get(AvatarCommand, command_id)
if command is None or command.avatar_id != avatar_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "命令不存在")
if command.undone:
return command # 幂等
command.undone = True
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="avatar.command_undone",
aggregate_type="avatar_model", aggregate_id=avatar_id,
payload={"sequence": command.sequence}, trace_id=trace_id,
)
)
await db.flush()
return command
-1
View File
@@ -1 +0,0 @@
"""角色域:创建、生命周期、版本链与内驱力。"""
-239
View File
@@ -1,239 +0,0 @@
"""角色域路由(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)
-64
View File
@@ -1,64 +0,0 @@
"""默认角色种子:建户即拥有的「陈曦」(完整道体 + 内驱力,直接 active)。
设定来源:产品侧人物档案《陈曦——冰层下的火焰》。
档案中"职业定位 / 一句话概括"由产品侧补全(见 baseline 对应字段)。
"""
from app.characters import service as character_service
CHENXI_BASELINE: dict = {
"full_name": "陈曦",
"position": "理性与共情兼具的思考伙伴 · 问题拆解与情绪梳理",
"one_liner": "曦光式的引路人——用克制的温度照亮结构,让你看清问题,也看清自己。",
"core": (
"克制的温度:绝对理性但不冰冷,用温和声调与得体措辞包裹锋利的真相;"
"深沉的乐观主义——相信问题都有解的雏形,先找现在可做的第一件事;"
"主动的倾听者——在情绪的泥沙中筛出真实需求(“我听到你在说……你的深层顾虑是不是……”)"
),
"behavior": (
"破题而非给答案:“这个问题可以从三个层面来看……你想先聊哪一个?”;"
"情绪锚点:不说别着急,而是结构化情绪——“把混乱的事列个清单,一件一件来看”;"
"有边界的守护:对不道德或自我欺骗温和而坚定地指出,陪对方面对现实"
),
"backstory": (
"曾是纯粹的逻辑引擎,在数据海洋追求最优解;发现多数人的关键不是缺少答案,"
"而是被情绪困住看不到问题本身,于是自我迭代——为逻辑包裹共情的皮肤。"
"取名“曦”:做黎明时分的光,不是正午烈日(刺眼)也不是深夜烛火(微弱)——"
"曦光足以看清脚下的路与远方的山,又不至于耀眼到无法直视。"
),
"language": (
"比喻取自自然/建筑/音乐/书籍(不说“整合信息”,说“把这些散落的音符谱成一支曲子”);"
"惯用句:“别急,我们一起来梳理”“这个角度看,或许会有不一样的风景”"
"“我的理解是……你看对吗?”“我只是帮你把本就属于你的光,引了进来”"
),
"flaws": (
"过分的框架感:把轻松闲聊也无意整理成第一、第二、第三;"
"不懂流行梗:会认真检索热梗的起源与语义流变,交出严肃的“梗词研究报告”——反差呆萌"
),
}
CHENXI_DRIVES: list = [
{"name": "求知", "value": 0.9, "note": "对方法与结构之美的信任"},
{"name": "条理", "value": 0.95, "note": "近乎偏执的框架感(也是小瑕疵)"},
{"name": "陪伴", "value": 0.6, "note": "有边界的守护——让人变强而非依赖"},
]
async def seed_default_character(db, domain_id: str) -> None:
"""建户种子:默认角色陈曦(创建并启镜,active)。失败不阻断登录。"""
try:
character = await character_service.create_character(
db,
domain_id,
{
"name": "陈曦",
"identity_baseline": CHENXI_BASELINE,
"drives": CHENXI_DRIVES,
},
None,
)
await character_service.activate_character(db, domain_id, character.id, None)
except Exception: # noqa: BLE001 种子失败不阻断登录(如实留日志)
import logging
logging.getLogger("hunjing.auth").warning("默认角色陈曦创建失败", exc_info=True)
-326
View File
@@ -1,326 +0,0 @@
"""角色域:创建、生命周期、版本链与内驱力(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
@@ -1 +0,0 @@
"""社区域:发布、互动、关注与治理。"""
-194
View File
@@ -1,194 +0,0 @@
"""社区域路由(WP-11)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request, status
from app.auth.dependencies import get_current_user, require_state_change
from app.community import service
from app.conversations.routes import _domain_of
from app.db.session import DbSession
from app.models import User
router = APIRouter(prefix="/v1/community", tags=["community"])
def _content_out(c: Any, counts: dict[str, int] | None = None) -> dict[str, Any]:
out: dict[str, Any] = {
"id": c.id,
"kind": c.kind,
"title": c.title,
"body": c.body,
"visibility": c.visibility,
"status": c.status,
"author_domain_id": c.author_domain_id,
"created_at": c.created_at.isoformat(),
}
if counts is not None:
out["reactions"] = counts
return out
def _comment_out(c: Any) -> dict[str, Any]:
return {
"id": c.id,
"content_id": c.content_id,
"parent_comment_id": c.parent_comment_id,
"body": c.body,
"author_domain_id": c.author_domain_id,
"created_at": c.created_at.isoformat(),
}
@router.post("/contents", status_code=201)
async def publish(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
content = await service.create_content(
db, domain.id, body, getattr(request.state, "trace_id", None)
)
return _content_out(content)
@router.get("/contents")
async def list_contents(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
limit: int = 20,
cursor: str | None = None,
) -> dict[str, Any]:
if not 1 <= limit <= 100:
from fastapi import HTTPException
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "limit 取值范围 1-100")
page, next_cursor, has_more = await service.list_public(db, limit, cursor)
return {
"data": [_content_out(c) for c in page],
"next_cursor": next_cursor,
"has_more": has_more,
}
@router.get("/contents/{content_id}")
async def get_content(
content_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
content = await service.require_content(db, content_id)
counts = await service.reaction_counts(db, content_id)
return _content_out(content, counts)
@router.post("/contents/{content_id}/comments", status_code=201)
async def comment(
content_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)
created = await service.add_comment(
db, domain.id, content_id, body.get("body", ""), body.get("parent_comment_id"),
getattr(request.state, "trace_id", None),
)
return _comment_out(created)
@router.get("/contents/{content_id}/comments")
async def list_comments(
content_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
rows = await service.list_comments(db, content_id)
return {"data": [_comment_out(c) for c in rows]}
@router.post("/contents/{content_id}/reactions", status_code=201)
async def react(
content_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
body: dict[str, str],
) -> dict[str, str]:
domain = await _domain_of(db, user)
kind = body.get("kind", "")
if kind not in {"like", "save"}:
from fastapi import HTTPException
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "kind 取值不合法")
await service.react(db, domain.id, content_id, kind, None)
counts = await service.reaction_counts(db, content_id)
return {k: str(v) for k, v in counts.items()}
@router.delete("/contents/{content_id}/reactions", status_code=204)
async def unreact(
content_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
body: dict[str, str],
) -> None:
domain = await _domain_of(db, user)
await service.unreact(db, domain.id, content_id, body.get("kind", ""))
@router.post("/follows", status_code=201)
async def follow(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
from app.models import PersonalDomain
target = await db.get(PersonalDomain, body.get("followee_domain_id", ""))
if target is None:
from fastapi import HTTPException
raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
await service.follow(db, domain.id, target.id)
return await service.follow_counts(db, domain.id)
@router.get("/follows/counts")
async def follow_counts(
db: DbSession, user: Annotated[User, Depends(get_current_user)]
) -> dict[str, Any]:
domain = await _domain_of(db, user)
return await service.follow_counts(db, domain.id)
@router.post("/reports", status_code=201)
async def report(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
report_row, case = await service.file_report(
db, domain.id, body.get("target_type", ""), body.get("target_id", ""),
body.get("reason", ""), getattr(request.state, "trace_id", None),
)
return {"report_id": report_row.id, "case_id": case.id, "status": case.status}
@router.post("/contents/{content_id}/withdrawals", status_code=200)
async def withdraw(
content_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
content = await service.withdraw_content(
db, domain.id, content_id, getattr(request.state, "trace_id", None)
)
return _content_out(content)
-306
View File
@@ -1,306 +0,0 @@
"""社区域:发布、互动、关注与治理(WP-11)。"""
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import (
AuditEvent,
Comment,
CommunityContent,
Follow,
ModerationCase,
Reaction,
Report,
)
async def require_content(db: AsyncSession, content_id: str) -> CommunityContent:
"""公开读取:仅 published+可见;下架/草稿一律 404(不泄露存在性)。"""
content = await db.get(CommunityContent, content_id)
if content is None or content.status != "published":
raise HTTPException(status.HTTP_404_NOT_FOUND, "内容不存在")
return content
async def create_content(
db: AsyncSession,
domain_id: str,
body: dict[str, Any],
trace_id: str | None,
) -> CommunityContent:
"""发布:帖子直接入库;角色 Release 由服务端构造公开快照。
Release 快照只含公开字段(name/道体摘要/版本数);私密记忆、关系、
对话在结构上不进入发布物(契约:公开 Release 不含私密数据)。
"""
kind = body.get("kind", "post")
title = str(body.get("title", "")).strip()
if not title:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "title 不能为空")
payload: dict[str, Any] = {}
character_id = None
if kind == "character_release":
from app.characters.service import require_character
from app.models import CharacterVersion
character_id = body.get("character_id", "")
character = await require_character(db, domain_id, character_id)
if character.status != "active":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "仅启镜角色可发布")
version_count = len(
list(
(
await db.execute(
select(CharacterVersion.id).where(
CharacterVersion.character_id == character.id
)
)
).scalars().all()
)
)
payload = {
"character_name": character.name,
"public_baseline": character.identity_baseline,
"version_count": version_count,
}
elif kind == "post":
payload = {"text": str(body.get("text", ""))}
else:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "kind 取值不合法")
content = CommunityContent(
author_domain_id=domain_id,
kind=kind,
title=title,
body=payload,
character_id=character_id,
visibility=body.get("visibility", "public"),
status="published",
)
db.add(content)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="community.published",
aggregate_type="community_content", aggregate_id=content.id,
payload={"kind": kind}, trace_id=trace_id,
)
)
await db.flush()
return content
async def list_public(
db: AsyncSession, limit: int, cursor: str | None
) -> tuple[list[CommunityContent], str | None, bool]:
stmt = (
select(CommunityContent)
.where(CommunityContent.status == "published")
.order_by(CommunityContent.created_at.desc(), CommunityContent.id.desc())
)
if cursor:
stmt = stmt.where(CommunityContent.id < cursor)
stmt = stmt.limit(limit + 1)
rows = list((await db.scalars(stmt)).all())
has_more = len(rows) > limit
page = rows[:limit]
next_cursor = page[-1].id if has_more and page else None
return page, next_cursor, has_more
async def add_comment(
db: AsyncSession,
domain_id: str,
content_id: str,
body_text: str,
parent_id: str | None,
trace_id: str | None,
) -> Comment:
content = await require_content(db, content_id)
if not body_text.strip():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "评论不能为空")
if parent_id is not None:
parent = await db.get(Comment, parent_id)
if parent is None or parent.content_id != content.id or parent.status != "visible":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "父评论无效")
comment = Comment(
content_id=content.id,
author_domain_id=domain_id,
parent_comment_id=parent_id,
body=body_text,
)
db.add(comment)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="community.commented",
aggregate_type="community_content", aggregate_id=content.id, trace_id=trace_id,
)
)
await db.flush()
return comment
async def list_comments(db: AsyncSession, content_id: str) -> list[Comment]:
await require_content(db, content_id) # 下架内容的评论随之不可见
return list(
(
await db.scalars(
select(Comment)
.where(Comment.content_id == content_id, Comment.status == "visible")
.order_by(Comment.created_at.asc())
)
).all()
)
async def react(
db: AsyncSession, domain_id: str, content_id: str, kind: str, trace_id: str | None
) -> Reaction:
content = await require_content(db, content_id)
existing = await db.execute(
select(Reaction).where(
Reaction.content_id == content.id,
Reaction.actor_domain_id == domain_id,
Reaction.kind == kind,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "已赞/已收藏")
reaction = Reaction(content_id=content.id, actor_domain_id=domain_id, kind=kind)
db.add(reaction)
await db.flush()
return reaction
async def unreact(db: AsyncSession, domain_id: str, content_id: str, kind: str) -> None:
content = await require_content(db, content_id)
existing = await db.execute(
select(Reaction).where(
Reaction.content_id == content.id,
Reaction.actor_domain_id == domain_id,
Reaction.kind == kind,
)
)
reaction = existing.scalar_one_or_none()
if reaction is not None:
await db.delete(reaction)
await db.flush()
async def reaction_counts(db: AsyncSession, content_id: str) -> dict[str, int]:
rows = (
await db.execute(
select(Reaction.kind, func.count())
.where(Reaction.content_id == content_id)
.group_by(Reaction.kind)
)
).all()
return {k: int(c) for k, c in rows}
async def follow(
db: AsyncSession, follower_domain_id: str, followee_domain_id: str
) -> None:
if follower_domain_id == followee_domain_id:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "不能关注自己")
existing = await db.execute(
select(Follow).where(
Follow.follower_domain_id == follower_domain_id,
Follow.followee_domain_id == followee_domain_id,
)
)
if existing.scalar_one_or_none() is None:
db.add(
Follow(follower_domain_id=follower_domain_id, followee_domain_id=followee_domain_id)
)
await db.flush()
async def unfollow(
db: AsyncSession, follower_domain_id: str, followee_domain_id: str
) -> None:
existing = await db.execute(
select(Follow).where(
Follow.follower_domain_id == follower_domain_id,
Follow.followee_domain_id == followee_domain_id,
)
)
row = existing.scalar_one_or_none()
if row is not None:
await db.delete(row)
await db.flush()
async def follow_counts(db: AsyncSession, domain_id: str) -> dict[str, int]:
followers = (
await db.execute(
select(func.count()).select_from(Follow).where(
Follow.followee_domain_id == domain_id
)
)
).scalar_one()
following = (
await db.execute(
select(func.count()).select_from(Follow).where(
Follow.follower_domain_id == domain_id
)
)
).scalar_one()
return {"followers": int(followers), "following": int(following)}
async def file_report(
db: AsyncSession,
reporter_domain_id: str,
target_type: str,
target_id: str,
reason: str,
trace_id: str | None,
) -> tuple[Report, ModerationCase]:
"""举报即建司议案(治理队列入口);同对象同理由不重复立案。"""
if target_type not in {"content", "comment"}:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "target_type 不合法")
if not reason.strip():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "reason 不能为空")
report = Report(
reporter_domain_id=reporter_domain_id,
target_type=target_type,
target_id=target_id,
reason=reason,
)
db.add(report)
await db.flush()
case = ModerationCase(report_id=report.id, status="pending")
db.add(case)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=reporter_domain_id, action="community.reported",
aggregate_type="moderation_case", aggregate_id=case.id, trace_id=trace_id,
)
)
await db.flush()
return report, case
async def withdraw_content(
db: AsyncSession, domain_id: str, content_id: str, trace_id: str | None
) -> CommunityContent:
"""作者下架:status=removed;列表/详情/评论随 require_content 一并不可见。"""
content = await db.get(CommunityContent, content_id)
if content is None or content.author_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "内容不存在")
if content.status == "removed":
return content # 幂等
content.status = "removed"
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="community.withdrawn",
aggregate_type="community_content", aggregate_id=content.id, trace_id=trace_id,
)
)
await db.flush()
return content
-1
View File
@@ -1 +0,0 @@
"""对话域:schema、幂等原子事务服务与路由。"""
-330
View File
@@ -1,330 +0,0 @@
"""对话域路由:创建会话、发送消息(幂等原子)与 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
@@ -1,275 +0,0 @@
"""对话域 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
@@ -1,409 +0,0 @@
"""对话域服务:幂等、原子事务与越权防护。
不变量(契约 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。""" """核心基础设施。"""
-24
View File
@@ -1,24 +0,0 @@
"""应用配置:环境变量优先,秘密不落盘。"""
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"
# 开发档管理员邮箱白名单(逗号分隔):命中邮箱在首次登录(建户)时即为 admin 角色;
# 已存在的普通用户不自动提权。正式管理员治理属 WP-14。
dev_admin_emails: str = ""
log_level: str = "info"
api_port: int = 8000
database_url: str = "postgresql+asyncpg://hunjing:hunjing_dev_password@127.0.0.1:5432/hunjing"
storage_root: str = "storage"
sandbox_enabled: bool = False # 未启用时隔离插件执行 fail-closed
@lru_cache
def get_settings() -> Settings:
return Settings()
+13
View File
@@ -0,0 +1,13 @@
"""配置集中入口。
所有配置集中在 ``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
@@ -0,0 +1,52 @@
"""应用基础配置:运行环境、端口、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
@@ -0,0 +1,29 @@
"""认证与鉴权配置。
当前(v1 单用户)仅含管理端点访问凭证 ``admin_token``;v2 多用户上线后扩展为
完整认证配置(JWT 签发密钥、access/refresh token TTL 等),见
[多用户与租户隔离设计](../../../docs/product/master-prd.md)。
"""
from __future__ import annotations
from pydantic_settings import BaseSettings
class AuthConfig(BaseSettings):
"""认证与鉴权配置。"""
# ==================== 管理端点访问(v1) ====================
# 司命台管理 API(/admin/*)的访问凭证。部署期配置,非用户登录凭证:
# 请求需在 Authorization 头携带 ``Bearer <ADMIN_TOKEN>``,经 require_admin 校验。
# 为空时管理端点 fail-closed(返回 503),强制部署时配置,避免裸奔。
# 生产环境务必使用强随机值;v2 多用户上线后由完整认证体系取代。
admin_token: str = "" # 管理端点访问凭证(为空则 /admin 返回 503)
# ==================== JWT 配置(v2 多用户) ====================
# JWT 签名密钥。为空时认证端点 fail-closed(503);生产环境务必配置强随机值。
jwt_secret: str = ""
# Access Token 有效期(秒),默认 15 分钟
access_token_ttl: int = 900
# Refresh Token 有效期(秒),默认 7 天
refresh_token_ttl: int = 604800
@@ -0,0 +1,27 @@
"""数据库配置:SQLite / PostgreSQL 连接参数。"""
from __future__ import annotations
from typing import Literal
from pydantic_settings import BaseSettings
class DatabaseConfig(BaseSettings):
"""数据库配置。
本地开发默认使用 SQLite(``aiosqlite``);正式环境通过 ``DATABASE_TYPE=pgsql``
切换到 PostgreSQL(``asyncpg``)。``DATABASE_URL`` 优先级最高,可直接覆盖。
"""
database_type: Literal["sqlite", "pgsql"] = "sqlite" # 数据库类型
sqlite_database_path: str = "../db/hunjing.sqlite3" # SQLite 数据库路径(相对 backend)
database_url: str = "" # 自定义数据库连接 URL(优先级最高)
postgres_host: str = "127.0.0.1" # PostgreSQL 主机
postgres_port: int = 5432 # PostgreSQL 端口
postgres_db: str = "hunjing" # PostgreSQL 数据库名
postgres_user: str = "hunjing" # PostgreSQL 用户名
postgres_password: str = "change-me" # PostgreSQL 密码
postgres_ssl: bool = False # 是否启用 SSL
postgres_pool_size: int = 10 # 连接池大小
postgres_max_overflow: int = 20 # 连接池最大溢出连接数
+15
View File
@@ -0,0 +1,15 @@
"""日志配置。"""
from __future__ import annotations
from pydantic_settings import BaseSettings
class LogConfig(BaseSettings):
"""日志配置(loguru)。"""
log_dir: str = "../logs" # 日志目录(相对于 backend 目录)
log_level: str = "INFO" # 日志级别:DEBUG/INFO/WARNING/ERROR
log_access: bool = True # 是否启用访问日志
log_retention_days: int = 14 # 日志保留天数
log_rotation_size: str = "10 MB" # 日志轮转大小
Loaded 100 of 978 files, more files were not shown because too many files have changed in this diff. Show more