Compare commits

..
23 Commits
Author SHA1 Message Date
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
353 changed files with 15618 additions and 26169 deletions

No files matched your search

+30
View File
@@ -665,3 +665,33 @@ SAFETY_POLICY_PATH=config/safety.yaml
# BACKEND_URL=http://localhost:8000 (本地开发,跨公网须 https://) # BACKEND_URL=http://localhost:8000 (本地开发,跨公网须 https://)
# Docker 部署由 docker-compose.app.yml 写死为 http://hunjing-backend:8000 # Docker 部署由 docker-compose.app.yml 写死为 http://hunjing-backend:8000
# 浏览器所有请求同源走 BFF(/api/proxy、/api/sse、/api/auth),客户端不需要后端地址。 # 浏览器所有请求同源走 BFF(/api/proxy、/api/sse、/api/auth),客户端不需要后端地址。
# =====================================================================
# 神识 · 模型网关(Go,rewrite/1.0 M0) — 灵台唯一模型出口
# =====================================================================
# 神识服务地址;为空时灵台 Kernel 使用进程内回声桩(仅骨架验证,不出真实模型流)。
# 本地开发:SHENSHI_BASE_URL=http://127.0.0.1:8080(先 shenshi/ 目录 `go run .`)
# Docker 部署由 docker-compose.app.yml 写死为 http://hunjing-shenshi:8080
SHENSHI_BASE_URL=
# 灵台 → 神识服务令牌(两边一致);为空时神识开发档放行不校验
SHENSHI_SERVICE_TOKEN=
# 神识流式调用超时(秒)
SHENSHI_TIMEOUT_SECONDS=120.0
# 神识服务监听端口(shenshi 服务自身)
SHENSHI_PORT=8080
# 神识出站端点目录(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
+112 -163
View File
@@ -49,211 +49,150 @@ pi :<https://github.com/earendil-works/pi>
向量库:<https://github.com/oceanbase/seekdb> 向量库:<https://github.com/oceanbase/seekdb>
live-2d: <https://github.com/stevenjoezhang/live2d-widget> live-2d: <https://github.com/stevenjoezhang/live2d-widget>
上下文压缩:<https://github.com/headroomlabs-ai/headroom> 上下文压缩:<https://github.com/headroomlabs-ai/headroom>
记忆:<https://github.com/AGI-is-going-to-arrive/Memory-Palace>,<https://github.com/Dataojitori/nocturne_memory>,<https://github.com/TencentCloud/TencentDB-Agent-Memory>,<https://github.com/riisovo/memory-trigger>,<https://github.com/MemTensor/MemOS> 记忆:<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> 成熟的相关的项目:<https://github.com/momori777/Artemis>,<https://github.com/RTGS2017/NagaAgent>,<https://github.com/AlfreScarlet/MoeChat>,<https://github.com/heshengtao/super-agent-party>,<https://github.com/MewCo-AI/mewco_ai_assistant_comm>,<https://github.com/weidu12123/Liyuan>
deepseek-herness:<https://github.com/deepseek-ai/deepseek-harness> deepseek-herness:<https://github.com/deepseek-ai/deepseek-harness>
模型网关设计:<https://github.com/xgpxg/aiway>,<https://github.com/bestruirui/octopus>,<https://github.com/QuantumNous/new-api>,<https://github.com/cita-777/metapi>,<https://github.com/looplj/axonhub>,<https://github.com/dyedd/lens> 模型网关设计:<https://github.com/xgpxg/aiway>,<https://github.com/bestruirui/octopus>,<https://github.com/QuantumNous/new-api>,<https://github.com/cita-777/metapi>,<https://github.com/looplj/axonhubs>,<https://github.com/dyedd/lens>
## 项目定位 ## 完整技术栈
本项目是一个面向长期运行与持续演化的 Agent 平台,核心目标是提供统一的 Agent Runtime、认知能力、工具执行、记忆、RAG、MCP、桌面端与基础设施。 > 魂镜服务映射(文化名登记见 `docs/contracts/contract-glossary.md` §2):前端=琉璃镜(Next.js);BFF=照壁(Fastify);后端核心=灵台(Python FastAPI,六域业务+插件化运行时);高并发 I/O 层=神识(Go 模型网关)+博闻(Go MCP 搜索);桌面端=镜匣(Tauri 最小壳);乾坤(Rust 沙箱)、天目(OCR)、元炁(本地推理)为独立辅助服务。详细架构见 `docs/architecture/`。
核心认知闭环: ---
```mermaid ### 前端(TypeScript 主力)
flowchart LR
A["感知"] --> B["上下文"]
B --> C["记忆"]
C --> D["理解"]
D --> E["规划与推理"]
E --> F["决策"]
F --> G["工具 / 行动"]
G --> H["环境反馈"]
H --> I["反思"]
I --> C
```
## 必读文档 **定位**:浏览器端 UI、状态管理、实时交互
**技术选型**:
Next.js 16 + React 19 + TypeScript + Tailwind CSS v4 + Zustand/Jotai + shadcn/ui + pnpm
开始涉及架构修改前: #### 核心依赖
1. `docs/architecture/overview.md` - **实时通信**:SSE(主,接收流式文本/事件)/ WebSocket(双向协同场景)
2. `docs/agent/overview.md` - **数据请求**:TanStack Query(服务端状态管理与缓存)+ openapi-fetch(类型安全请求,类型由后端 OpenAPI 生成)
3. `docs/decisions/README.md` - **可视化**:Mermaid(图表/流程图渲染)+ React Flow(流程编排与交互)/ Cytoscape.js(知识图谱交互)
- **Markdown 渲染**:react-markdown + remark/rehype 插件体系 + Shiki(代码高亮,支持流式增量解析)
- **长列表虚拟滚动**:@tanstack/react-virtual(大量数据高性能渲染)
- **表单校验**:React Hook Form + Zod(类型安全的表单验证)
- **测试**:Vitest(单元测试)+ Testing Library(组件测试)+ Playwright(E2E 测试)
- **代码质量**:ESLint(flat config 格式)+ Prettier + Husky + lint-staged + commitlint
涉及具体领域时,必须优先阅读对应领域文档。 ---
## 核心工程原则 ### BFF(TypeScript + Node.js)
- 能力与基础设施解耦。 **定位**:前端聚合层、鉴权透传、响应裁剪、SSE/WebSocket 代理、类型安全桥接
- Agent Runtime 不直接绑定具体数据库、向量库、图数据库或对象存储。 **技术选型**:
- 外部能力通过 Tool Registry / MCP 接入。 Node.js 22 LTS + NestJS / Fastify + TypeScript + Zod + openapi-fetch
- 长期数据进入 PostgreSQL;Redis 不作为核心业务数据最终持久化层。
- VectorStore、GraphStore、FileStorage 必须通过抽象接口访问。
- API 以 OpenAPI 3.1 为契约。
- 重要架构选择必须通过 ADR 记录。
- 不为了技术复杂度而提前引入分布式基础设施。
## Repository 结构 #### 核心依赖
- `apps/`:客户端与服务应用。 - **HTTP 框架**:Fastify(高性能)或 NestJS(模块化结构化)
- `packages/`:共享库与 SDK。 - **聚合/代理**:http-proxy / undici(请求转发与合并)
- `services/`:独立基础服务。 - **鉴权**:JWT + cookie 透传
- `docs/`:架构、设计、规范与 ADR。 - **日志**:pino(高性能结构化日志)
- `tests/`:跨模块测试与评估。 - **配置管理**:dotenv / convict(环境配置校验)
- `scripts/`:开发和运维脚本。 - **测试**:Vitest + supertest(API 集成测试)
- **代码质量**:ESLint + Prettier + Husky
## Agent Runtime 规则 ---
Agent Runtime 是系统核心。 ### 后端核心(Python + uv)
所有 Agent Run 应具有稳定的 `run_id`,并可追踪: **定位**:业务主力、AI Agent 编排、RAG 检索
**技术选型**:
Python 3.12+(uv 包管理)+ FastAPI + SQLAlchemy 2.0 + Alembic + Pydantic v2 + httpx + loguru + uvicorn
`Run -> Plan -> Model Call -> Memory -> Tool Call -> Observation -> Reflection -> Result` #### 核心依赖
Agent 不应把业务逻辑隐含在 Prompt 中;稳定规则、权限和状态应由代码或配置表达。 - **任务队列**: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
## Memory 规则 ---
采用六类逻辑记忆: ### 桌面端(Rust + Tauri)
- Working Memory **定位**:桌面客户端外壳、本地文件索引、沙箱执行、本地向量检索、离线能力
- Episodic Memory **技术选型**:
- Semantic Memory Rust(cargo 管理)+ Tauri v2 + tokio + serde + specta(TypeScript 类型导出)
- Procedural Memory
- Social Memory
- Identity Memory
统一通过 `Memory Manager` 访问。 #### 核心依赖
业务代码不得直接依赖 Redis、VectorStore 等具体实现。 - **本地检索**:tantivy(全文检索)
- **沙箱执行**:Tauri 进程隔离 + 权限模型(capabilities 映射为 Agent 工具授权)
- **打包分发**:tauri-plugin-updater(自动更新)+ tauri-plugin-store(本地配置存储)
- **前端通信**:Tauri commands(同步/异步调用)+ events(双向事件)+ specta(类型安全)
- **代码质量**:clippy + rustfmt
详细设计:`docs/agent/memory.md` ---
## Planning / Reasoning 规则 ### 高并发 I/O 层(Go)
规划与推理采用多模式架构: **定位**:网页抓取集群、API 网关与限流、长连接转发、定时调度、轻量级数据聚合
**技术选型**:
Go 1.25+(go mod 管理)+ Gin / Echo + colly + goquery + zap + viper
- ReAct #### 核心依赖
- Plan-and-Execute
- Reflection
- Self-Correction
- Dynamic Replanning
详细设计:`docs/agent/planning.md`、`docs/agent/reasoning.md`。 - **并发控制**:ants(协程池)+ golang.org/x/time/rate(限流器)
- **无头浏览器**:chromedp / rod(动态页面渲染抓取)
- **配置与服务发现**:viper(配置管理)+ etcd(服务注册与发现,可选)
- **可观测性**:OpenTelemetry-Go + Prometheus client
- **测试**:testing + testify + httptest
- **代码质量**:golangci-lint + gofumpt
## Tool 规则 ---
所有 Agent Tool 必须: ### 基础设施
- 注册到 Tool Registry。 #### 数据库
- 使用结构化参数 Schema。
- 经过权限检查。
- 具有超时和错误处理。
- 记录调用审计。
- 返回统一结果结构。
详细设计:`docs/tools/overview.md` - **关系数据库**:PostgreSQL 16(主库,支持 pgvector 扩展)
- **缓存/消息队列**:Redis 7+(Session / 任务队列 / 限流计数器 / 短期记忆存储)
## MCP 规则 #### 向量数据库
MCP 是外部工具和上下文扩展协议。 - **开发/测试**:ChromaDB(轻量快速)
- **生产过渡**:PostgreSQL + pgvector(减少运维复杂度)
- **大规模生产**:Milvus(分布式向量检索)
MCP Tool 必须经过内部 Tool Adapter 和 Permission Layer,不得绕过统一权限体系。 #### 图数据库
详细设计:`docs/mcp/overview.md` - **开发/测试**:Neo4j(社区版,易于上手)
- **大规模生产**:NebulaGraph(分布式图数据库,高性能)
## API 规则 #### 文件/对象存储
- API 使用 OpenAPI 3.1。 - **开发环境**:本地文件系统
- FastAPI 负责生成服务端 Schema。 - **自建生产**:MinIO(兼容 S3 协议)
- 前端优先使用类型安全客户端。 - **云生产**:AWS S3 / 阿里云 OSS / 腾讯云 COS
- SSE 用于 Agent 流式事件。
- WebSocket 只用于确有双向实时通信需求的场景。
- API 错误使用统一错误模型。
详细设计:`docs/api/overview.md` #### 容器
## 数据访问规则 - **容器化**:Docker + Docker Compose(本地开发环境一键编排)
- PostgreSQL:核心业务与长期结构化数据。 #### API 契约
- Redis:缓存、短期状态、运行态数据。
- VectorStore:向量检索。
- GraphStore:关系与知识图谱。
- FileStorage:对象与文件。
- 所有可替换基础设施通过接口隔离。
## 安全规则 - **规范**:OpenAPI 3.1(单一真源)
- **客户端生成**:openapi-generator(生成 TypeScript / Go 客户端)
安全采用纵深防御:
`Authentication -> RBAC -> Tool Permission -> Sandbox -> Audit`
Tauri Capabilities 与服务端 RBAC 属于不同安全边界,不得混为一谈。
必须考虑:
- Prompt Injection
- Secret Leakage
- Sensitive Data
- Tool Abuse
- Sandbox Escape
- Auditability
## 测试规则
新增能力至少考虑:
- Unit Test
- Integration Test
- API Test
- Tool Test
- Agent Workflow Test
RAG / Agent 的关键能力必须增加 Evaluation,而不能只依赖普通单元测试。
## 常用技术约束
### Python
Python 3.12+,使用 `uv` 管理依赖;Ruff + mypy。
### TypeScript
Next.js + React + TypeScript;pnpm;ESLint + Prettier。
### Rust
Tauri v2 + Tokio;rustfmt + clippy。
### Go
Go 1.25+;gofumpt + golangci-lint。
## 文档规则
架构修改必须同步修改相关 `docs/`。
改变技术选择或重要架构决策时创建 ADR。
不要把完整设计复制到 `AGENTS.md`;`AGENTS.md` 只保留必须影响 Agent 行为的规则和导航。
## 文档导航
- 总体架构:`docs/architecture/overview.md`
- Agent:`docs/agent/overview.md`
- Memory:`docs/agent/memory.md`
- Planning:`docs/agent/planning.md`
- Reasoning:`docs/agent/reasoning.md`
- RAG:`docs/rag/overview.md`
- Tools:`docs/tools/overview.md`
- MCP:`docs/mcp/overview.md`
- API:`docs/api/overview.md`
- 数据:`docs/data/database.md`
- 安全:`docs/security/overview.md`
- 基础设施:`docs/infrastructure/development.md`
- 测试:`docs/testing/strategy.md`
- ADR:`docs/decisions/README.md`
## 文档设计 ## 文档设计
@@ -267,6 +206,7 @@ Go 1.25+;gofumpt + golangci-lint。
Docker 文件放在根目录 `docker/` 下。 Docker 文件放在根目录 `docker/` 下。
容器名称以 `hunjing` 开头。 容器名称以 `hunjing` 开头。
Compose 文件不使用顶部 `version` 字段。 Compose 文件不使用顶部 `version` 字段。
本地开发默认使用 SQLite 文件数据库,正式环境使用 PostgreSQL,并按需启用 Milvus、Neo4j / NebulaGraph、Redis 和对象存储。
## 项目目录 ## 项目目录
@@ -286,7 +226,15 @@ Compose 文件不使用顶部 `version` 字段。
### 本地开发(Makefile 统一入口) ### 本地开发(Makefile 统一入口)
所有本地服务的启动、测试与数据库迁移统一经根目录 Makefile(`make help` 查看全部) 所有本地服务的启动、测试与数据库迁移统一经根目录 Makefile(`make help` 查看全部):
```bash
make dev-core # 核心三服务并行:灵台 + 神识 + 照壁
make dev-backend # 灵台(另有 dev-shenshi / dev-bff / dev-frontend / dev-sandbox / dev-tianmu / dev-yuanqi)
make db-upgrade # Alembic 升级到 head(迁移编号 000/001/002…)
make db-revision m="说明" # 生成下一号迁移(自动编号 + autogenerate,禁手写哈希 id)
make test # 全部测试(backend / shenshi / bff / frontend)
```
### Docker ### Docker
@@ -296,6 +244,7 @@ Compose 文件不使用顶部 `version` 字段。
```bash ```bash
# 先准备配置(只需一次) # 先准备配置(只需一次)
cp .env.example .env # 填写 POSTGRES_PASSWORD 与各 *_API_KEY cp .env.example .env # 填写 POSTGRES_PASSWORD 与各 *_API_KEY
cp config/models.example.yaml config/models.yaml # 按需填写真实模型 endpoint
# 1) 启动基础设施(首次创建 hunjing-net 网络;Milvus 会自动带起 hunjing-etcd / hunjing-minio) # 1) 启动基础设施(首次创建 hunjing-net 网络;Milvus 会自动带起 hunjing-etcd / hunjing-minio)
docker compose -f docker/docker-compose.infra.yml up -d docker compose -f docker/docker-compose.infra.yml up -d
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+131 -39
View File
@@ -1,30 +1,142 @@
# Makefile for cleaning Python cache files and empty directories # ==============================
# Author: AI Assistant # 魂镜 · 统一开发入口(rewrite/1.0)
# Usage: make clean or make clean-all # 所有本地服务的启动 / 测试 / 数据库迁移统一经本文件;
# Docker 编排(基础设施与应用容器)见 AGENTS.md「开发命令」与 docker/README.md。
# ==============================
.PHONY: clean clean-all clean-pyc clean-pytest clean-mypy clean-empty-dirs help
# Colors for pretty output
GREEN := \033[0;32m GREEN := \033[0;32m
YELLOW := \033[0;33m YELLOW := \033[0;33m
RED := \033[0;31m NC := \033[0m
NC := \033[0m # No Color
# Default target .PHONY: help \
dev-backend dev-shenshi dev-bff dev-frontend dev-sandbox dev-tianmu dev-yuanqi dev-core dev-all \
db-upgrade db-downgrade db-revision db-current \
test test-backend test-shenshi test-bff test-frontend \
vet-shenshi typecheck-bff \
infra-up app-up app-down \
clean clean-all clean-pyc clean-pytest clean-mypy clean-empty-dirs clean-dry-run
# 默认目标
help: help:
@echo "$(GREEN)Available targets:$(NC)" @echo "$(GREEN)魂镜统一开发入口$(NC)"
@echo " $(YELLOW)make clean$(NC) - Remove Python cache files, pytest cache, mypy cache, and empty directories" @echo ""
@echo " $(YELLOW)make clean-pyc$(NC) - Remove Python cache files (__pycache__, .pyc, .pyo)" @echo "$(YELLOW)服务启动(开发)$(NC)"
@echo " $(YELLOW)make clean-pytest$(NC) - Remove pytest cache (.pytest_cache)" @echo " make dev-backend 灵台(uvicorn 热重载,:8000)"
@echo " $(YELLOW)make clean-mypy$(NC) - Remove mypy cache (.mypy_cache)" @echo " make dev-shenshi 神识(go run,:8080)"
@echo " $(YELLOW)make clean-empty-dirs$(NC) - Remove empty directories" @echo " make dev-bff 照壁(tsx watch,:3000)"
@echo " $(YELLOW)make clean-all$(NC) - Remove everything (cache, logs, coverage, build, dist, etc.)" @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"
# ============================== 服务启动(开发) ==============================
dev-backend:
cd backend && uv run python -m app.main
dev-shenshi:
cd shenshi && go run .
dev-bff:
cd bff && npm run dev
dev-frontend:
cd frontend && pnpm dev
dev-sandbox:
cd sandbox && cargo run
dev-tianmu:
cd tianmu && uv run python -m src.main
dev-yuanqi:
cd yuanqi && uv run python -m src.main
# 核心三服务并行:灵台 + 神识 + 照壁(M0 验收链路)
dev-core:
$(MAKE) -j3 dev-backend dev-shenshi dev-bff
dev-all:
$(MAKE) -j4 dev-backend dev-shenshi dev-bff dev-frontend
# ============================== 数据库(Alembic) ==============================
# 迁移约定:revision id 与文件名前缀用三位顺序号(000、001、002…),生成统一走
# `make db-revision`(自动取最大号+1 并传 --rev-id);不要手写哈希 id。
db-upgrade:
cd backend && uv run alembic upgrade head
db-downgrade:
cd backend && uv run alembic downgrade -1
db-revision:
@next=$$(ls backend/alembic/versions 2>/dev/null | grep -E '^[0-9][0-9][0-9]_.*\.py$$' | cut -c1-3 | sort -n | tail -1); \
if [ -z "$$next" ]; then echo "未找到顺序编号迁移,请先确认基线 000 存在"; exit 1; fi; \
next=$$(printf '%03d' $$((10#$$next + 1))); \
echo "$(GREEN)生成迁移 $(NC)$$next:$(m)"; \
cd backend && uv run alembic revision --autogenerate --rev-id $$next -m "$(m)"
db-current:
cd backend && uv run alembic current
# ============================== 测试与检查 ==============================
test-backend:
cd backend && uv run pytest -q
test-shenshi:
cd shenshi && go test ./...
test-bff:
cd bff && npm test --silent
test-frontend:
cd frontend && pnpm test
test: test-backend test-shenshi test-bff test-frontend
vet-shenshi:
cd shenshi && go vet ./...
typecheck-bff:
cd bff && npm run type-check --silent
# ============================== Docker 编排快捷方式 ==============================
infra-up:
docker compose -f docker/docker-compose.infra.yml up -d
app-up:
docker compose -f docker/docker-compose.app.yml up -d --build
app-down:
docker compose -f docker/docker-compose.app.yml down
# ============================== 清理(保留原有能力) ==============================
# Main clean target
clean: clean-pyc clean-pytest clean-mypy clean-empty-dirs clean: clean-pyc clean-pytest clean-mypy clean-empty-dirs
@echo "$(GREEN)✓ Cleanup completed successfully!$(NC)" @echo "$(GREEN)✓ Cleanup completed successfully!$(NC)"
# Clean Python cache files
clean-pyc: clean-pyc:
@echo "$(YELLOW)Removing Python cache files...$(NC)" @echo "$(YELLOW)Removing Python cache files...$(NC)"
@find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
@@ -33,55 +145,35 @@ clean-pyc:
@find . -type f -name "*.pyd" -delete 2>/dev/null || true @find . -type f -name "*.pyd" -delete 2>/dev/null || true
@echo "$(GREEN)✓ Python cache files removed$(NC)" @echo "$(GREEN)✓ Python cache files removed$(NC)"
# Clean pytest cache
clean-pytest: clean-pytest:
@echo "$(YELLOW)Removing pytest cache...$(NC)" @echo "$(YELLOW)Removing pytest cache...$(NC)"
@rm -rf .pytest_cache 2>/dev/null || true @rm -rf .pytest_cache 2>/dev/null || true
@find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true @find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
@echo "$(GREEN)✓ Pytest cache removed$(NC)" @echo "$(GREEN)✓ Pytest cache removed$(NC)"
# Clean mypy cache
clean-mypy: clean-mypy:
@echo "$(YELLOW)Removing mypy cache...$(NC)" @echo "$(YELLOW)Removing mypy cache...$(NC)"
@rm -rf .mypy_cache 2>/dev/null || true @rm -rf .mypy_cache 2>/dev/null || true
@find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true @find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
@echo "$(GREEN)✓ Mypy cache removed$(NC)" @echo "$(GREEN)✓ Mypy cache removed$(NC)"
# Remove empty directories
clean-empty-dirs: clean-empty-dirs:
@echo "$(YELLOW)Removing empty directories...$(NC)" @echo "$(YELLOW)Removing empty directories...$(NC)"
@# Remove empty directories (including hidden ones), excluding .git and .venv
@find . -type d -empty -not -path "./.git/*" -not -path "./.venv/*" -not -path "./venv/*" -not -path "./env/*" -not -path "./.env/*" -delete 2>/dev/null || true @find . -type d -empty -not -path "./.git/*" -not -path "./.venv/*" -not -path "./venv/*" -not -path "./env/*" -not -path "./.env/*" -delete 2>/dev/null || true
@# Second pass to remove newly created empty parent directories
@find . -type d -empty -not -path "./.git/*" -not -path "./.venv/*" -not -path "./venv/*" -not -path "./env/*" -not -path "./.env/*" -delete 2>/dev/null || true @find . -type d -empty -not -path "./.git/*" -not -path "./.venv/*" -not -path "./venv/*" -not -path "./env/*" -not -path "./.env/*" -delete 2>/dev/null || true
@echo "$(GREEN)✓ Empty directories removed$(NC)" @echo "$(GREEN)✓ Empty directories removed$(NC)"
# Clean everything (extended cleanup)
clean-all: clean clean-all: clean
@echo "$(YELLOW)Removing additional files...$(NC)" @echo "$(YELLOW)Removing additional files...$(NC)"
@# Coverage reports
@rm -rf htmlcov .coverage coverage.xml .coverage.* 2>/dev/null || true @rm -rf htmlcov .coverage coverage.xml .coverage.* 2>/dev/null || true
@# Build artifacts
@rm -rf build/ dist/ *.egg-info .eggs/ 2>/dev/null || true @rm -rf build/ dist/ *.egg-info .eggs/ 2>/dev/null || true
@# Log files
@find . -type f -name "*.log" -delete 2>/dev/null || true @find . -type f -name "*.log" -delete 2>/dev/null || true
@# Jupyter notebooks checkpoint
@find . -type d -name ".ipynb_checkpoints" -exec rm -rf {} + 2>/dev/null || true @find . -type d -name ".ipynb_checkpoints" -exec rm -rf {} + 2>/dev/null || true
@# IDE files (optional, uncomment if needed)
# @rm -rf .vscode/ .idea/ 2>/dev/null || true
@echo "$(GREEN)✓ Extended cleanup completed!$(NC)" @echo "$(GREEN)✓ Extended cleanup completed!$(NC)"
# Dry run - show what would be deleted (for safety)
clean-dry-run: clean-dry-run:
@echo "$(YELLOW)Dry run - files that would be deleted:$(NC)" @echo "$(YELLOW)Dry run - files that would be deleted (run 'make clean' to delete):$(NC)"
@echo "\nPython cache files:"
@find . -type d -name "__pycache__" 2>/dev/null || echo "None" @find . -type d -name "__pycache__" 2>/dev/null || echo "None"
@find . -type f -name "*.pyc" 2>/dev/null || echo "None" @find . -type f -name "*.pyc" 2>/dev/null || echo "None"
@find . -type f -name "*.pyo" 2>/dev/null || echo "None"
@echo "\nPytest cache:"
@find . -type d -name ".pytest_cache" 2>/dev/null || echo "None" @find . -type d -name ".pytest_cache" 2>/dev/null || echo "None"
@echo "\nMypy cache:"
@find . -type d -name ".mypy_cache" 2>/dev/null || echo "None" @find . -type d -name ".mypy_cache" 2>/dev/null || echo "None"
@echo "\nEmpty directories:"
@find . -type d -empty -not -path "./.git/*" -not -path "./.venv/*" 2>/dev/null || echo "None"
@echo "$(YELLOW)\nRun 'make clean' to actually delete these files$(NC)"
+12 -7
View File
@@ -49,12 +49,17 @@
## 技术边界 ## 技术边界
| 服务 | 技术 | 主要职责 | 工程架构以根目录 `AGENTS.md` 完整技术栈为基准,按五层组织;服务与文化命名登记见[契约词典](docs/contracts/contract-glossary.md)。
| --- | --- | --- |
| 灵台 | Python | 人物、镜树、人格、记忆、缘契、三界与认知编排 | | 服务 | 文化名 | 技术 | 主要职责 |
| 神识 | Go | Provider、Endpoint、模型路由、流式、BYOK、配额与成本 | | --- | --- | --- | --- |
| 乾坤 | Rust | 沙箱、高风险执行、设备与具身安全 | | 前端 | 琉璃镜 | Next.js / React / TS | 全部 Web 界面、镜树阅读、治理呈现、Live2D 与浏览器通感 |
| 琉璃镜 | TypeScript / Next.js | 全部 Web 用户界面、Live2D 与浏览器通感 | | BFF | 照壁 | Node.js / Fastify | 鉴权透传、SSE 代理、响应裁剪、类型桥接 |
| 后端核心 | 灵台 | Python / FastAPI | 六域业务、插件化认知运行时(九枢)、事件溯源、司命裁决、百工注册 |
| 模型网关 | 神识 | Go | Provider、路由、流式、BYOK、配额与成本计量 |
| 桌面壳 | 镜匣 | Rust / Tauri | 最小桌面壳、本地能力桥、乾坤本地执行通道 |
| 隔离执行 | 乾坤 | Rust / axum | 沙箱、高风险执行、设备与具身安全 |
| 辅助服务 | 博闻 / 天目 / 元炁 | Go / Python | MCP 联网搜索 / 本地 OCR / 本地推理 |
神识、百工、司命和灵台遵循:**神识通协议,百工掌能力,司命决边界,灵台承结果。** 神识、百工、司命和灵台遵循:**神识通协议,百工掌能力,司命决边界,灵台承结果。**
@@ -69,7 +74,7 @@ flowchart LR
E --> F[代码与测试] E --> F[代码与测试]
``` ```
现有代码是迁移与审计对象,不反向覆盖已确认的产品定义。 现有代码是迁移与审计对象,不反向覆盖已确认的产品定义。当前处于 rewrite/1.0 全量重写期,架构与契约层已按五层栈重建,范围与节奏见[重写执行计划](docs/operations/rewrite-plan.md)。
## 文档入口 ## 文档入口
+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/
-64
View File
@@ -1,64 +0,0 @@
# 后端开发
## 技术栈
Python 3.12 + FastAPI + SQLAlchemy + Alembic + loguru + httpx + chromadb
uv (用于虚拟环境和依赖管理)
## 开发命令
启动命令之前确保目前在backend目录下,并且已经激活了虚拟环境。默认本地开发使用 SQLite(aiosqlite)文件数据库,Redis、ChromaDB、Neo4j 暂不启用
```bash
uv venv -p 3.12 # 创建虚拟环境
source .venv/bin/activate # 激活虚拟环境
uv add <pkg> # 添加依赖
uv remove <pkg> # 移除依赖
uv run python -m app.main # 按 .env 中 BACKEND_HOST/BACKEND_PORT 启动服务
uv run uvicorn app.main:app --reload --port 8000 # 手动指定端口启动服务(命令行 --port 会覆盖 .env)
# 数据库迁移
uv run alembic revision --autogenerate --rev-id=001 -m "描述" # 生成迁移
uv run alembic upgrade head # 执行迁移
uv run alembic downgrade -1 # 回滚一个版本
uv run alembic current # 查看当前版本
# 测试
uv run pytest # 运行测试
```
## 代码规范
在__init__文件中可以将模块内的类、函数等导出,方便其他模块导入使用
遵循 PEP 8 代码风格指南和更加pythonic的开发方式
遵循单一职责原则,每个文件、类、函数等都要只干一件事
使用 loguru 进行日志记录,避免使用 logging 模块
使用 Alembic 进行数据库迁移,禁止手动修改数据库表结构,文件命名以迁移版本号开头,例如 001_initial.py;迁移必须优先兼容 SQLite,涉及 PostgreSQL 专用 DDL 时必须提供 SQLite 分支或显式跳过
使用alembic init alembic命令初始化迁移环境,使用命令生成相关的迁移文件,不能直接生成迁移文件
使用 httpx 进行异步 HTTP 请求,禁止在路由函数内直接使用同步数据库会话或 httpx 客户端
所有的测试代码都写在tests目录下,测试文件命名以test_开头,测试函数命名以test_开头
所有敏感信息(如密钥、Token、数据库密码)必须通过环境变量或配置文件管理,禁止硬编码到代码中
## 代码审查
开发完成后必须使用以下命令进行代码检查:
```bash
uv run ruff check # Lint 检查
uv run ruff check --fix # 自动修复部分代码风格问题
uv run pre-commit run --all-files # 可选:完整运行 CI 级检查(包括类型检查)
```
确保没有任何 lint 错误后再提交。
## 绝对禁止事项
直接修改 pyproject.toml 文件添加或者移除依赖(必须使用 uv add/remove 命令)
在路由函数内直接使用同步数据库会话(必须通过 Depends 注入)
在路由函数内直接 async with httpx.AsyncClient()
使用 logging 模块(统一用 loguru)
硬编码密钥、Token、数据库密码到代码
手动创建/修改数据库表结构(必须使用 Alembic 迁移)
在 `modules/` 中 import `repositories` / `db` / `integrations` / `ai_provider` / `httpx` / `sqlalchemy`(领域层禁止 IO,需要 IO 时上移到 services/application)
在业务目录(`ai_provider` / `integrations` 等)中持有 `settings.py` / `config.py`(配置统一进 `core/config/`)
使用 `python-dotenv`(`.env` 由 `core/config` 以绝对路径 `_env_file` 加载)
+4
View File
@@ -15,6 +15,10 @@ 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.
@@ -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 ###
File diff suppressed because it is too large. Load diff
@@ -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 ###
@@ -1,56 +0,0 @@
"""add_chat_session_owner_scope
Revision ID: 002
Revises: 001
Create Date: 2026-07-14 07:54:32.969191
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '002'
down_revision: str | Sequence[str] | None = '001'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
is_sqlite = conn.engine.dialect.name == "sqlite"
if is_sqlite:
# SQLite:用 batch 模式加列(新增 NOT NULL 列需 batch recreating)
with op.batch_alter_table("chat_sessions") as batch_op:
batch_op.add_column(
sa.Column("owner_scope", sa.String(length=80), nullable=False, server_default=""),
)
batch_op.create_index("ix_chat_sessions_owner_scope", ["owner_scope"])
# 移除 server_default(仅 DDL 阶段需要)
with op.batch_alter_table("chat_sessions") as batch_op:
batch_op.alter_column("owner_scope", server_default=None)
else:
op.add_column(
"chat_sessions",
sa.Column("owner_scope", sa.String(length=80), nullable=False, server_default=""),
)
op.create_index(
op.f("ix_chat_sessions_owner_scope"), "chat_sessions", ["owner_scope"], unique=False
)
op.alter_column("chat_sessions", "owner_scope", server_default=None)
def downgrade() -> None:
conn = op.get_bind()
is_sqlite = conn.engine.dialect.name == "sqlite"
if is_sqlite:
with op.batch_alter_table("chat_sessions") as batch_op:
batch_op.drop_index("ix_chat_sessions_owner_scope")
batch_op.drop_column("owner_scope")
else:
op.drop_index(op.f("ix_chat_sessions_owner_scope"), table_name="chat_sessions")
op.drop_column("chat_sessions", "owner_scope")
@@ -0,0 +1,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 ###
@@ -1,36 +0,0 @@
"""add daoti soul fields to character
Revision ID: 003
Revises: 002
Create Date: 2026-07-24 10:47:40.993422
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '003'
down_revision: str | Sequence[str] | None = '002'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('characters', sa.Column('personality_summary', sa.Text(), nullable=True))
op.add_column('characters', sa.Column('mission', sa.Text(), nullable=True))
op.add_column('characters', sa.Column('safety_guidelines', sa.Text(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('characters', 'safety_guidelines')
op.drop_column('characters', 'mission')
op.drop_column('characters', 'personality_summary')
# ### end Alembic commands ###
@@ -0,0 +1,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 ###
@@ -1,81 +0,0 @@
"""add lorebook tables
Revision ID: 004
Revises: 003
Create Date: 2026-07-24 12:20:38.453954
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '004'
down_revision: str | Sequence[str] | None = '003'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('lorebooks',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('scan_depth', sa.Integer(), nullable=True),
sa.Column('token_budget', sa.Integer(), nullable=True),
sa.Column('recursive_scanning', sa.Boolean(), nullable=False),
sa.Column('scope', sa.String(length=40), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_lorebooks_character_id'), 'lorebooks', ['character_id'], unique=False)
op.create_index(op.f('ix_lorebooks_owner_scope'), 'lorebooks', ['owner_scope'], unique=False)
op.create_index(op.f('ix_lorebooks_scope'), 'lorebooks', ['scope'], unique=False)
op.create_index(op.f('ix_lorebooks_status'), 'lorebooks', ['status'], unique=False)
op.create_table('lorebook_entries',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('lorebook_id', sa.String(length=80), nullable=False),
sa.Column('keys_json', sa.Text(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('secondary_keys_json', sa.Text(), nullable=False),
sa.Column('constant', sa.Boolean(), nullable=False),
sa.Column('selective', sa.Boolean(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('case_sensitive', sa.Boolean(), nullable=False),
sa.Column('use_regex', sa.Boolean(), nullable=False),
sa.Column('position', sa.String(length=40), nullable=False),
sa.Column('insertion_order', sa.Integer(), nullable=False),
sa.Column('priority', sa.Integer(), nullable=False),
sa.Column('comment', sa.String(length=500), nullable=False),
sa.Column('bound_entity_id', sa.String(length=80), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['lorebook_id'], ['lorebooks.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_lorebook_entries_bound_entity_id'), 'lorebook_entries', ['bound_entity_id'], unique=False)
op.create_index(op.f('ix_lorebook_entries_lorebook_id'), 'lorebook_entries', ['lorebook_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_lorebook_entries_lorebook_id'), table_name='lorebook_entries')
op.drop_index(op.f('ix_lorebook_entries_bound_entity_id'), table_name='lorebook_entries')
op.drop_table('lorebook_entries')
op.drop_index(op.f('ix_lorebooks_status'), table_name='lorebooks')
op.drop_index(op.f('ix_lorebooks_scope'), table_name='lorebooks')
op.drop_index(op.f('ix_lorebooks_owner_scope'), table_name='lorebooks')
op.drop_index(op.f('ix_lorebooks_character_id'), table_name='lorebooks')
op.drop_table('lorebooks')
# ### end Alembic commands ###
@@ -0,0 +1,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')
)
+11 -10
View File
@@ -9,26 +9,26 @@ from app.api.v1.endpoints import (
avatar, avatar,
character_import, character_import,
characters, characters,
chat,
drive, drive,
events, events,
evolution,
extraction,
goals, goals,
health, health,
imports, imports,
embryo_drafts,
jingsheng_characters,
kernel_runs,
lorebooks, lorebooks,
mirror_trees,
mcp, mcp,
memory,
personality, personality,
runs, runs,
safety, safety,
sandbox_files, sandbox_files,
settings, settings,
skills, skills,
time,
tools, tools,
world, world,
xirang,
) )
api_router = APIRouter() api_router = APIRouter()
@@ -36,9 +36,13 @@ api_router.include_router(health.router)
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(characters.router) api_router.include_router(characters.router)
api_router.include_router(character_import.router) api_router.include_router(character_import.router)
api_router.include_router(chat.router)
api_router.include_router(runs.router) api_router.include_router(runs.router)
api_router.include_router(memory.router) api_router.include_router(kernel_runs.router)
api_router.include_router(embryo_drafts.router)
api_router.include_router(jingsheng_characters.router)
api_router.include_router(mirror_trees.router)
api_router.include_router(xirang.router)
api_router.include_router(mirror_trees.node_router)
api_router.include_router(lorebooks.router) api_router.include_router(lorebooks.router)
api_router.include_router(personality.router) api_router.include_router(personality.router)
api_router.include_router(tools.router) api_router.include_router(tools.router)
@@ -49,12 +53,9 @@ api_router.include_router(sandbox_files.router)
api_router.include_router(settings.router) api_router.include_router(settings.router)
api_router.include_router(world.router) api_router.include_router(world.router)
api_router.include_router(imports.router) api_router.include_router(imports.router)
api_router.include_router(evolution.router)
api_router.include_router(drive.router) api_router.include_router(drive.router)
api_router.include_router(events.router) api_router.include_router(events.router)
api_router.include_router(extraction.router)
api_router.include_router(goals.router) api_router.include_router(goals.router)
api_router.include_router(admin.router) api_router.include_router(admin.router)
api_router.include_router(audio.router) api_router.include_router(audio.router)
api_router.include_router(avatar.router) api_router.include_router(avatar.router)
api_router.include_router(time.router)
+4 -4
View File
@@ -387,7 +387,7 @@ async def admin_tenants_overview() -> list[_TenantUserOverview]:
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.models.character import Character from app.models.character import Character
from app.models.memory import Memory from app.models.xirang import Memory
from app.models.user import User from app.models.user import User
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
@@ -406,7 +406,7 @@ async def admin_tenants_overview() -> list[_TenantUserOverview]:
mem_count = await session.scalar( mem_count = await session.scalar(
select(func.count()) select(func.count())
.select_from(Memory) .select_from(Memory)
.join(Character, Memory.character_id == Character.id) .join(Character, Memory.character_id == Character.id).where(Memory.status != "tombstoned")
.where( .where(
Character.owner_scope == user.id, Character.owner_scope == user.id,
Character.is_system_template.is_(False), Character.is_system_template.is_(False),
@@ -454,13 +454,13 @@ async def admin_tenant_memories(user_id: str) -> list[dict]:
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.models.character import Character from app.models.character import Character
from app.models.memory import Memory from app.models.xirang import Memory
from app.services.serializers import memory_to_response from app.services.serializers import memory_to_response
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
result = await session.execute( result = await session.execute(
select(Memory) select(Memory)
.join(Character, Memory.character_id == Character.id) .join(Character, Memory.character_id == Character.id).where(Memory.status != "tombstoned")
.where( .where(
Character.owner_scope == user_id, Character.owner_scope == user_id,
Character.is_system_template.is_(False), Character.is_system_template.is_(False),
@@ -1,29 +1,49 @@
"""角色卡导入 / 导出 API。""" """角色卡导入 / 导出 API。
rewrite/1.0 M2b:导入产出**可审阅灵胚**(含 character_book 暂存),不再直接建人物;
经照心编辑与启镜(POST /embryo-drafts/{id}/activate)后才物化人物与世谱。
"""
import json import json
from fastapi import APIRouter, Depends, File, Response, UploadFile from fastapi import APIRouter, Depends, File, Response, UploadFile
from app.api.v1.deps import get_current_user from app.api.v1.deps import get_current_user
from app.domains import jingsheng
from app.schemas.auth import CurrentUser from app.schemas.auth import CurrentUser
from app.schemas.character import ImportCardResponse from app.services.card_import import card_to_embryo_payload, export_character_card
from app.services.card_import import export_character_card, import_character_card_response
router = APIRouter(prefix="/characters", tags=["characters"]) router = APIRouter(prefix="/characters", tags=["characters"])
@router.post("/import-card", response_model=ImportCardResponse) @router.post("/import-card", status_code=201)
async def import_character_card_api( async def import_character_card_api(
file: UploadFile = File(..., description="角色卡文件,支持 .png / .json / .charx。"), file: UploadFile = File(..., description="角色卡文件,支持 .png / .json / .charx。"),
current_user: CurrentUser = Depends(get_current_user), current_user: CurrentUser = Depends(get_current_user),
) -> ImportCardResponse: ) -> dict:
"""导入酒馆角色卡(V1/V2/V3),建立人物 + 世谱(character_book)。 """导入酒馆角色卡(V1/V2/V3)→ 生成可审阅灵胚。
``personality`` 写入道体 ``personality_summary`` 作初始值; 卡内容整体进入灵胚道体草稿(identity_baseline.import_card),
``character_book`` 转为该角色的世谱书 + 条目。 ``character_book`` 在启镜时物化为该人物的世谱书与条目。
""" """
raw = await file.read() raw = await file.read()
return await import_character_card_response(raw, owner_scope=current_user.owner_scope) 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") @router.get("/{character_id}/export-card")
-137
View File
@@ -1,137 +0,0 @@
"""聊天 API。"""
from fastapi import APIRouter, Depends, Header, Query, Request, Response, status
from app.api.v1.deps import get_current_user
from app.schemas.auth import CurrentUser
from app.schemas.chat import (
ChatMessageListResponse,
ChatMessageResponse,
ChatSessionListResponse,
ChatSessionResponse,
CreateSessionRequest,
SelectVersionRequest,
SendMessageRequest,
SendMessageResponse,
UpdateSessionRequest,
)
from app.services.chat import (
create_session,
delete_session,
get_session_response,
list_message_responses,
list_session_responses,
select_message_version,
send_message,
update_session_response,
)
from app.services.idempotency import run_idempotent
router = APIRouter(prefix="/chat", tags=["chat"])
@router.post("/sessions", response_model=ChatSessionResponse)
async def create_chat_session(
payload: CreateSessionRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> ChatSessionResponse:
return await create_session(payload, owner_scope=current_user.owner_scope)
@router.get("/sessions", response_model=ChatSessionListResponse)
async def list_chat_sessions(
current_user: CurrentUser = Depends(get_current_user),
status: str | None = Query(default=None, description="按状态过滤:active / archived"),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> ChatSessionListResponse:
return await list_session_responses(
owner_scope=current_user.owner_scope, status=status, limit=limit, offset=offset
)
@router.get("/sessions/{session_id}", response_model=ChatSessionResponse)
async def get_chat_session(
session_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> ChatSessionResponse:
return await get_session_response(session_id, owner_scope=current_user.owner_scope)
@router.patch("/sessions/{session_id}", response_model=ChatSessionResponse)
async def update_chat_session(
session_id: str,
payload: UpdateSessionRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> ChatSessionResponse:
return await update_session_response(session_id, payload, owner_scope=current_user.owner_scope)
@router.delete("/sessions/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_chat_session(
session_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> Response:
await delete_session(session_id, owner_scope=current_user.owner_scope)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/sessions/{session_id}/messages", response_model=SendMessageResponse)
async def send_chat_message(
session_id: str,
payload: SendMessageRequest,
request: Request,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
current_user: CurrentUser = Depends(get_current_user),
) -> SendMessageResponse:
return await run_idempotent(
key=idempotency_key,
method="POST",
path=str(request.url.path),
payload=payload,
response_type=SendMessageResponse,
resource_type="run",
handler=lambda session: send_message(
session,
session_id,
payload,
owner_scope=current_user.owner_scope,
tool_runtime=getattr(request.app.state, "tool_runtime", None),
confirmation_registry=getattr(request.app.state, "confirmation_registry", None),
confirmation_store=getattr(request.app.state, "confirmation_store", None),
event_store=getattr(request.app.state, "event_store", None),
agent_run_registry=getattr(request.app.state, "agent_run_registry", None),
python_skill_catalog=getattr(request.app.state, "python_skill_catalog", None),
),
)
@router.get("/sessions/{session_id}/messages", response_model=ChatMessageListResponse)
async def list_chat_messages(
session_id: str,
current_user: CurrentUser = Depends(get_current_user),
limit: int = Query(default=100, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ChatMessageListResponse:
return await list_message_responses(
session_id, owner_scope=current_user.owner_scope, limit=limit, offset=offset
)
@router.patch(
"/sessions/{session_id}/messages/{message_id}/version",
response_model=ChatMessageResponse,
)
async def select_message_version_endpoint(
session_id: str,
message_id: str,
payload: SelectVersionRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> ChatMessageResponse:
"""选择/激活消息的某个历史版本。"""
return await select_message_version(
session_id,
message_id,
payload,
owner_scope=current_user.owner_scope,
)
@@ -0,0 +1,141 @@
"""灵胚 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
)
await jingsheng.record_rehearsal(
embryo_id, user.owner_scope, run_id=run.id, prompt=payload.prompt
)
return {"run_id": run.id, "kind": "rehearsal"}
@router.post("/{embryo_id}/activate")
async def activate_embryo_draft(
embryo_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
"""启镜:签发正式人物 ID、魂籍、执镜关系与命台。"""
character = await jingsheng.activate_embryo(embryo_id, user.owner_scope)
return {
"character_id": character.id,
"name": character.name,
"status": character.status,
"source": character.source,
"identity_record_version": 1,
}
@router.post("/{embryo_id}/abandon", status_code=204)
async def abandon_embryo_draft(
embryo_id: str, user: CurrentUser = Depends(get_current_user)
) -> None:
await jingsheng.abandon_embryo(embryo_id, user.owner_scope)
-60
View File
@@ -1,60 +0,0 @@
"""演化候选审核 API。"""
from fastapi import APIRouter, Query
from app.schemas.common import Page
from app.schemas.evolution import EvolutionCandidateResponse
from app.services.evolution import (
approve_evolution_candidate,
list_evolution_candidates,
reject_evolution_candidate,
)
router = APIRouter(tags=["evolution"])
@router.get(
"/characters/{character_id}/evolution-candidates",
response_model=Page[EvolutionCandidateResponse],
)
async def list_evolution_candidates_endpoint(
character_id: str,
status: str | None = Query(
default=None, description="按状态过滤:pending/approved/rejected/applied"
),
candidate_type: str | None = Query(
default=None, alias="type", description="按候选类型过滤(契约字典 §12.1)"
),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> Page[EvolutionCandidateResponse]:
"""列出某人物的演化候选(大周天生成 + 审核状态)。"""
return await list_evolution_candidates(
character_id=character_id,
status=status,
candidate_type=candidate_type,
page=page,
page_size=page_size,
)
@router.post(
"/evolution-candidates/{candidate_id}/approve",
response_model=EvolutionCandidateResponse,
)
async def approve_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
"""审核通过演化候选:派生到下游(人格版本/程序记忆等),status → applied。"""
return await approve_evolution_candidate(candidate_id)
@router.post(
"/evolution-candidates/{candidate_id}/reject",
response_model=EvolutionCandidateResponse,
)
async def reject_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
"""驳回演化候选:status → rejected,不派生下游。"""
return await reject_evolution_candidate(candidate_id)
@@ -1,90 +0,0 @@
"""大周天抽取候选审核 API(记忆候选 + 世界实体候选)。"""
from fastapi import APIRouter, Query
from app.schemas.candidates import MemoryCandidateResponse
from app.schemas.common import Page
from app.schemas.world import WorldEntityResponse
from app.services.extraction import (
approve_memory_proposal,
approve_world_entity,
list_memory_proposals,
list_world_entities_pending,
reject_memory_proposal,
reject_world_entity,
)
router = APIRouter(tags=["extraction"])
@router.get(
"/characters/{character_id}/memory-candidates",
response_model=Page[MemoryCandidateResponse],
)
async def list_memory_candidates_endpoint(
character_id: str,
source: str | None = Query(
default=None, description="来源过滤:cycle(大周天)/ import(资料导入)"
),
status: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> Page[MemoryCandidateResponse]:
"""列出记忆候选(大周天 + 资料导入统一)。"""
return await list_memory_proposals(
character_id=character_id,
source=source,
status=status,
page=page,
page_size=page_size,
)
@router.post(
"/memory-candidates/{candidate_id}/approve",
response_model=MemoryCandidateResponse,
)
async def approve_memory_candidate_endpoint(
candidate_id: str,
) -> MemoryCandidateResponse:
"""通过记忆候选:派生为 memories 记录(source=big_cycle)。"""
return await approve_memory_proposal(candidate_id)
@router.post(
"/memory-candidates/{candidate_id}/reject",
response_model=MemoryCandidateResponse,
)
async def reject_memory_candidate_endpoint(
candidate_id: str,
) -> MemoryCandidateResponse:
"""驳回记忆候选。"""
return await reject_memory_proposal(candidate_id)
@router.get(
"/characters/{character_id}/world/entity-proposals",
response_model=Page[WorldEntityResponse],
)
async def list_world_entity_proposals_endpoint(
character_id: str,
status: str | None = Query(default="pending", description="默认 pending(候选)"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> Page[WorldEntityResponse]:
"""列出世界实体候选(大周天抽取,status=pending 待审核)。"""
return await list_world_entities_pending(
character_id=character_id, status=status, page=page, page_size=page_size
)
@router.post("/world/entities/{entity_id}/approve", response_model=WorldEntityResponse)
async def approve_world_entity_endpoint(entity_id: str) -> WorldEntityResponse:
"""通过世界实体候选:status → confirmed 进入世界模型。"""
return await approve_world_entity(entity_id)
@router.post("/world/entities/{entity_id}/reject", response_model=WorldEntityResponse)
async def reject_world_entity_endpoint(entity_id: str) -> WorldEntityResponse:
"""驳回世界实体候选。"""
return await reject_world_entity(entity_id)
@@ -0,0 +1,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
)
return await get_run_response(run.id)
-73
View File
@@ -1,73 +0,0 @@
"""记忆 API。"""
from fastapi import APIRouter, Depends, Header, Query, Request
from app.api.v1.deps import get_current_user
from app.schemas.auth import CurrentUser
from app.schemas.common import Page
from app.schemas.memory import MemoryCreateRequest, MemoryResponse, MemoryUpdateRequest
from app.services.idempotency import run_idempotent
from app.services.memory import create_memory, delete_memory, list_memory_page, update_memory
router = APIRouter(prefix="/memory", tags=["memory"])
@router.get("", response_model=Page[MemoryResponse])
async def list_memory(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
memory_type: str | None = Query(
default=None,
alias="type",
description="按类型过滤(如 user/assistant/system,契约字典 memory.type)。",
),
source: str | None = Query(
default=None,
description="按来源过滤(如 user_explicit/big_cycle/import,契约字典 memory.source)。",
),
current_user: CurrentUser = Depends(get_current_user),
) -> Page[MemoryResponse]:
return await list_memory_page(
page=page,
page_size=page_size,
memory_type=memory_type,
source=source,
owner_scope=current_user.owner_scope,
)
@router.post("", response_model=MemoryResponse)
async def create_memory_api(
payload: MemoryCreateRequest,
request: Request,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
current_user: CurrentUser = Depends(get_current_user),
) -> MemoryResponse:
return await run_idempotent(
key=idempotency_key,
method="POST",
path=str(request.url.path),
payload=payload,
response_type=MemoryResponse,
resource_type="memory",
handler=lambda session: create_memory(
session, payload, owner_scope=current_user.owner_scope
),
)
@router.patch("/{memory_id}", response_model=MemoryResponse)
async def update_memory_api(
memory_id: str,
payload: MemoryUpdateRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> MemoryResponse:
return await update_memory(memory_id, payload, owner_scope=current_user.owner_scope)
@router.delete("/{memory_id}", response_model=MemoryResponse)
async def delete_memory_api(
memory_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> MemoryResponse:
return await delete_memory(memory_id, owner_scope=current_user.owner_scope)
@@ -0,0 +1,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)
-2
View File
@@ -38,7 +38,6 @@ router = APIRouter(prefix="/runs", tags=["runs"])
@router.get("", response_model=Page[RunResponse]) @router.get("", response_model=Page[RunResponse])
async def list_runs( async def list_runs(
character_id: str | None = Query(default=None, description="按人物过滤。"), character_id: str | None = Query(default=None, description="按人物过滤。"),
session_id: str | None = Query(default=None, description="按会话过滤。"),
status: str | None = Query(default=None, description="按状态过滤。"), status: str | None = Query(default=None, description="按状态过滤。"),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100), page_size: int = Query(default=20, ge=1, le=100),
@@ -46,7 +45,6 @@ async def list_runs(
"""Run 列表(按创建时间倒序);归档页运行时间河数据源。""" """Run 列表(按创建时间倒序);归档页运行时间河数据源。"""
return await list_runs_response( return await list_runs_response(
character_id=character_id, character_id=character_id,
session_id=session_id,
status=status, status=status,
page=page, page=page,
page_size=page_size, page_size=page_size,
-113
View File
@@ -1,113 +0,0 @@
"""辰正时态 API(设计 §10)。
挂载于 /api/v1/time。事件不走 REST(走 EventStore 订阅)。路由薄层:参数校验 → 调
service → 转响应;session 由 service 自管(deps.py 惯例)。
"""
from fastapi import APIRouter, Depends, Request
from app.api.v1.deps import get_current_user, get_event_store
from app.core.config import settings
from app.schemas.auth import CurrentUser
from app.schemas.time import (
AcquaintanceResponse,
AnniversaryCreateRequest,
AnniversaryResponse,
AnniversaryUpdateRequest,
SnapshotResponse,
TimeConfigResponse,
)
from app.services.time_sense import (
create_anniversary_for_active,
delete_anniversary_or_404,
get_acquaintance_for_active,
get_anniversary_or_404,
get_live_snapshot,
list_anniversaries_for_active,
update_anniversary_or_404,
)
router = APIRouter(prefix="/time", tags=["time"])
@router.get("/snapshot", response_model=SnapshotResponse)
async def get_snapshot(
request: Request, current_user: CurrentUser = Depends(get_current_user)
) -> SnapshotResponse:
"""当前 TemporalSnapshot + ModulationFactors + context_text。"""
result = await get_live_snapshot(get_event_store(request), owner_scope=current_user.owner_scope)
return SnapshotResponse(
snapshot=result.snapshot,
factors=result.factors,
context_text=result.context_text,
)
@router.get("/anniversaries", response_model=list[AnniversaryResponse])
async def list_anniversaries_api(
current_user: CurrentUser = Depends(get_current_user),
) -> list[AnniversaryResponse]:
items = await list_anniversaries_for_active(owner_scope=current_user.owner_scope)
return [AnniversaryResponse.model_validate(a) for a in items]
@router.post("/anniversaries", response_model=AnniversaryResponse, status_code=201)
async def create_anniversary_api(
payload: AnniversaryCreateRequest, current_user: CurrentUser = Depends(get_current_user)
) -> AnniversaryResponse:
created = await create_anniversary_for_active(payload, owner_scope=current_user.owner_scope)
return AnniversaryResponse.model_validate(created)
@router.get("/anniversaries/{anniv_id}", response_model=AnniversaryResponse)
async def get_anniversary_api(
anniv_id: str, current_user: CurrentUser = Depends(get_current_user)
) -> AnniversaryResponse:
return AnniversaryResponse.model_validate(
await get_anniversary_or_404(anniv_id, owner_scope=current_user.owner_scope)
)
@router.patch("/anniversaries/{anniv_id}", response_model=AnniversaryResponse)
async def update_anniversary_api(
anniv_id: str,
payload: AnniversaryUpdateRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> AnniversaryResponse:
return AnniversaryResponse.model_validate(
await update_anniversary_or_404(anniv_id, payload, owner_scope=current_user.owner_scope)
)
@router.delete("/anniversaries/{anniv_id}", status_code=204)
async def delete_anniversary_api(
anniv_id: str, current_user: CurrentUser = Depends(get_current_user)
) -> None:
await delete_anniversary_or_404(anniv_id, owner_scope=current_user.owner_scope)
@router.get("/acquaintance", response_model=AcquaintanceResponse)
async def get_acquaintance_api(
request: Request, current_user: CurrentUser = Depends(get_current_user)
) -> AcquaintanceResponse:
acq = await get_acquaintance_for_active(
get_event_store(request), owner_scope=current_user.owner_scope
)
return AcquaintanceResponse(total_days=acq.total_days, natural_language=acq.natural_language)
@router.get("/config", response_model=TimeConfigResponse)
async def get_time_config_api(
current_user: CurrentUser = Depends(get_current_user), # noqa: ARG001
) -> TimeConfigResponse:
return TimeConfigResponse(
user_timezone=settings.user_timezone,
home_timezone=settings.home_timezone,
default_city=settings.default_city,
offline_active_minutes=settings.offline_active_minutes,
offline_short_absence_minutes=settings.offline_short_absence_minutes,
offline_long_absence_minutes=settings.offline_long_absence_minutes,
offline_reunion_impact_minutes=settings.offline_reunion_impact_minutes,
care_enabled=settings.care_enabled,
jetlag_threshold_minutes=settings.jetlag_threshold_minutes,
)
+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
]
-602
View File
@@ -1,602 +0,0 @@
"""大周天:长期演化循环(architecture §3.2;v1-scope §3.3)。
双触发:记忆阈值 + 每轮对话后检查。循环阶段化执行(自我评估 → 演化策略 →
候选生成),候选生成调神识模型产出统一 ``evolution_candidates``,按 ``level``
半自动应用——人格特质/程序记忆等已成熟下游立即派生,L4 核心变更留人工审核
(见 [大周天演化候选落地设计] ../../docs/domain/core-domains.md)。
单进程 asyncio,多 worker 需任务队列。
"""
import asyncio
from dataclasses import asdict
from datetime import UTC
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai_provider import ProviderManager, ProviderManagerFactory
from app.core.config import settings
from app.core.errors import AppError
from app.core.ids import new_goal_id
from app.core.logging import logger
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.candidates import MemoryCandidate
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
from app.models.evolution import EvolutionCandidate
from app.models.governance import Goal
from app.models.memory import Memory
from app.models.world import CharacterWorldEntity
from app.modules.big_cycle.extract import parse_evolution_candidates
from app.modules.big_cycle.prompts import (
EvaluationSnapshot,
build_evolution_extraction_messages,
)
from app.modules.drive.extract import parse_curiosities
from app.modules.drive.prompts import build_drive_extraction_messages
from app.modules.evolution.policies import should_auto_apply
from app.modules.extraction.extract import parse_extraction
from app.modules.extraction.prompts import build_extraction_messages
from app.modules.runtime.events import Event
from app.repositories import big_cycle as big_cycle_repo
from app.repositories import characters as character_repo
from app.repositories import drive as drive_repo
from app.repositories import evolution as evolution_repo
from app.repositories import memory as memory_repo
from app.repositories import runtime as runtime_repo # 新增:活跃 run 检查
from app.repositories import world as world_repo
from app.repositories.json_utils import dumps
from app.services import evolution as evolution_service
from app.services.audit import record_audit_event
from app.services.event_store import DbEventStore
from app.services.personality import build_personality_summary
_running_big_cycle_tasks: set[asyncio.Task[None]] = set()
_RECENT_MEMORY_LIMIT = 10
# Token 预算估算(各阶段大致消耗,用于超预算时跳过非关键阶段)
_ESTIMATED_EVALUATION_COST = 4000
_ESTIMATED_CANDIDATE_COST = 16000
_ESTIMATED_MEMORY_COST = 8000
_provider_manager: ProviderManager | None = None
def _get_provider_manager() -> ProviderManager:
global _provider_manager
if _provider_manager is None:
_provider_manager = ProviderManagerFactory.create()
return _provider_manager
def _get_event_store() -> DbEventStore:
"""创建临时 EventStore 实例用于推送大周天事件(自管 session 工厂)。"""
return DbEventStore(AsyncSessionLocal)
async def _emit_big_cycle_event(
event_type: str,
character_id: str,
cycle_id: str,
*,
payload: dict | None = None,
) -> None:
"""推送大周天事件到 EventStore(自管 Session,失败静默)。"""
try:
store = _get_event_store()
await store.append(
Event(
type=event_type,
source="big_cycle",
visibility="public",
character_id=character_id,
payload={"cycle_id": cycle_id, **(payload or {})},
)
)
except Exception: # noqa: BLE001
logger.warning(f"推送大周天事件失败: {event_type}", cycle_id=cycle_id)
async def drain_pending_big_cycles() -> None:
"""等待所有进行中的大周天循环完成(测试断言 / 优雅停机用)。"""
if _running_big_cycle_tasks:
await asyncio.gather(*_running_big_cycle_tasks, return_exceptions=True)
async def maybe_trigger_big_cycle(character_id: str, trigger: str = "session_turn") -> str | None:
"""检查触发条件,满足则创建 cycle 记录并启动后台循环;返回 cycle_id 或 None。"""
if not settings.enable_big_cycle:
return None
async with AsyncSessionLocal() as session:
# 任务中断防护:如果有活跃运行中的 run,跳过大周天避免干扰对话
if await runtime_repo.has_active_runs(session, character_id):
logger.debug(f"大周天跳过:{character_id} 有活跃 run")
return None
memory_count = await character_repo.count_memories(session, character_id)
if memory_count < settings.big_cycle_memory_threshold:
return None
last = await big_cycle_repo.get_last_completed(session, character_id)
if last is not None and last.completed_at is not None:
last_completed = last.completed_at
if last_completed.tzinfo is None:
last_completed = last_completed.replace(tzinfo=UTC)
if (
utc_now() - last_completed
).total_seconds() < settings.big_cycle_min_interval_seconds:
return None
cycle = BigCycle(
character_id=character_id,
trigger=trigger,
status="pending",
memory_count_at_trigger=memory_count,
)
await big_cycle_repo.add_cycle(session, cycle)
await session.commit()
cycle_id = cycle.id
start_big_cycle(cycle_id, character_id)
return cycle_id
def start_big_cycle(cycle_id: str, character_id: str) -> None:
task = asyncio.create_task(_run_big_cycle(cycle_id, character_id))
_running_big_cycle_tasks.add(task)
task.add_done_callback(_running_big_cycle_tasks.discard)
async def _phase_goal_progress(session: AsyncSession, cycle: BigCycle) -> None:
"""大周天目标进度更新:根据演化结果增加关联目标进度。
当前为简单版本:每完成一轮大周天,character 的活跃 task/session 目标进度 +0.1。
后续可改为 LLM 评估更精确的进度。
"""
from app.repositories import governance as governance_repo
active_goals = await governance_repo.list_goals_by_character(
session, cycle.character_id, status_filter="active"
)
for goal in active_goals:
if goal.level in ("task", "session") and goal.progress < 1.0:
new_progress = min(1.0, goal.progress + 0.1)
await governance_repo.update_goal_progress(session, goal.id, new_progress)
if new_progress >= 1.0 and goal.level == "task":
goal.status = "completed"
from app.services.governance import emit_goal_event
await emit_goal_event(
goal,
"completed",
extra_payload={"trigger": "big_cycle"},
)
def _score_autonomy(subgoal) -> float:
"""按 information_gain / interruption_cost 比值排序。"""
gain = getattr(subgoal, "expected_information_gain", None) or 0.0
cost = getattr(subgoal, "interruption_cost", None) or 1.0
return gain / max(cost, 0.1)
async def _phase_autonomous_exploration(
session: AsyncSession, cycle: BigCycle,
) -> None:
"""自主探索执行:将最高优先级的已批准次生目标标记为待叩心枢执行。
选择 expected_information_gain / interruption_cost 比值最高的已批准次生目标,
标记为 auto_execute_pending,由叩心枢的下一个 tick 拾取。
每次大周天最多标记 2 个。
"""
from app.repositories import drive as drive_repo
approved = await drive_repo.list_approved_subgoals(
session, cycle.character_id, limit=5,
)
if not approved:
return
sorted_subgoals = sorted(approved, key=_score_autonomy, reverse=True)
to_execute = sorted_subgoals[:2]
for sub in to_execute:
sub.status = "auto_execute_pending"
await session.flush()
logger.info(
f"大周天自主探索标记 cycle={cycle.id} "
f"subgoals={len(to_execute)}/{len(approved)}"
)
async def _run_big_cycle(cycle_id: str, character_id: str) -> None:
"""大周天主循环:阶段化执行,超预算时跳过非关键阶段。"""
budget_spent = 0
await _emit_big_cycle_event("big_cycle.started", character_id, cycle_id)
async with AsyncSessionLocal() as session:
try:
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
if cycle is None:
return
cycle.status = "running"
cycle.started_at = utc_now()
await session.commit()
# 阶段 1:自我评估(关键阶段,始终执行)
snapshot = await _phase_evaluation(session, cycle)
budget_spent += _ESTIMATED_EVALUATION_COST
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "evaluation", "status": "completed"})
# 阶段 2:演化策略(轻量,始终执行)
await _phase_evolution(session, cycle)
# 预算检查:超过 token 预算则跳过候选/记忆/探索阶段
if budget_spent >= settings.big_cycle_token_budget:
logger.warning(f"大周天 token 预算耗尽 cycle={cycle_id} spent={budget_spent}")
cycle.summary = "token 预算耗尽,跳过后续非关键阶段"
await session.commit()
await _emit_big_cycle_event(
"big_cycle.phase", character_id, cycle_id,
payload={"phase": "candidate", "status": "skipped", "reason": "budget_exhausted"},
)
await _emit_big_cycle_event(
"big_cycle.phase", character_id, cycle_id,
payload={"phase": "memory", "status": "skipped", "reason": "budget_exhausted"},
)
else:
# 阶段 3:候选生成
await _phase_candidates(session, cycle, snapshot)
budget_spent += _ESTIMATED_CANDIDATE_COST
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "candidate", "status": "completed"})
# 阶段 4:记忆/世界抽取
await _phase_memory(session, cycle, snapshot)
budget_spent += _ESTIMATED_MEMORY_COST
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "memory", "status": "completed"})
# 阶段 5:目标进度更新
await _phase_goal_progress(session, cycle)
# 阶段 6:自主探索执行(轻量,始终执行)
await _phase_autonomous_exploration(session, cycle)
cycle.status = "completed"
cycle.phase = "done"
cycle.completed_at = utc_now()
await record_audit_event(
session,
action="big_cycle.completed",
character_id=character_id,
actor="system",
decision="auto",
input_summary=(cycle.summary or "大周天循环完成")[:200],
resource_type="big_cycle",
resource_id=cycle.id,
)
await session.commit()
await _emit_big_cycle_event(
"big_cycle.completed", character_id, cycle_id,
payload={"summary": cycle.summary},
)
except Exception as exc:
await _fail_cycle(session, cycle_id, exc)
await _emit_big_cycle_event(
"big_cycle.failed", character_id, cycle_id,
payload={"error": str(exc)[:200]},
)
async def _phase_evaluation(session: AsyncSession, cycle: BigCycle) -> EvaluationSnapshot:
"""自我评估:汇总记忆/人格/世界态势,写入 cycle 快照供候选生成消费。"""
cycle.phase = "evaluation"
await session.commit()
character = await character_repo.get_character(session, cycle.character_id)
if character is None:
raise AppError("not_found", "人物不存在。")
memory_count = await character_repo.count_memories(session, cycle.character_id)
recent, _ = await memory_repo.list_memories(
session, cycle.character_id, page=1, page_size=_RECENT_MEMORY_LIMIT
)
personality = await build_personality_summary(session, character)
_, world_count = await world_repo.list_entities(
session, cycle.character_id, page=1, page_size=1
)
snapshot = EvaluationSnapshot(
character_id=cycle.character_id,
memory_count=memory_count,
recent_memories=[(m.title, m.content) for m in recent],
personality_summary=personality.text or "",
world_entity_count=world_count,
conclusion=_build_conclusion(memory_count, world_count, personality.text),
evidence_ids=[m.id for m in recent],
)
# 人格权重向量:查询最近大周天的累积漂移,注入态势快照供 LLM 感知
from app.repositories import evolution as evolution_repo
recent_candidates, _ = await evolution_repo.list_candidates(
session, cycle.character_id, status=None, page=1, page_size=100,
)
if recent_candidates:
recent_drift = sum(c.drift_score or 0.0 for c in recent_candidates)
drift_budget_remaining = max(0.0, settings.big_cycle_max_drift - recent_drift)
snapshot.drift_budget_remaining = drift_budget_remaining
snapshot.cumulative_drift = recent_drift
cycle.evaluation_snapshot_json = dumps(asdict(snapshot))
await session.commit()
logger.info(f"大周天自我评估 cycle={cycle.id} memory={memory_count} world={world_count}")
await _generate_drive(session, cycle, snapshot)
return snapshot
async def _generate_drive(
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
) -> None:
"""内驱力生成(C 子阶段):基于态势生成好奇问题与探索性次生目标。"""
system, user = build_drive_extraction_messages(snapshot)
raw = await _call_model(system, user, cycle.character_id)
drafts = parse_curiosities(raw)[: settings.big_cycle_max_candidates]
curiosities = [
CuriosityQuestion(
character_id=cycle.character_id,
cycle_id=cycle.id,
question=draft.question,
source=draft.source,
related_entities_json=dumps(draft.related_entities),
expected_value=draft.expected_value,
urgency=draft.urgency,
risk_level=draft.risk_level,
)
for draft in drafts
]
if curiosities:
await drive_repo.add_curiosities(session, curiosities)
await session.flush()
subgoals: list[ExploratorySubgoal] = []
for curiosity, draft in zip(curiosities, drafts, strict=True):
for sub in draft.subgoals:
subgoals.append(
ExploratorySubgoal(
character_id=cycle.character_id,
cycle_id=cycle.id,
curiosity_question_id=curiosity.id,
title=sub.title,
description=sub.description,
expected_information_gain=sub.expected_information_gain,
interruption_cost=sub.interruption_cost,
risk_level=sub.risk_level,
status="candidate",
)
)
if subgoals:
await drive_repo.add_subgoals(session, subgoals)
await session.flush()
# 自动通过低风险次生目标(L0-L2)→ 创建 Goal
auto_approve_levels = {"L0", "L1", "L2"}
auto_count = 0
for sub in subgoals:
if sub.risk_level in auto_approve_levels:
sub.status = "approved"
goal = Goal(
id=new_goal_id(),
character_id=cycle.character_id,
title=sub.title,
description=sub.description or "由次生目标自动生成",
level="task",
type="exploration",
status="active",
)
session.add(goal)
auto_count += 1
await session.flush()
if auto_count:
logger.info(f"大周天自动通过 {auto_count}/{len(subgoals)} 个低风险次生目标")
logger.info(
f"大周天内驱力生成 cycle={cycle.id} curiosities={len(curiosities)} subgoals={len(subgoals)}"
)
await _emit_big_cycle_event(
"big_cycle.drive_generated", cycle.character_id, cycle.id,
payload={
"curiosity_count": len(curiosities),
"subgoal_count": len(subgoals),
"auto_approved_subgoals": auto_count,
},
)
async def _phase_evolution(session: AsyncSession, cycle: BigCycle) -> None:
"""演化策略阶段(轻量):演化策略融合进候选生成 prompt,此处仅标记阶段。"""
cycle.phase = "evolution"
await session.commit()
logger.info(f"大周天演化策略 cycle={cycle.id}")
async def _phase_candidates(
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
) -> None:
"""候选生成:调神识模型产出演化候选,按 level 半自动应用。"""
cycle.phase = "candidate"
await session.commit()
system, user = build_evolution_extraction_messages(snapshot)
raw = await _call_model(system, user, cycle.character_id)
drafts = parse_evolution_candidates(raw)[: settings.big_cycle_max_candidates]
candidates = [
EvolutionCandidate(
character_id=cycle.character_id,
cycle_id=cycle.id,
type=draft.type,
source=draft.source,
level=draft.level,
scope=draft.scope,
title=draft.title,
proposal=draft.proposal,
delta_json=dumps(draft.delta),
evidence_ids_json=dumps(_resolve_evidence(snapshot, draft.evidence)),
confidence=draft.confidence,
benefit_score=draft.benefit_score,
risk_score=draft.risk_score,
drift_score=draft.drift_score,
auto_apply_allowed=draft.auto_apply_allowed,
status="pending",
)
for draft in drafts
]
await evolution_repo.add_candidates(session, candidates)
await session.flush()
# 反漂移检测:高漂移候选不自动应用 + 累积漂移跟踪
cumulative_drift = 0.0
drift_blocked = 0
for candidate in candidates:
if candidate.drift_score >= settings.big_cycle_drift_threshold:
# 高漂移候选强制标记为 pending(人工审核),不自动应用
candidate.auto_apply_allowed = False
candidate.status = "pending"
candidate.error_message = f"漂移 {candidate.drift_score} >= 阈值 {settings.big_cycle_drift_threshold},需人工审核"
drift_blocked += 1
cumulative_drift += candidate.drift_score
# 累积漂移超过上限 → 全部候选降为 pending
if cumulative_drift >= settings.big_cycle_max_drift:
logger.warning(f"累积漂移 {cumulative_drift:.2f} >= 上限 {settings.big_cycle_max_drift},全部候选降为人工审核")
for candidate in candidates:
if candidate.status != "applied":
candidate.auto_apply_allowed = False
candidate.status = "pending"
candidate.error_message = f"累积漂移 {cumulative_drift:.2f} 超限,暂停自动演化"
for candidate in candidates:
if should_auto_apply(candidate):
target_id = await evolution_service.apply_evolution_candidate(session, candidate)
if target_id is not None:
candidate.status = "applied"
candidate.resulting_target_id = target_id
else:
candidate.error_message = "该类型派生待后续阶段,已降级为人工审核"
applied = sum(1 for c in candidates if c.status == "applied")
pending = sum(1 for c in candidates if c.status == "pending")
cycle.summary = (
f"大周天生成 {len(candidates)} 个演化候选,"
f"{applied} 个已自动应用,{pending} 个待审核"
f"{f'({drift_blocked} 个因漂移拦截)' if drift_blocked else ''}"
f"{f',累积漂移 {cumulative_drift:.2f}' if cumulative_drift > 0 else ''}"
)
if drift_blocked:
await _emit_big_cycle_event(
"big_cycle.drift_blocked", cycle.character_id, cycle.id,
payload={
"drift_blocked": drift_blocked,
"cumulative_drift": round(cumulative_drift, 2),
"threshold": settings.big_cycle_drift_threshold,
},
)
await session.commit()
logger.info(f"大周天候选生成 cycle={cycle.id} count={len(candidates)} applied={applied}")
async def _phase_memory(
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
) -> None:
"""记忆/世界抽取(D 子阶段):从态势抽取记忆候选与世界实体候选。"""
cycle.phase = "memory"
await session.commit()
system, user = build_extraction_messages(snapshot)
raw = await _call_model(system, user, cycle.character_id)
memory_drafts, entity_drafts = parse_extraction(raw)
memory_drafts = memory_drafts[: settings.big_cycle_max_candidates]
entity_drafts = entity_drafts[: settings.big_cycle_max_candidates]
memory_cands = [
MemoryCandidate(
character_id=cycle.character_id,
cycle_id=cycle.id,
type=draft.type,
title=draft.title,
content=draft.content,
confidence=draft.confidence,
importance=draft.importance,
sensitivity=draft.sensitivity,
reason=draft.reason,
status="pending",
)
for draft in memory_drafts
]
entities = [
CharacterWorldEntity(
character_id=cycle.character_id,
type=draft.type,
name=draft.name,
attributes_json=dumps(draft.attributes),
state_type=draft.state_type,
confidence=draft.confidence,
status="pending",
)
for draft in entity_drafts
]
if memory_cands:
session.add_all(memory_cands)
if entities:
session.add_all(entities)
await session.flush()
# 自动落地高置信度(≥0.7)低敏感度的记忆候选到 memories 表
auto_memories = 0
for cand in memory_cands:
if cand.confidence >= 0.7 and cand.sensitivity in ("low", "none"):
mem = Memory(
character_id=cycle.character_id,
type=cand.type,
title=cand.title,
content=cand.content,
source="big_cycle",
importance=cand.importance,
sensitivity=cand.sensitivity,
confidence=cand.confidence,
)
session.add(mem)
cand.status = "applied"
auto_memories += 1
# 自动落地高置信度世界实体候选
auto_entities = 0
for ent in entities:
if ent.confidence >= 0.7:
ent.status = "active"
auto_entities += 1
await session.flush()
if auto_memories or auto_entities:
logger.info(
f"大周天记忆/世界自动落地 cycle={cycle.id} "
f"memories={auto_memories} entities={auto_entities}"
)
logger.info(
f"大周天记忆/世界抽取 cycle={cycle.id} "
f"memories={len(memory_cands)} entities={len(entities)}"
)
def _resolve_evidence(snapshot: EvaluationSnapshot, indices: list[int]) -> list[str]:
return [snapshot.evidence_ids[i] for i in indices if 0 <= i < len(snapshot.evidence_ids)]
def _build_conclusion(memory_count: int, world_count: int, personality_text: str | None) -> str:
parts = [f"记忆 {memory_count} 条", f"世界实体 {world_count} 个"]
parts.append("已具备人格基线" if personality_text else "尚无人格基线")
return ";".join(parts)
async def _call_model(system: str, user: str, _character_id: str) -> str:
manager = _get_provider_manager()
messages = manager.to_message_list([system], [("user", user)])
response = await manager.chat(messages, model_hint=settings.big_cycle_model)
return response.content
async def _fail_cycle(session: AsyncSession, cycle_id: str, exc: Exception) -> None:
logger.error(f"大周天循环失败 cycle={cycle_id}: {exc}")
try:
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
if cycle is not None:
cycle.status = "failed"
cycle.error_code = "big_cycle_failed"
cycle.error_message = str(exc)[:500]
cycle.completed_at = utc_now()
await session.commit()
except Exception: # noqa: BLE001 — 失败处理本身不能再抛
pass
Whitespace-only changes.
@@ -1,970 +0,0 @@
"""发送聊天消息用例。"""
from __future__ import annotations
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai_provider import ProviderManager, ProviderManagerFactory
from app.ai_provider.types.chat import ChatChunk
from app.application.agent import (
Agent,
AgentConfig,
AgentEvent,
AgentRunContext,
AgentRunRegistry,
ConfirmationRegistry,
ExtensionRegistry,
PythonSkillCatalog,
PythonSkillDefinition,
activate_python_skills,
compose_system_messages,
default_tools,
next_step_for_tool,
run_agent_turn,
step_event_payload,
task_from_deliberative_plan,
task_plan_payload,
)
from app.application.tool_runtime.runtime import ToolRuntime
from app.application.tool_runtime.view import RunScopedToolView
from app.core.config import settings
from app.core.errors import AppError, current_trace_id
from app.core.logging import logger
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.chat import ChatMessage
from app.modules.agent import DEFAULT_CHAT_AGENT
from app.modules.chat.history import history_pairs
from app.modules.chat.policies import validate_text_message
from app.modules.goal_orchestrator import extract_session_goal, format_goal_directive
from app.modules.memory.context import build_memory_context
from app.modules.runtime.contract import RuntimeContract
from app.modules.runtime.events import EventStore
from app.modules.safety.confirmation import ConfirmationStore
from app.repositories import chat as chat_repo
from app.repositories import runtime as runtime_repo
from app.repositories.json_utils import dumps, loads_dict
from app.schemas.chat import SendMessageRequest, SendMessageResponse
from app.services import runtime as runtime_service
from app.services.audit import record_audit_event
from app.services.characters import default_model_hint, resolve_active_character
from app.services.chat import recent_chat_messages
from app.services.memory import retrieve_memories
from app.services.personality import build_personality_summary
from app.services.runtime import TERMINAL_RUN_STATUS
from app.services.safety import decide_chat_safety
# 后台流式任务引用:asyncio.create_task 仅保留弱引用,需强引用防止中途被 GC。
_running_stream_tasks: set[asyncio.Task[None]] = set()
# 流式增量分片阈值:每攒够 N 字符产出一条 delta 事件,配合 sse_poll_interval(0.01s)
# 让前端看到逐段平滑增量。值越小流式越细、事件数越多(run_max_events 默认 1000)。
_REASONING_FLUSH_CHARS = 8
_CONTENT_FLUSH_CHARS = 8
_provider_manager: ProviderManager | None = None
def _get_provider_manager() -> ProviderManager:
global _provider_manager
if _provider_manager is None:
_provider_manager = ProviderManagerFactory.create()
return _provider_manager
async def drain_pending_streams() -> None:
"""等待所有进行中的后台流式任务完成。
``execute`` 在创建 run 后即返回,LLM 流式在后台任务中执行;调用方在需要确保
运行终态时(测试断言、优雅停机)await 此函数。
"""
if _running_stream_tasks:
await asyncio.gather(*_running_stream_tasks, return_exceptions=True)
def _contract_system_messages(contract: RuntimeContract) -> list[str]:
messages = [*contract.system_instructions]
if contract.system_prompt:
messages.append(contract.system_prompt) # 卡的 system_prompt:道体增补层(§6)
if contract.session_goal:
messages.append(format_goal_directive(contract.session_goal))
if contract.active_goals:
messages.append(contract.active_goals) # 在 session_goal 之后注入
if contract.mission:
messages.append(f"核心使命:\n{contract.mission}") # 道体恒定层
if contract.lorebook_before:
messages.append(contract.lorebook_before)
if contract.personality_summary:
messages.append(f"当前人格:\n{contract.personality_summary}")
if contract.lorebook_after:
messages.append(contract.lorebook_after)
if contract.emotion_state:
messages.append(contract.emotion_state)
if contract.memory_context:
messages.append(contract.memory_context)
if contract.temporal_context:
messages.append(contract.temporal_context)
if contract.safety_guidelines:
messages.append("行为边界:\n" + contract.safety_guidelines) # 道体安全(先于司命动态约束)
if contract.safety_constraints:
messages.append(
"安全约束:\n" + "\n".join(f"- {item}" for item in contract.safety_constraints)
)
# 沙箱工作目录提示:避免模型在 shell 中使用 Linux 特有路径(如 /home/user、/tmp)
# 导致 macOS 上沙箱执行时 cd 失败。
messages.append(
"当前运行在隔离沙箱中,所有文件操作(read_file/write_file/shell)的工作目录"
"是沙箱隔离的 workspace。系统并非 Linux,/home/user、/root 等路径不存在。"
"不要 cd 到这些目录;直接在 workspace 中对文件进行读写或执行命令。"
)
return messages
# 首轮对话自动重命名:用默认模型生成简短标题,失败静默不影响对话主流程。
_TITLE_MAX_CHARS = 40
_TITLE_STRIP_CHARS = "\"'“”‘’「」『』"
async def _call_model(system_prompt: str, model_hint: str) -> str:
"""调用 LLM 返回纯文本。"""
manager = _get_provider_manager()
messages = manager.to_message_list([system_prompt], [('user', system_prompt)])
response = await manager.chat(messages, model_hint=model_hint)
return response.content
async def _generate_session_title(user_content: str, assistant_content: str) -> str:
"""用默认模型根据首轮对话内容生成简短会话标题。"""
manager = _get_provider_manager()
messages = manager.to_message_list(
[
"你会根据一段对话生成简短的中文会话标题。"
"要求:不超过 16 个字;概括对话主题;直接输出标题文字;"
"不要加引号、书名号、句号,不要解释。"
],
[("user", user_content), ("assistant", assistant_content)],
)
response = await manager.chat(messages, model_hint=settings.default_model_hint)
raw = response.content.strip().split("\n", 1)[0].strip()
return raw.strip(_TITLE_STRIP_CHARS)[:_TITLE_MAX_CHARS]
class SendChatMessageUseCase:
"""完成消息持久化与运行态记录;LLM 流式回复在后台任务中边产出边推 SSE 事件。"""
def __init__(
self,
session: AsyncSession,
*,
owner_scope: str = settings.default_owner_scope,
tool_runtime: ToolRuntime | None = None,
confirmation_registry: ConfirmationRegistry | None = None,
confirmation_store: ConfirmationStore | None = None,
event_store: EventStore | None = None,
agent_run_registry: AgentRunRegistry | None = None,
python_skill_catalog: PythonSkillCatalog | None = None,
) -> None:
self._session = session
self._owner_scope = owner_scope
self._tool_runtime = tool_runtime
self._confirmation_registry = confirmation_registry
self._confirmation_store = confirmation_store
self._event_store = event_store
self._agent_run_registry = agent_run_registry
self._python_skill_catalog = python_skill_catalog
async def execute(self, session_id: str, payload: SendMessageRequest) -> SendMessageResponse:
validate_text_message(modality=payload.modality, content=payload.content)
character = await resolve_active_character(self._session, owner_scope=self._owner_scope)
chat_session = await chat_repo.get_session(
self._session, session_id, owner_scope=self._owner_scope
)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
if payload.prior_versions:
# 重答:复用原文用户消息,不新建 user 消息,避免刷新后出现重复的消息对。
recent = await chat_repo.list_recent_messages(
self._session,
chat_session.id,
limit=10,
)
existing_user = None
for msg in reversed(recent):
if msg.role == "user" and msg.deleted_at is None:
existing_user = msg
break
if existing_user is None:
raise AppError("not_found", "要重答的原始用户消息不存在。")
user_message = existing_user
else:
user_message = ChatMessage(
character_id=character.id,
session_id=chat_session.id,
role="user",
content=payload.content,
modality="text",
metadata_json=dumps({}),
sensitivity="low",
)
self._session.add(user_message)
await self._session.flush()
model_hint = payload.model_hint or default_model_hint(character)
session_goal = extract_session_goal(payload.content)
run = await runtime_service.create_run(
self._session,
character_id=character.id,
session_id=chat_session.id,
model_hint=model_hint,
goal=session_goal or "回复用户消息",
)
user_message.run_id = run.id
await runtime_service.append_event(
self._session,
run_id=run.id,
character_id=character.id,
event_type="run.started",
payload={"run_id": run.id, "session_id": chat_session.id},
)
# 先提交 run + run.started:HTTP 响应随后返回 run_id,前端据此建立 SSE;
# 后台流式在独立 session 中边产出边提交,前端 SSE(DB 轮询)即可实时读到增量。
await self._session.commit()
task = asyncio.create_task(
self._stream_assistant_response(
run_id=run.id,
character_id=character.id,
chat_session_id=chat_session.id,
payload=payload,
)
)
_running_stream_tasks.add(task)
task.add_done_callback(_running_stream_tasks.discard)
from app.core.config import settings as _settings
return SendMessageResponse(
message_id=user_message.id,
assistant_message_id=None,
run_id=run.id,
session_id=chat_session.id,
character_id=character.id,
status=run.status,
stream_url=f"{_settings.api_prefix}/runs/{run.id}/stream",
)
async def _stream_assistant_response(
self,
*,
run_id: str,
character_id: str,
chat_session_id: str,
payload: SendMessageRequest,
) -> None:
"""后台流式:独立 session 完成 LLM 调用与事件推送,异常定稿为 run.failed。"""
try:
async with AsyncSessionLocal() as session:
await self._produce_assistant_stream(
session,
run_id=run_id,
character_id=character_id,
chat_session_id=chat_session_id,
payload=payload,
)
except TimeoutError:
await self._fail_stream(
run_id=run_id,
character_id=character_id,
code="run_timeout",
message="运行超时。",
)
except Exception:
logger.exception("chat_stream_failed", run_id=run_id)
await self._fail_stream(
run_id=run_id,
character_id=character_id,
code="run_failed",
message="运行失败,请稍后重试。",
)
async def _produce_assistant_stream(
self,
session: AsyncSession,
*,
run_id: str,
character_id: str,
chat_session_id: str,
payload: SendMessageRequest,
) -> None:
character = await resolve_active_character(session, owner_scope=self._owner_scope)
model_hint = payload.model_hint or default_model_hint(character)
run = await runtime_repo.get_run(session, run_id)
if run is None or run.status in TERMINAL_RUN_STATUS:
return
session_goal = run.goal or ""
# 重答场景(prior_versions):在构建上下文前软删除被替换的旧 assistant 消息,
# 保留原文用户消息不动——用户消息已在 execute() 中复用,不新建、不删除。
if payload.prior_versions:
latest = await chat_repo.list_recent_messages(
session,
chat_session_id,
limit=5,
)
# latest 按 created_at ASC 排列且仅含未删除消息,从末尾往前找最后一个 assistant
for i in range(len(latest) - 1, -1, -1):
if latest[i].role == "assistant" and latest[i].deleted_at is None:
latest[i].deleted_at = utc_now()
break
await session.commit()
safety = await decide_chat_safety(
session,
character_id=character_id,
run_id=run_id,
)
personality = await build_personality_summary(session, character)
memories = await retrieve_memories(
session,
character_id,
limit=settings.memory_injection_limit,
)
memory_context = build_memory_context(memories)
# 息壤推送:将检索到的记忆以结构化数据推送到前端 SSE 事件流
if memories.memories:
memory_items = []
for m in memories.memories:
memory_items.append(
{
"id": m.id,
"title": m.title,
"content": m.content,
"source": m.source,
"confidence": m.confidence,
"importance": m.importance,
"type": m.type,
}
)
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="memory.retrieved",
payload={"memories": memory_items},
)
await session.commit()
recent_messages = await recent_chat_messages(session, chat_session_id)
trace_id = current_trace_id()
# 辰正 temporal_context(设计 §8.1):对话入口算时态快照 + 发 time.* 事件到当前 run。
# 降级原则(§12):任何失败 → temporal_context=None,不注入,不阻塞对话。
temporal_context: str | None = None
if self._event_store is not None:
try:
from app.modules.time_sense.modulation import ModulationInput
from app.services.time_sense import compute_live_snapshot
time_result = await compute_live_snapshot(
session,
self._event_store,
character_id,
ModulationInput(),
run_id=run_id,
owner_scope=self._owner_scope,
)
temporal_context = time_result.context_text or None
except Exception: # noqa: BLE001
logger.warning("temporal_context 计算失败,已跳过", run_id=run_id)
# 灵核情绪状态注入(设计 §11.5):拉模式 compute_emotion_prompt。
# 降级原则(§12):任何失败 → emotion_state=None,不注入,不阻塞对话。
emotion_state: str | None = None
try:
from app.services.personality_emotion import compute_emotion_prompt
emotion_state = await compute_emotion_prompt(session, character_id)
except Exception: # noqa: BLE001
logger.warning("emotion_state 计算失败,已跳过", run_id=run_id)
# 世谱(Lorebook)注入:扫对话历史匹配命中条目,注入 before/after(设计 §7)
# 降级原则:任何失败 → 不注入,不阻塞对话。
lorebook_before: str | None = None
lorebook_after: str | None = None
try:
from app.services.lorebook import build_lorebook_injection
scan_parts = [payload.content]
scan_parts.extend(
m.content for m in recent_messages[-8:] if m.role in ("user", "assistant")
)
lorebook_before, lorebook_after = await build_lorebook_injection(
session,
character_id,
self._owner_scope,
"\n".join(scan_parts),
)
except Exception: # noqa: BLE001
logger.warning("世谱注入失败,已跳过", run_id=run_id)
profile_data = loads_dict(character.profile_json or "{}")
contract = RuntimeContract(
character_id=character_id,
session_id=chat_session_id,
run_id=run_id,
model_hint=model_hint,
agent=DEFAULT_CHAT_AGENT,
system_instructions=[],
system_prompt=profile_data.get("system_prompt") or None,
session_goal=session_goal or None,
personality_summary=personality.text,
mission=character.mission,
safety_guidelines=character.safety_guidelines,
lorebook_before=lorebook_before,
lorebook_after=lorebook_after,
emotion_state=emotion_state,
memory_context=memory_context,
temporal_context=temporal_context,
recent_messages=recent_messages,
safety_constraints=safety.constraints,
trace_id=trace_id,
)
# 道体完整性校验(§6):确认恒定层未被动态注入在数据层覆盖
from app.modules.runtime.integrity import validate_daoti_integrity
validate_daoti_integrity(
contract,
mission=character.mission,
safety_guidelines=character.safety_guidelines,
)
# —— 活跃目标注入 ——
try:
from app.services.governance import load_active_goals_context
active_goals = await load_active_goals_context(character.id)
if active_goals:
contract.active_goals = active_goals
except Exception:
logger.warning("活跃目标加载失败,跳过注入")
await record_audit_event(
session,
action="model.request.started",
character_id=character_id,
run_id=run_id,
actor="system",
decision="auto",
input_summary=model_hint,
resource_type="run",
resource_id=run_id,
)
system_messages = compose_system_messages(
_contract_system_messages(contract),
contract.agent,
[],
)
# ── 百工技能执行引擎:匹配 → prompt 注入 → 工具绑定注册 ──
from app.application.skill_executor import SkillExecutor
tool_view = RunScopedToolView(self._tool_runtime) if self._tool_runtime is not None else None
skill_exec = SkillExecutor(
tool_view=tool_view,
event_store=self._event_store,
)
try:
await skill_exec.match_and_activate(
payload.content,
system_messages,
run_id=run_id,
character_id=character_id,
skill_refs=contract.agent.skill_refs if contract.agent else (),
)
except Exception: # noqa: BLE001
logger.warning("skill_executor 匹配失败,已跳过")
agent_task = None
completed_plan_step_ids: set[str] = set()
# ── 洛书 Deliberative 规划 ──
if settings.enable_deliberative_planning:
from app.application.planning import (
build_planning_prompt,
format_plan_for_prompt,
parse_plan,
should_plan,
)
if should_plan(payload.content):
try:
planner_prompt = build_planning_prompt(
payload.content,
memory_context=memory_context[:300] if memory_context else '',
)
planner_raw = await _call_model(planner_prompt, model_hint)
plan = parse_plan(planner_raw)
if plan:
agent_task = task_from_deliberative_plan(
plan,
run_id=run_id,
agent_name=contract.agent.name if contract.agent else "默认 Agent",
objective=payload.content,
)
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="plan.created",
source="planner",
payload=task_plan_payload(agent_task),
)
await session.commit()
plan_text = format_plan_for_prompt(plan)
system_messages.append(
"【当前计划】\n请按以下计划逐步执行。"
"每一步完成后标记完成,再进入下一步。"
"全部完成后回复总结。\n\n" + plan_text
)
logger.info(f"洛书生成 deliberative plan: {plan.title} ({len(plan.steps)} 步)")
except Exception:
logger.warning('deliberative 规划生成失败,回退 ReAct')
python_skills: tuple[PythonSkillDefinition, ...] = ()
if self._python_skill_catalog is not None:
python_skills = self._python_skill_catalog.match(
payload.content,
skill_refs=contract.agent.skill_refs if contract.agent else (),
metadata={
"run_id": run_id,
"session_id": chat_session_id,
"character_id": character_id,
"model_hint": model_hint,
},
)
for skill in python_skills:
if skill.instructions:
system_messages.append(f"【Python Skill:{skill.name}】\n{skill.instructions}")
manager = _get_provider_manager()
messages = manager.to_message_list(
system_messages,
history_pairs(recent_messages),
)
reasoning_buf = ""
content_buf = ""
full_content = ""
had_reasoning = False
reasoning_finalized = False
last_model = model_hint
async def flush_reasoning(*, force: bool = False) -> None:
nonlocal reasoning_buf
while reasoning_buf and (len(reasoning_buf) >= _REASONING_FLUSH_CHARS or force):
cut = (
_REASONING_FLUSH_CHARS
if len(reasoning_buf) >= _REASONING_FLUSH_CHARS
else len(reasoning_buf)
)
delta, reasoning_buf = reasoning_buf[:cut], reasoning_buf[cut:]
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="reasoning.delta",
source="model",
payload={"delta": delta},
)
await session.commit()
async def flush_content(*, force: bool = False) -> None:
nonlocal content_buf
while content_buf and (len(content_buf) >= _CONTENT_FLUSH_CHARS or force):
cut = (
_CONTENT_FLUSH_CHARS
if len(content_buf) >= _CONTENT_FLUSH_CHARS
else len(content_buf)
)
delta, content_buf = content_buf[:cut], content_buf[cut:]
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="message.delta",
source="model",
payload={"delta": delta},
)
await session.commit()
async def finalize_reasoning() -> None:
"""收尾思考阶段:冲刷残余 reasoning 并推送 reasoning.completed。
在第一段正文出现前调用,确保思考摘要完整先于正文;流结束兜底再调一次。
多轮 Agent 场景下,每轮模型输出的 content 出现前都会触发,已被 reasoning_finalized
标记短路——即仅第一段 reasoning 完整 flush 后发 completed,后续轮次的 reasoning
仍经 on_chunk 实时 flush,但不再发 completed(避免事件流断裂)。
思考模式关闭时(payload.thinking=False)不处理任何 reasoning 事件,
防止模型仍产出 reasoning_content 导致前端 ThoughtBlock 误展示。
"""
nonlocal reasoning_finalized
if reasoning_finalized or not payload.thinking:
return
reasoning_finalized = True
await flush_reasoning(force=True)
if had_reasoning:
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="reasoning.completed",
source="model",
payload={},
)
await session.commit()
async def on_chunk(chunk: ChatChunk) -> None:
"""把 Agent 流式 chunk 喂给现有 buf/flush/finalize 逻辑,事件行为与单轮流式等价。"""
nonlocal reasoning_buf, content_buf, full_content, had_reasoning
# 思考模式关闭时不处理 reasoning chunk,避免前端 ThoughtBlock 错误展示
if payload.thinking and chunk.delta_reasoning:
had_reasoning = True
reasoning_buf += chunk.delta_reasoning
await flush_reasoning()
if chunk.delta_content:
# 正文开始前先收尾思考,保证思考摘要完整先于正文
await finalize_reasoning()
content_buf += chunk.delta_content
full_content += chunk.delta_content
await flush_content()
# 收集工具调用信息,最终存入消息 metadata 供前端持久化展示
tool_calls: list[dict[str, object]] = []
_started_tool_calls: dict[str, dict[str, object]] = {}
_plan_steps_by_tool_call = {}
async def _reset_turn_state() -> None:
"""每轮 Agent 开始前重置 reasoning 标记(多轮场景支持)。
Agent loop 首轮产出 reasoning → content → tool_calls,次轮可能再产出 reasoning。
若不重置,reasoning_finalized=True 导致 reasoning.completed 无法再发出。
"""
nonlocal had_reasoning, reasoning_finalized
had_reasoning = False
reasoning_finalized = False
async def on_tool_event(event_type: str, payload_dict: dict[str, object]) -> None:
"""工具生命周期事件:作为 agent 事件落库 + 收集 tool_calls 入 metadata。"""
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type=event_type,
source="agent",
payload=payload_dict,
)
await session.commit()
# 收集工具调用信息供前端持久化展示
if event_type == "tool.started":
tc_id = str(payload_dict.get("tool_call_id", ""))
tool_name = str(payload_dict.get("tool_name", ""))
if agent_task is not None:
step = next_step_for_tool(agent_task, tool_name, completed_plan_step_ids)
if step is not None:
_plan_steps_by_tool_call[tc_id] = step
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="plan.step.started",
source="planner",
payload=step_event_payload(agent_task, step, status="running"),
)
await session.commit()
_started_tool_calls[tc_id] = {
"tool_name": str(payload_dict.get("tool_name", "")),
"tool_call_id": tc_id,
"args": str(payload_dict.get("args", "")),
"status": "running",
}
elif event_type in ("tool.completed", "tool.failed"):
tc_id = str(payload_dict.get("tool_call_id", ""))
entry = _started_tool_calls.pop(tc_id, None)
step = _plan_steps_by_tool_call.pop(tc_id, None)
if step is not None and agent_task is not None:
tool_status = str(payload_dict.get("status", "error"))
step_status = "completed" if tool_status == "success" else "failed"
if step_status == "completed":
completed_plan_step_ids.add(step.id)
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type=f"plan.step.{step_status}",
source="planner",
payload=step_event_payload(
agent_task,
step,
status=step_status,
result_summary=str(payload_dict.get("result", ""))[:500] or None,
),
)
await session.commit()
if entry is not None:
entry["status"] = str(payload_dict.get("status", "error"))
entry["result"] = str(payload_dict.get("result", ""))
tool_calls.append(entry)
extensions = ExtensionRegistry()
python_skill_activation = await activate_python_skills(extensions, python_skills)
if python_skills:
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="skill.activated",
source="agent",
payload={"skills": [skill.name for skill in python_skills], "kind": "python"},
)
await session.commit()
async def persist_agent_event(event: AgentEvent, _run_context: AgentRunContext) -> None:
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type=event.type,
source="agent",
payload=event.payload,
)
await session.commit()
extensions.add_event_listener(persist_agent_event)
on_confirm_timeout = None
if self._confirmation_store is not None:
confirmation_store = self._confirmation_store
async def on_confirm_timeout(confirmation_id: str) -> None:
await confirmation_store.mark_timed_out(confirmation_id)
agent = Agent(
AgentConfig(
tools=tuple(
default_tools(
tool_view.specs_by_name(),
allowed_tools=contract.agent.tool_policy.allowed_tools
if contract.agent
else (),
)
if tool_view is not None
else ()
),
tool_runtime=tool_view,
model_hint=(contract.agent.model_hint or model_hint)
if contract.agent
else model_hint,
thinking_enabled=payload.thinking,
reasoning_effort=payload.reasoning_effort,
max_iterations=contract.agent.execution_policy.max_iterations
if contract.agent
else settings.agent_max_iterations,
confirmation_registry=self._confirmation_registry,
confirmation_timeout=settings.confirmation_timeout_seconds,
on_turn=_reset_turn_state,
on_chunk=on_chunk,
on_tool_event=on_tool_event,
on_confirm_timeout=on_confirm_timeout,
),
messages=messages,
run_context=AgentRunContext(
run_id=run_id,
session_id=chat_session_id,
character_id=character_id,
trace_id=trace_id,
),
extensions=extensions,
# 保留模块级符号作为注入点,现有测试与外部集成无需重写。
runner=run_agent_turn,
)
if self._agent_run_registry is not None:
await self._agent_run_registry.register(run_id, agent)
try:
run = await runtime_repo.get_run(session, run_id)
if run is None or run.status in TERMINAL_RUN_STATUS:
return
async with asyncio.timeout(settings.run_timeout_seconds):
agent_result = await agent.continue_run()
finally:
if self._agent_run_registry is not None:
await self._agent_run_registry.unregister(run_id, agent)
await python_skill_activation.close()
# 流结束兜底:模型可能只输出思考无正文,或残余 buffer 未达阈值。
await finalize_reasoning()
await flush_content(force=True)
# 取消可能发生在模型返回与持久化之间;终态 Run 不再写助手消息或覆盖状态。
run = await runtime_repo.get_run(session, run_id)
if run is None or run.status in TERMINAL_RUN_STATUS:
return
# 以 agent_result.content 作为权威最终回复正文持久化(应与 full_content 一致;
# 不一致时以 agent 循环产出为准,因后者覆盖多轮模型最终输出)。
final_content = agent_result.content or full_content
version_ts = utc_now().isoformat()
# 构造版本历史:prior_versions 前置 + 当前回复作为最新版本
prior = payload.prior_versions or []
all_versions = [*prior, {"content": final_content, "created_at": version_ts}]
active_idx = len(all_versions) - 1 # 始终最新
assistant = ChatMessage(
character_id=character_id,
session_id=chat_session_id,
role="assistant",
content=final_content,
modality="text",
run_id=run_id,
metadata_json=dumps(
{
"model_hint": model_hint,
"model": last_model,
"tool_calls": tool_calls,
}
),
sensitivity="low",
versions_json=dumps(all_versions),
active_version_idx=active_idx,
)
session.add(assistant)
await session.flush()
await record_audit_event(
session,
action="model.response.completed",
character_id=character_id,
run_id=run_id,
actor="model",
decision="auto",
output_summary="模型响应已完成。",
resource_type="message",
resource_id=assistant.id,
)
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="message.completed",
source="model",
payload={"message_id": assistant.id, "content": final_content},
)
run = await runtime_repo.get_run(session, run_id)
if run is not None and run.status not in TERMINAL_RUN_STATUS:
await runtime_service.complete_run(session, run)
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="run.completed",
payload={"run_id": run_id},
)
await session.commit()
# 首轮对话完成后,用默认模型自动生成会话标题(失败不影响主流程)
await self._maybe_rename_session(
session,
chat_session_id=chat_session_id,
user_content=payload.content,
assistant_content=final_content,
)
# 每轮对话后检查大周天触发条件(失败不影响主流程)
try:
from app.application.big_cycle import maybe_trigger_big_cycle
await maybe_trigger_big_cycle(character_id, trigger="session_turn")
except Exception: # noqa: BLE001
logger.warning("大周天触发检查失败,已跳过")
# 小周天每轮记忆候选 + 返照反思(后台任务,失败不影响主流程)
try:
from app.application.reflection import start_reflection_after_turn
start_reflection_after_turn(
character_id=character_id,
session_id=chat_session_id,
user_content=payload.content,
assistant_content=final_content,
)
except Exception: # noqa: BLE001
logger.warning("小周天反思触发失败,已跳过")
# —— 会话目标自动持久化 ——
try:
from app.db.session import AsyncSessionLocal as GoalSession
from app.services.goal_persist import auto_persist_session_goal
async with GoalSession() as goal_session:
await auto_persist_session_goal(goal_session, character.id, payload.content)
await goal_session.commit()
except Exception:
logger.warning("会话目标自动持久化失败,跳过")
async def _maybe_rename_session(
self,
session: AsyncSession,
*,
chat_session_id: str,
user_content: str,
assistant_content: str,
) -> None:
"""首轮对话完成后用 LLM 生成会话标题;已有标题或非首轮则跳过,失败静默。"""
try:
existing = await chat_repo.get_session(session, chat_session_id)
if existing is None or existing.title:
return
assistant_count = await chat_repo.count_messages_by_role(
session, chat_session_id, role="assistant"
)
if assistant_count != 1:
return
title = await _generate_session_title(user_content, assistant_content)
if not title:
return
await chat_repo.update_session_title(session, chat_session_id, title)
await session.commit()
logger.info("session_auto_renamed", session_id=chat_session_id, title=title)
except Exception:
logger.warning("session_title_generate_failed", session_id=chat_session_id)
async def _fail_stream(
self,
*,
run_id: str,
character_id: str,
code: str,
message: str,
) -> None:
async with AsyncSessionLocal() as session:
run = await runtime_repo.get_run(session, run_id)
if run is None or run.status in TERMINAL_RUN_STATUS:
return
await runtime_service.fail_run(session, run, code=code, message=message)
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="run.failed",
payload={"code": code, "message": message},
)
await session.commit()
-150
View File
@@ -1,150 +0,0 @@
"""小周天每轮记忆候选 + 返照反思(异步后台任务,跨 modules + ai_provider)。
言灵表达(流式回复完成)后触发:从本轮对话抽取记忆候选(待审核)+ 生成反思
(直接建 memory type=reflection)。失败静默,不影响主流程。仅适用单进程;
多 worker 部署需替换为跨进程任务队列。
"""
import asyncio
from app.ai_provider import ProviderManager, ProviderManagerFactory
from app.core.config import settings
from app.core.logging import logger
from app.db.session import AsyncSessionLocal
from app.models.candidates import MemoryCandidate
from app.models.memory import Memory
from app.modules.reflection.extract import parse_reflection
from app.modules.reflection.prompts import build_reflection_messages
_running_reflection_tasks: set[asyncio.Task[None]] = set()
_provider_manager: ProviderManager | None = None
def _get_provider_manager() -> ProviderManager:
global _provider_manager
if _provider_manager is None:
_provider_manager = ProviderManagerFactory.create()
return _provider_manager
async def drain_pending_reflections() -> None:
"""等待所有进行中的反思任务完成(测试断言 / 优雅停机用)。"""
if _running_reflection_tasks:
await asyncio.gather(*_running_reflection_tasks, return_exceptions=True)
def start_reflection_after_turn(
character_id: str,
session_id: str,
user_content: str,
assistant_content: str,
) -> None:
"""每轮对话后启动反思抽取(后台任务);session_id 留作未来事件回写。"""
task = asyncio.create_task(
_run_reflection(character_id, session_id, user_content, assistant_content)
)
_running_reflection_tasks.add(task)
task.add_done_callback(_running_reflection_tasks.discard)
async def _run_reflection(
character_id: str,
session_id: str,
user_content: str,
assistant_content: str,
) -> None:
async with AsyncSessionLocal() as session:
try:
system, user = build_reflection_messages(user_content, assistant_content)
raw = await _call_model(system, user, character_id)
memory_drafts, reflection_text = parse_reflection(raw)
memory_drafts = memory_drafts[: settings.big_cycle_max_candidates]
# 小周天记忆候选:高置信度(≥0.7)低敏感度直接落地为 Memory,
# 其余留 pending 待大周天统一审核
auto_memories = 0
pending_count = 0
for draft in memory_drafts:
if draft.confidence >= 0.7 and draft.sensitivity in ("low", "none"):
session.add(
Memory(
character_id=character_id,
type=draft.type,
title=draft.title,
content=draft.content,
source="reflection",
importance=draft.importance,
sensitivity=draft.sensitivity,
confidence=draft.confidence,
)
)
auto_memories += 1
else:
session.add(
MemoryCandidate(
character_id=character_id,
type=draft.type,
title=draft.title,
content=draft.content,
confidence=draft.confidence,
importance=draft.importance,
sensitivity=draft.sensitivity,
reason=draft.reason,
status="pending",
)
)
pending_count += 1
if reflection_text:
title = reflection_text[:60] + ("…" if len(reflection_text) > 60 else "")
session.add(
Memory(
character_id=character_id,
type="reflection",
title=title,
content=reflection_text,
source="reflection",
importance=0.5,
sensitivity="low",
)
)
await session.commit()
logger.info(
f"小周天反思 character={character_id} session={session_id} "
f"auto={auto_memories} pending={pending_count} "
f"reflection={'yes' if reflection_text else 'no'}"
)
# 推送反思完成事件(供叩心枢/前端观测)
try:
from app.modules.runtime.events import Event
from app.services.event_store import DbEventStore
store = DbEventStore(AsyncSessionLocal)
await store.append(
Event(
type="reflection.completed",
source="reflection",
visibility="public",
character_id=character_id,
payload={
"session_id": session_id,
"auto_memories": auto_memories,
"pending_count": pending_count,
"has_reflection": bool(reflection_text),
"reflection_preview": (reflection_text or "")[:100],
},
)
)
except Exception: # noqa: BLE001
logger.warning("推送 reflection.completed 事件失败")
except Exception as exc: # noqa: BLE001 — 后台任务失败静默
logger.warning(f"小周天反思失败 character={character_id}: {exc}")
async def _call_model(system: str, user: str, _character_id: str) -> str:
manager = _get_provider_manager()
messages = manager.to_message_list([system], [("user", user)])
response = await manager.chat(messages, model_hint=settings.big_cycle_model)
return response.content
+10
View File
@@ -14,6 +14,16 @@ class RuntimeConfig(BaseSettings):
sse_poll_interval_seconds: float = 0.01 # SSE 轮询间隔(秒,10ms:前端逐段平滑增量) sse_poll_interval_seconds: float = 0.01 # SSE 轮询间隔(秒,10ms:前端逐段平滑增量)
sse_heartbeat_seconds: float = 15.0 # SSE 心跳间隔(秒) sse_heartbeat_seconds: float = 15.0 # SSE 心跳间隔(秒)
run_timeout_seconds: int = 120 # 运行超时时间(秒) run_timeout_seconds: int = 120 # 运行超时时间(秒)
# ==================== 神识(模型网关,rewrite/1.0) ====================
shenshi_base_url: str = "" # 神识服务地址;为空时 Kernel 使用进程内回声桩(M0 骨架)
shenshi_service_token: str = "" # 灵台→神识服务令牌(开发档可空)
shenshi_timeout_seconds: float = 120.0 # 神识流式调用超时(秒)
# ==================== 镜生域(rewrite/1.0 M1) ====================
identity_record_secret: str = "dev-identity-secret" # 魂籍签名密钥;正式部署必须改掉默认值
death_calm_period_hours: int = 72 # 死亡冷静期时长(小时),期内可撤销
seat_lease_ttl_seconds: int = 60 # 命台租约 TTL(秒)
# Agent 循环最大轮数:模型每轮仍带工具调用则继续,超限截断返回(见 agent.runtime.DEFAULT_MAX_ITERATIONS) # Agent 循环最大轮数:模型每轮仍带工具调用则继续,超限截断返回(见 agent.runtime.DEFAULT_MAX_ITERATIONS)
agent_max_iterations: int = 8 agent_max_iterations: int = 8
# 单次高风险工具确认挂起超时(秒):须小于 run_timeout_seconds,超时按拒绝处理; # 单次高风险工具确认挂起超时(秒):须小于 run_timeout_seconds,超时按拒绝处理;
+44
View File
@@ -115,6 +115,50 @@ def new_user_id() -> str:
return new_id("user_") return new_id("user_")
def new_embryo_id() -> str:
return new_id("embryo_")
def new_custodianship_id() -> str:
return new_id("cust_")
def new_seat_id() -> str:
return new_id("seat_")
def new_identity_record_id() -> str:
return new_id("identity_")
def new_mirror_tree_id() -> str:
return new_id("tree_")
def new_tree_node_id() -> str:
return new_id("node_")
def new_citation_id() -> str:
return new_id("cite_")
def new_canonization_id() -> str:
return new_id("canon_")
def new_transplant_id() -> str:
return new_id("transplant_")
def new_memory_trigger_id() -> str:
return new_id("memory_trigger_")
def new_factual_record_id() -> str:
return new_id("frecord_")
def new_role_id() -> str: def new_role_id() -> str:
return new_id("rl_") return new_id("rl_")
+5
View File
@@ -13,3 +13,8 @@ def to_iso(value: datetime | None) -> str | None:
if value.tzinfo is None: if value.tzinfo is None:
value = value.replace(tzinfo=UTC) value = value.replace(tzinfo=UTC)
return value.astimezone(UTC).isoformat().replace("+00:00", "Z") return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
def as_utc(value: datetime) -> datetime:
"""SQLite 读回 naive datetime 时补 UTC;PG(timezone=True) 读回 aware 则原样返回。"""
return value if value.tzinfo else value.replace(tzinfo=UTC)
+1
View File
@@ -0,0 +1 @@
"""六域领域层(rewrite/1.0)。镜生域先行,其余域随里程碑落地。"""
+37
View File
@@ -0,0 +1,37 @@
"""镜生域:灵胚、启镜、执镜关系、命台、魂籍、死亡与冷静期(data-model.md §1)。"""
from app.domains.jingsheng.identity import build_identity_record, verify_identity_record
from app.domains.jingsheng.seat import SeatConflict, acquire_lease, emit_seat_event, release_lease
from app.domains.jingsheng.service import (
abandon_embryo,
activate_embryo,
bind_event_store,
create_embryo,
death_cancel,
death_finalize,
death_request,
get_embryo,
list_embryos,
record_rehearsal,
)
from app.domains.jingsheng.service import patch_embryo
__all__ = [
"SeatConflict",
"abandon_embryo",
"acquire_lease",
"activate_embryo",
"bind_event_store",
"build_identity_record",
"create_embryo",
"death_cancel",
"death_finalize",
"death_request",
"emit_seat_event",
"get_embryo",
"list_embryos",
"patch_embryo",
"record_rehearsal",
"release_lease",
"verify_identity_record",
]
+82
View File
@@ -0,0 +1,82 @@
"""魂籍(portable_identity_record):可验证、版本化的身份与状态包。
完整性模型:
- payload 规范化序列化(排序键、紧凑分隔符)后取 SHA-256 摘要;
- 签名 = HMAC-SHA256(密钥, "{character_id}|{version}|{digest}"),绑定人物与版本;
- 校验:重算摘要与签名比对。篡改 payload 破坏摘要;把记录复制到别的人物/版本,
签名不匹配——文件相同或人物 ID 相同不足以冒充原主体(data-model.md §1)。
"""
from __future__ import annotations
import hashlib
import hmac
import json
from app.core.config import settings
from app.models.jingsheng import IdentityRecord
def _canonical(payload: dict) -> str:
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _digest(payload: dict) -> str:
return hashlib.sha256(_canonical(payload).encode("utf-8")).hexdigest()
def _signature(character_id: str, version: int, digest: str) -> str:
secret = settings.identity_record_secret
message = f"{character_id}|{version}|{digest}".encode("utf-8")
return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
def build_identity_record(
*,
character_id: str,
version: int,
identity: dict,
runtime_state: dict,
data_state: dict | None = None,
) -> IdentityRecord:
"""构建一条魂籍版本记录(调用方负责落库)。"""
payload = {
"spec": "hunjing.portable_identity_record.v1",
"character_id": character_id,
"version": version,
"identity": identity,
"data_state": data_state or {"note": "数据状态由各域自报(M1 占位)"},
"runtime_state": runtime_state,
}
digest = _digest(payload)
return IdentityRecord(
character_id=character_id,
version=version,
payload_json=json.dumps(payload, ensure_ascii=False),
digest=digest,
signature=_signature(character_id, version, digest),
)
class IdentityVerificationError(Exception):
"""校验失败:摘要或签名不匹配。"""
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
def verify_identity_record(record: IdentityRecord) -> dict:
"""校验魂籍记录,返回规范化 payload;失败抛 IdentityVerificationError。"""
try:
payload = json.loads(record.payload_json)
except json.JSONDecodeError as exc:
raise IdentityVerificationError("payload 无法解析") from exc
if _digest(payload) != record.digest:
raise IdentityVerificationError("摘要不匹配(payload 已被篡改)")
expected = _signature(record.character_id, record.version, record.digest)
if not hmac.compare_digest(expected, record.signature):
raise IdentityVerificationError("签名不匹配(非本平台签发或被移植冒用)")
if payload.get("character_id") != record.character_id or payload.get("version") != record.version:
raise IdentityVerificationError("payload 声明的身份与记录不符")
return payload
+85
View File
@@ -0,0 +1,85 @@
"""命台租约(authoritative_runtime_seat):同一人物唯一权威运行位置。
- 活跃租约(未过期、非本人)存在时,其他持有者获取被拒(SeatConflict);
- 租约过期可接管,epoch 递增(旧持有者写入按 epoch 失效——M2 运行时接入);
- 事件(seat.acquired / released / contested)由 API 层在事务提交后发射,
避免事务内跨连接写冲突;M1 为 API 级租约,Kernel 前台串行仍为进程内锁,多进程一致在 M2 收口。
"""
from __future__ import annotations
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import as_utc, utc_now
from app.models.jingsheng import RuntimeSeat
class SeatConflict(Exception):
"""命台被其他持有者占用(HJ-1101 语义);调用方须留痕 seat.contested。"""
def __init__(self, seat: RuntimeSeat) -> None:
super().__init__(f"命台被 {seat.holder} 占用至 {seat.lease_expires_at}")
self.seat = seat
async def acquire_lease(
session: AsyncSession, character_id: str, holder: str, ttl_seconds: int = 60
) -> RuntimeSeat:
"""获取或续期命台租约;冲突抛 SeatConflict(不在此发事件,避免事务内跨连接写)。"""
seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
if seat is None:
seat = RuntimeSeat(character_id=character_id, epoch=0)
session.add(seat)
now = utc_now()
if (
seat.holder is not None
and seat.holder != holder
and seat.lease_expires_at is not None
and as_utc(seat.lease_expires_at) > now
):
raise SeatConflict(seat)
# 仅"从其他持有者手中接管"递增 epoch;无人持有时首次获取沿用当前纪元
takeover = seat.holder is not None and seat.holder != holder
seat.holder = holder
seat.lease_expires_at = now + timedelta(seconds=ttl_seconds)
if takeover:
seat.epoch += 1
await session.flush()
return seat
async def release_lease(session: AsyncSession, character_id: str, holder: str) -> bool:
"""释放租约;返回是否实际释放(holder 不匹配时无操作)。"""
seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
if seat is None or seat.holder != holder:
return False
seat.holder = None
seat.lease_expires_at = None
await session.flush()
return True
async def emit_seat_event(
event_type: str,
character_id: str,
holder: str | None,
epoch: int | None,
*,
requester: str | None = None,
) -> None:
"""事务外发射命台事件(一炁,visibility=internal)。"""
from app.domains import one_qi
payload: dict = {"holder": holder, "epoch": epoch}
if requester is not None:
payload["requester"] = requester
await one_qi.emit(
event_type,
character_id=character_id,
payload=payload,
visibility="internal",
source="jingsheng",
)
+352
View File
@@ -0,0 +1,352 @@
"""镜生域服务:灵胚生命周期、启镜事务、死亡请求与冷静期。"""
from __future__ import annotations
import json
from datetime import datetime, UTC
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.core.time import as_utc, utc_now
from app.db.session import AsyncSessionLocal
from app.domains.jingsheng.identity import build_identity_record
from app.models.character import Character
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat
from app.services.event_store import DbEventStore
from app.domains import one_qi
def bind_event_store(store: DbEventStore) -> None:
"""lifespan 装配时注入事件存储(委托共享绑定)。"""
one_qi.bind_event_store(store)
async def _emit(
event_type: str,
*,
character_id: str | None,
owner_scope: str,
payload: dict,
visibility: str = "public",
) -> None:
await one_qi.emit(
event_type,
character_id=character_id,
payload=payload,
visibility=visibility,
source="jingsheng",
)
def _owned(embryo: EmbryoDraft, owner_scope: str) -> EmbryoDraft:
if embryo.owner_scope != owner_scope:
raise AppError("not_found", "灵胚不存在。")
return embryo
async def list_embryos(owner_scope: str) -> list[EmbryoDraft]:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(EmbryoDraft)
.where(EmbryoDraft.owner_scope == owner_scope, EmbryoDraft.status != "abandoned")
.order_by(EmbryoDraft.created_at.desc())
)
return list(result.scalars().all())
async def create_embryo(
*,
owner_scope: str,
source: str,
name: str,
description: str | None = None,
identity_baseline: dict | None = None,
) -> EmbryoDraft:
if source not in ("original", "adopted", "imported", "serendipity"):
raise AppError("invalid_request", "灵胚来源取值不合法。")
embryo = EmbryoDraft(
owner_scope=owner_scope,
source=source,
name=name,
description=description,
identity_baseline_json=json.dumps(identity_baseline or {}, ensure_ascii=False),
)
async with AsyncSessionLocal() as session:
session.add(embryo)
await session.commit()
await session.refresh(embryo)
await _emit(
"embryo.created",
character_id=None,
owner_scope=owner_scope,
payload={"embryo_id": embryo.id, "source": source},
)
return embryo
async def get_embryo(embryo_id: str, owner_scope: str) -> EmbryoDraft:
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None or embryo.status == "abandoned":
raise AppError("not_found", "灵胚不存在。")
return _owned(embryo, owner_scope)
async def patch_embryo(
embryo_id: str,
owner_scope: str,
*,
name: str | None = None,
description: str | None = None,
identity_baseline: dict | None = None,
) -> EmbryoDraft:
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None or embryo.status == "abandoned":
raise AppError("not_found", "灵胚不存在。")
_owned(embryo, owner_scope)
if embryo.status == "activated":
raise AppError(
"HJ-1103", "灵胚已启镜,草稿不可再编辑。", status_code=409
)
if name is not None:
embryo.name = name
if description is not None:
embryo.description = description
if identity_baseline is not None:
embryo.identity_baseline_json = json.dumps(identity_baseline, ensure_ascii=False)
await session.commit()
await session.refresh(embryo)
return embryo
async def record_rehearsal(embryo_id: str, owner_scope: str, *, run_id: str, prompt: str) -> None:
"""照心试演引用落灵胚(内容在事件日志,不构成人物经历)。"""
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None or embryo.status == "abandoned":
raise AppError("not_found", "灵胚不存在。")
_owned(embryo, owner_scope)
rehearsals = json.loads(embryo.rehearsals_json)
rehearsals.append({"run_id": run_id, "prompt": prompt, "performed_at": utc_now().isoformat()})
embryo.rehearsals_json = json.dumps(rehearsals, ensure_ascii=False)
if embryo.status == "drafting":
embryo.status = "previewing"
await session.commit()
async def abandon_embryo(embryo_id: str, owner_scope: str) -> None:
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None:
raise AppError("not_found", "灵胚不存在。")
_owned(embryo, owner_scope)
if embryo.status == "activated":
raise AppError("HJ-1103", "灵胚已启镜,不能放弃;请走人物死亡流程。", status_code=409)
embryo.status = "abandoned"
await session.commit()
await _emit(
"embryo.abandoned",
character_id=None,
owner_scope=owner_scope,
payload={"embryo_id": embryo_id},
)
async def activate_embryo(embryo_id: str, owner_scope: str) -> Character:
"""启镜:单事务内签发人物、执镜关系、命台与魂籍 v1。
并发防护:以 ``UPDATE embryo_drafts SET status='confirming' WHERE status IN (可启镜态)``
的原子行数作为闸门——第二个并发启镜拿不到闸门即 409,不产生第二个人物。
"""
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None or embryo.status == "abandoned":
raise AppError("not_found", "灵胚不存在。")
_owned(embryo, owner_scope)
gate = await session.execute(
update(EmbryoDraft)
.where(EmbryoDraft.id == embryo_id, EmbryoDraft.status.in_(("drafting", "previewing", "confirming")))
.values(status="confirming")
)
if gate.rowcount != 1:
raise AppError(
"HJ-1103", "灵胚当前状态不允许启镜(可能已被并发启镜)。", status_code=409
)
baseline = json.loads(embryo.identity_baseline_json or "{}")
character = Character(
name=embryo.name,
owner_scope=owner_scope,
status="active",
source=embryo.source,
identity_baseline_json=embryo.identity_baseline_json,
profile_json=json.dumps({"description": embryo.description or ""}, ensure_ascii=False),
activated_at=utc_now(),
)
session.add(character)
await session.flush() # 取 character.id(commit 后属性过期,异步下不可再访问)
character_id = character.id
session.add(Custodianship(character_id=character.id, custodian_scope=owner_scope))
session.add(RuntimeSeat(character_id=character.id, epoch=1))
book_entry_count = await _materialize_import_book(session, baseline, character, owner_scope)
record = build_identity_record(
character_id=character.id,
version=1,
identity={
"name": character.name,
"source": character.source,
"identity_baseline": baseline,
"activated_at": character.activated_at.isoformat(),
},
runtime_state={"status": "active", "seat_epoch": 1},
data_state={"imported_book_entries": book_entry_count},
)
session.add(record)
await session.flush() # 取魂籍 ID
record_id = record.id
source = embryo.source
embryo.status = "activated"
await session.commit()
await _emit(
"character.activated",
character_id=character_id,
owner_scope=owner_scope,
payload={"embryo_id": embryo_id, "source": source, "identity_record": record_id},
)
async with AsyncSessionLocal() as session:
created = await session.get(Character, character_id)
assert created is not None
return created
async def death_request(
character_id: str, owner_scope: str, *, calm_period_hours: int
) -> Character:
"""死亡请求:进入冷静期(death_pending),可撤销;冷静期内人物默认静修。"""
async with AsyncSessionLocal() as session:
character = await _owned_character(session, character_id, owner_scope)
if character.status == "deceased":
raise AppError("HJ-1103", "人物已死亡。", status_code=409)
now = utc_now()
character.status = "death_pending"
character.death_requested_at = now
character.calm_period_until = datetime.fromtimestamp(
now.timestamp() + calm_period_hours * 3600, tz=UTC
)
await session.commit()
await session.refresh(character)
await _emit(
"character.death_requested",
character_id=character_id,
owner_scope=owner_scope,
payload={"calm_period_until": character.calm_period_until.isoformat()},
)
return character
async def death_cancel(character_id: str, owner_scope: str) -> Character:
"""冷静期撤销:恢复 active。人物不得阻挠撤销(宪章)。"""
async with AsyncSessionLocal() as session:
character = await _owned_character(session, character_id, owner_scope)
if character.status != "death_pending":
raise AppError("HJ-1103", "人物不在死亡冷静期。", status_code=409)
character.status = "active"
character.death_requested_at = None
character.calm_period_until = None
await session.commit()
await session.refresh(character)
await _emit(
"character.death_cancelled",
character_id=character_id,
owner_scope=owner_scope,
payload={},
)
return character
async def death_finalize(character_id: str, owner_scope: str) -> Character:
"""死亡落定:身份时间线闭合(deceased),数据保留——数据清除是独立操作。"""
async with AsyncSessionLocal() as session:
character = await _owned_character(session, character_id, owner_scope)
if character.status != "death_pending":
raise AppError("HJ-1103", "人物不在死亡冷静期。", status_code=409)
assert character.calm_period_until is not None
if utc_now() < as_utc(character.calm_period_until):
raise AppError(
"HJ-1103",
f"冷静期未满(至 {character.calm_period_until.isoformat()}),不能落定死亡。",
status_code=422,
)
character.status = "deceased"
character.died_at = utc_now()
seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
if seat is not None:
seat.holder = None
seat.lease_expires_at = None
await session.commit()
await session.refresh(character)
await _emit(
"character.deceased",
character_id=character_id,
owner_scope=owner_scope,
payload={"died_at": character.died_at.isoformat() if character.died_at else None},
)
return character
async def _owned_character(
session: AsyncSession, character_id: str, owner_scope: str
) -> Character:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
return character
async def _materialize_import_book(
session: AsyncSession, baseline: dict, character: Character, owner_scope: str
) -> int:
"""启镜时把导入卡的 character_book 物化为新人物的世谱书与条目(待审语义随世谱状态治理)。"""
from app.models.lorebook import Lorebook, LorebookEntry
from app.modules.imports.mapping import character_book_to_entries
book = (baseline.get("import_card") or {}).get("character_book")
entries = character_book_to_entries(book)
if not entries:
return 0
lorebook = Lorebook(
character_id=character.id,
name=f"{character.name} 的世谱(导入)",
description="角色卡 character_book 启镜物化",
scope="character",
owner_scope=owner_scope,
)
session.add(lorebook)
await session.flush()
for spec in entries:
session.add(
LorebookEntry(
lorebook_id=lorebook.id,
keys_json=json.dumps(spec["keys"], ensure_ascii=False),
content=spec["content"],
secondary_keys_json=json.dumps(spec["secondary_keys"], ensure_ascii=False),
constant=spec["constant"],
selective=spec["selective"],
enabled=spec["enabled"],
case_sensitive=spec["case_sensitive"],
use_regex=spec["use_regex"],
position=spec["position"],
insertion_order=spec["insertion_order"],
priority=spec["priority"],
comment=spec["comment"][:500],
)
)
return len(entries)
@@ -0,0 +1,47 @@
"""镜树域:树状对话、采用/归真分离、引文锚与移栽(data-model.md §2)。"""
from app.domains.mirror_tree.service import (
adopt,
ancestor_chain,
archive_tree,
canonize,
continue_node,
create_citation,
create_question,
create_tree,
edit_fork,
get_tree_view,
list_citations,
list_trees,
local_graph,
rename_tree,
node_view,
path_view,
resume_node,
spawn_answer,
transplant,
tree_view,
)
__all__ = [
"adopt",
"ancestor_chain",
"archive_tree",
"canonize",
"continue_node",
"create_citation",
"create_question",
"create_tree",
"edit_fork",
"get_tree_view",
"list_citations",
"list_trees",
"local_graph",
"rename_tree",
"node_view",
"path_view",
"resume_node",
"spawn_answer",
"transplant",
"tree_view",
]
+963
View File
@@ -0,0 +1,963 @@
"""镜树域服务:树/节点生命周期、另答、续思/续生、编辑分叉、采用、归真、引文、移栽。
核心不变量(mirror-tree PRD §4,测试钉死):
1. 重新生成永不覆盖旧候答(新行插入);
2. 后代绑定用户实际采用的候答(问节默认挂工作入口);
3. 采用 ≠ 归真:working_entry 变更不触碰 canonizations;
4. 已归真经历不因切换候答回滚(canonization 不可变);
5. 编辑已发送内容 = 新版本分叉,原文保留;
6. 归真路径与既有主脉分叉时,后到操作 409(HJ-1102),不静默替换。
"""
from __future__ import annotations
import asyncio
import json
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.domains import one_qi
from app.kernel import Kernel
from app.models.character import Character
from app.models.mirror_tree import (
Canonization,
Citation,
MirrorTree,
TreeNode,
TreeTransplant,
)
REALMS = ("reality", "persistent_world", "simulation")
SCOPES = REALMS # canonical_scope 与 realm 同值族(契约词典 §9.4/§12)
# ── 所有权与取数 ──────────────────────────────────────────────
async def _owned_tree(tree_id: str, owner_scope: str) -> MirrorTree:
async with AsyncSessionLocal() as session:
tree = await session.get(MirrorTree, tree_id)
if tree is not None:
character = await session.get(Character, tree.character_id)
if tree is None or character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "镜树不存在。")
return tree
async def _node_in_tree(session: AsyncSession, node_id: str, tree_id: str) -> TreeNode:
node = await session.get(TreeNode, node_id)
if node is None or node.tree_id != tree_id:
raise AppError("not_found", "节点不存在或不属于该树。")
return node
async def _get_node(node_id: str) -> TreeNode:
async with AsyncSessionLocal() as session:
node = await session.get(TreeNode, node_id)
if node is None:
raise AppError("not_found", "节点不存在。")
return node
def node_view(node: TreeNode, citations: list[Citation] | None = None) -> dict:
return {
"id": node.id,
"tree_id": node.tree_id,
"parent_node_id": node.parent_node_id,
"node_type": node.node_type,
"status": node.status,
"author": node.author,
"content": node.content,
"realm": node.realm,
"generation_meta": json.loads(node.generation_meta_json or "{}"),
"supersedes_node_id": node.supersedes_node_id,
"citations": [
{
"id": c.id,
"target_type": c.target_type,
"snapshot_digest": c.snapshot[:64],
"source_id": c.source_id,
"source_version": c.source_version,
}
for c in (citations or [])
],
"created_at": node.created_at.isoformat(),
}
async def _node_citations(session: AsyncSession, node_id: str) -> list[Citation]:
result = await session.execute(
select(Citation).where(Citation.node_id == node_id).order_by(Citation.created_at)
)
return list(result.scalars().all())
# ── 树 ──────────────────────────────────────────────
async def create_tree(*, character_id: str, owner_scope: str, title: str, default_realm: str = "reality") -> MirrorTree:
if default_realm not in REALMS:
raise AppError("invalid_request", "界域取值不合法。")
tree = MirrorTree(character_id=character_id, title=title, default_realm=default_realm)
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", "人物不存在。")
session.add(tree)
await session.commit()
await session.refresh(tree)
await one_qi.emit(
"tree.created",
character_id=character_id,
payload={"tree_id": tree.id, "title": title},
source="mirror_tree",
)
return tree
async def list_trees(owner_scope: str, character_id: str | None = None) -> list[MirrorTree]:
async with AsyncSessionLocal() as session:
character_ids = [
c.id
for c in (
await session.execute(
select(Character.id).where(Character.owner_scope == owner_scope)
)
).scalars()
]
if not character_ids:
return []
stmt = select(MirrorTree).where(
MirrorTree.character_id.in_(character_ids), MirrorTree.status == "active"
)
if character_id is not None:
stmt = stmt.where(MirrorTree.character_id == character_id)
stmt = stmt.order_by(MirrorTree.updated_at.desc())
return list((await session.execute(stmt)).scalars().all())
def tree_view(tree: MirrorTree) -> dict:
return {
"id": tree.id,
"character_id": tree.character_id,
"title": tree.title,
"status": tree.status,
"default_realm": tree.default_realm,
"working_entry_node_id": tree.working_entry_node_id,
"transplanted_from": json.loads(tree.transplanted_from_json or "{}"),
"created_at": tree.created_at.isoformat(),
}
async def get_tree_view(tree_id: str, owner_scope: str) -> dict:
"""树详情视图(含权限校验)。"""
async with AsyncSessionLocal() as session:
tree = await session.get(MirrorTree, tree_id)
if tree is not None:
character = await session.get(Character, tree.character_id)
if tree is None or character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "镜树不存在。")
return tree_view(tree)
async def rename_tree(tree_id: str, owner_scope: str, title: str) -> dict:
await _owned_tree(tree_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(MirrorTree, tree_id)
row.title = title
view = tree_view(row)
await session.commit()
return view
async def archive_tree(tree_id: str, owner_scope: str) -> dict:
await _owned_tree(tree_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(MirrorTree, tree_id)
row.status = "archived"
view = tree_view(row) # 会话内取视图,避免提交后属性过期
await session.commit()
return view
# ── 路径与图 ──────────────────────────────────────────────
async def ancestor_chain(node_id: str) -> list[TreeNode]:
"""root→node 的祖先链(含自身)。环与超深保护:上限 10_000 层。"""
chain: list[TreeNode] = []
current_id: str | None = node_id
async with AsyncSessionLocal() as session:
for _ in range(10_000):
if current_id is None:
break
node = await session.get(TreeNode, current_id)
if node is None:
raise AppError("not_found", f"祖先链断裂:{current_id}")
chain.append(node)
current_id = node.parent_node_id
chain.reverse()
return chain
async def path_view(tree_id: str, owner_scope: str, entry_node_id: str | None) -> dict:
tree = await _owned_tree(tree_id, owner_scope)
entry = entry_node_id or tree.working_entry_node_id
async with AsyncSessionLocal() as session:
if entry is None:
root_questions = (
await session.execute(
select(TreeNode).where(
TreeNode.tree_id == tree_id, TreeNode.parent_node_id.is_(None)
)
)
).scalars().all()
nodes = [
node_view(n, await _node_citations(session, n.id))
for n in sorted(root_questions, key=lambda n: n.created_at)
]
return {"tree_id": tree_id, "entry_node_id": None, "nodes": nodes}
node = await _node_in_tree(session, entry, tree_id)
chain = [node]
cursor = node.parent_node_id
for _ in range(10_000):
if cursor is None:
break
parent = await session.get(TreeNode, cursor)
chain.append(parent)
cursor = parent.parent_node_id
chain.reverse()
return {
"tree_id": tree_id,
"entry_node_id": entry,
"nodes": [node_view(n, await _node_citations(session, n.id)) for n in chain],
}
async def local_graph(tree_id: str, owner_scope: str, node_id: str | None, depth: int = 2) -> dict:
"""局部树图:入口的祖先(全链)+ 兄弟 + 向下 depth 层子枝(服务定位,不替代阅读)。"""
tree = await _owned_tree(tree_id, owner_scope)
entry = node_id or tree.working_entry_node_id
async with AsyncSessionLocal() as session:
nodes_out: dict[str, dict] = {}
if entry is not None:
node = await _node_in_tree(session, entry, tree_id)
chain = [node]
cursor = node.parent_node_id
while cursor is not None:
parent = await session.get(TreeNode, cursor)
chain.append(parent)
cursor = parent.parent_node_id
for n in chain:
nodes_out[n.id] = _graph_node(n)
# 兄弟(同父的其他子节点)
for n in chain:
if n.parent_node_id is not None:
stmt = select(TreeNode).where(
TreeNode.tree_id == tree_id, TreeNode.parent_node_id == n.parent_node_id
)
else:
stmt = select(TreeNode).where(
TreeNode.tree_id == tree_id, TreeNode.parent_node_id.is_(None)
)
siblings = (await session.execute(stmt)).scalars().all()
for s in siblings:
if s.id not in nodes_out:
nodes_out[s.id] = _graph_node(s)
# 向下展开
frontier: list[str] = [entry] if entry is not None else []
for _ in range(max(depth, 0)):
next_frontier: list[str] = []
for fid in frontier:
children = (
await session.execute(
select(TreeNode).where(TreeNode.parent_node_id == fid)
)
).scalars().all()
for child in children:
if child.id not in nodes_out:
nodes_out[child.id] = _graph_node(child)
next_frontier.append(child.id)
frontier = next_frontier
# 无入口(新树):给根问节
if entry is None:
roots = (
await session.execute(
select(TreeNode).where(
TreeNode.tree_id == tree_id, TreeNode.parent_node_id.is_(None)
)
)
).scalars().all()
for r in roots:
nodes_out[r.id] = _graph_node(r)
return {"tree_id": tree_id, "focus_node_id": entry, "nodes": list(nodes_out.values())}
def _graph_node(node: TreeNode) -> dict:
return {
"id": node.id,
"parent_node_id": node.parent_node_id,
"node_type": node.node_type,
"status": node.status,
"realm": node.realm,
"branch_mark": node.node_type == "answer",
"content_preview": node.content[:80],
}
# ── 问节与生成 ──────────────────────────────────────────────
async def create_question(
*,
tree_id: str,
owner_scope: str,
content: str,
realm: str | None = None,
parent_answer_id: str | None = None,
citations: list[dict] | None = None,
) -> TreeNode:
"""新问节:默认挂当前工作入口(不变量 2——绑定用户实际采用的候答)。"""
tree = await _owned_tree(tree_id, owner_scope)
parent_id = parent_answer_id or tree.working_entry_node_id
async with AsyncSessionLocal() as session:
if parent_id is not None:
parent = await _node_in_tree(session, parent_id, tree_id)
if parent.node_type == "question":
raise AppError("invalid_request", "问节必须挂在候答/续思之下(或作为根问节)。")
effective_realm = realm or tree.default_realm
if effective_realm not in REALMS:
raise AppError("invalid_request", "界域取值不合法。")
node = TreeNode(
tree_id=tree_id,
parent_node_id=parent_id,
node_type="question",
status="solidified",
author="user",
content=content,
realm=effective_realm,
)
session.add(node)
await session.flush()
node_id = node.id
if citations:
for c in citations:
session.add(_citation_row(node_id, c))
# 界域切换可见(PRD §8):与父节点(或树默认界域,根问节时)不同即留痕入境;
# 事件在提交后发射,避免事务内跨连接写(SQLite 锁冲突)
baseline_realm = tree.default_realm
if parent_id is not None:
baseline_realm = (await session.get(TreeNode, parent_id)).realm
realm_event = (
{"tree_id": tree_id, "node_id": node_id, "realm": effective_realm}
if effective_realm != baseline_realm
else None
)
await session.commit()
if realm_event is not None:
await one_qi.emit(
"realm.entered",
character_id=tree.character_id,
payload=realm_event,
source="mirror_tree",
)
await one_qi.emit(
"tree.node_created",
character_id=tree.character_id,
payload={"tree_id": tree_id, "node_id": node_id, "node_type": "question"},
source="mirror_tree",
)
return await _get_node(node_id)
def _citation_row(node_id: str, data: dict) -> Citation:
return Citation(
node_id=node_id,
target_type=data.get("target_type", "text_range"),
locator_json=json.dumps(data.get("locator", {}), ensure_ascii=False),
snapshot=data.get("snapshot", ""),
source_id=data.get("source_id", ""),
source_version=int(data.get("source_version", 1)),
)
async def build_generation_prompt(
question: TreeNode,
instructions: str | None,
*,
char_name: str | None = None,
user_name: str | None = None,
) -> str:
"""按祖先链组装投影 prompt 并做符文展开({{char}}/{{user}}/…)。
M2 形态:祖先路径 + 问题 + 临时指引;获准记忆与显式引文随 M3 息壤接入。
"""
from app.modules.macros.builtins import build_default_registry
from app.modules.macros.engine import render
from app.modules.macros.types import MacroContext
ctx = MacroContext(char_name=char_name, user_name=user_name)
registry = build_default_registry()
def expand(text: str) -> str:
return render(text, ctx, registry=registry)
chain = await ancestor_chain(question.id)
lines: list[str] = []
for n in chain:
if n.id == question.id:
continue
speaker = "用户" if n.node_type == "question" else "人物"
lines.append(f"{speaker}:{expand(n.content)}")
transcript = "\n".join(lines)
parts = []
# 获准长期记忆(息壤 M3):召回透明——注入来源可展开,旁枝不入(默认 active 级)
if question.tree_id:
from app.domains.xirang import service as xirang_service
recalled = await xirang_service.recall_for_generation(
await _tree_character_id(question.tree_id),
f"{transcript}\n{question.content}",
realm=question.realm,
)
if recalled:
memory_lines = [f"- {m.content}" for m in recalled]
parts.append(f"【获准记忆(来源可查)】\n" + "\n".join(memory_lines))
# 世谱激活(河图):关键词命中扫描近期文本,常驻条目恒注入;预算内按 insertion_order
tree_character_id = await _tree_character_id(question.tree_id)
activated = await _activate_lorebook(
tree_character_id,
scan_text=f"{transcript[-600:]}\n{question.content}",
expand=(lambda t: render(t, ctx, registry=registry)),
)
if activated:
parts.append("【世界认知(世谱命中)】\n" + "\n".join(f"- {line}" for line in activated))
if transcript:
parts.append(f"【共同经历(祖先路径)】\n{transcript}")
parts.append(f"【当前问题】\n{expand(question.content)}")
if instructions:
parts.append(f"【本次指引】\n{expand(instructions)}")
return "\n\n".join(parts)
async def _names_for_generation(tree: MirrorTree) -> tuple[str | None, str | None]:
"""人物名与执镜者名(符文上下文)。"""
from app.models.user import User
async with AsyncSessionLocal() as session:
character = await session.get(Character, tree.character_id)
char_name = character.name if character else None
user_name = None
if character is not None:
user = await session.get(User, character.owner_scope)
user_name = user.username if user else None
return char_name, user_name
async def _generation_overrides(owner_scope: str) -> dict:
"""火候(GenerationPreferences):本镜域默认采样参数 → IR 覆写。"""
from sqlalchemy import select
from app.models.governance import GenerationPreferences
async with AsyncSessionLocal() as session:
prefs = await session.get(GenerationPreferences, owner_scope)
if prefs is None:
# 兼容旧默认 owner_scope 行(历史数据)
prefs = (
await session.execute(
select(GenerationPreferences).where(
GenerationPreferences.owner_scope == owner_scope
)
)
).scalar_one_or_none()
if prefs is None:
return {}
return {"temperature": prefs.temperature, "max_tokens": prefs.max_output_tokens}
async def spawn_answer(
*,
question_id: str,
owner_scope: str,
kernel: Kernel,
instructions: str | None = None,
) -> dict:
"""另答:新候答(budding)+ Kernel 前台 Run;完成回调固化节点,永不覆盖旧候答。"""
question = await _get_node(question_id)
if question.node_type != "question":
raise AppError("invalid_request", "只能在问节下生成候答。")
tree = await _owned_tree(question.tree_id, owner_scope)
node = TreeNode(
tree_id=question.tree_id,
parent_node_id=question.id,
node_type="answer",
status="budding",
author="character",
content="",
realm=question.realm,
)
async with AsyncSessionLocal() as session:
session.add(node)
await session.flush()
node_id = node.id
await session.commit()
await one_qi.emit(
"tree.node_created",
character_id=tree.character_id,
payload={"tree_id": tree.id, "node_id": node_id, "node_type": "answer"},
source="mirror_tree",
)
char_name, user_name = await _names_for_generation(tree)
prompt = await build_generation_prompt(
question, instructions, char_name=char_name, user_name=user_name
)
overrides = await _generation_overrides(owner_scope)
run = await kernel.start_foreground_run(
character_id=tree.character_id,
prompt=prompt,
system_contract=None,
origin="tree_answer",
on_run_final=_make_finalize(tree.id, tree.character_id, node_id),
**overrides,
)
async with AsyncSessionLocal() as session:
node = await session.get(TreeNode, node_id)
node.generation_meta_json = json.dumps({"run_id": run.id}, ensure_ascii=False)
await session.commit()
return {"node_id": node_id, "run_id": run.id}
def _make_finalize(tree_id: str, character_id: str, node_id: str):
async def finalize(content: str, status: str) -> None:
async with AsyncSessionLocal() as session:
node = await session.get(TreeNode, node_id)
if node is None:
return
run_id = json.loads(node.generation_meta_json or "{}").get("run_id")
if status == "completed":
node.content = content
node.status = "solidified"
event = "tree.node_solidified"
payload = {"tree_id": tree_id, "node_id": node_id}
else:
node.content = content # 残答保留已生成部分,只读封存
node.status = "sealed_residual"
event = "tree.node_sealed"
payload = {"tree_id": tree_id, "node_id": node_id}
await session.commit()
await one_qi.emit(
event,
character_id=character_id,
payload=payload,
run_id=run_id,
source="mirror_tree",
)
return finalize
async def continue_node(
*, node_id: str, owner_scope: str, kernel: Kernel, instructions: str | None = None
) -> dict:
"""续思:基于已固化候答/续思生成独立延展节点,不修改原节点。"""
base = await _get_node(node_id)
if base.node_type == "question":
raise AppError("invalid_request", "续思基于候答或续思,不能基于问节。")
if base.status != "solidified":
raise AppError("HJ-1103", "只能续思已固化的内容。", status_code=409)
return await _spawn_continuation(base, owner_scope, kernel, kind="continue", instructions=instructions)
async def resume_node(
*, node_id: str, owner_scope: str, kernel: Kernel, instructions: str | None = None
) -> dict:
"""续生:针对中断残答,从其已完成内容继续生成新的延展节点;不改写残答。"""
base = await _get_node(node_id)
if base.status != "sealed_residual":
raise AppError("HJ-1103", "只能续生残答。", status_code=409)
return await _spawn_continuation(base, owner_scope, kernel, kind="resume", instructions=instructions)
async def _spawn_continuation(
base: TreeNode, owner_scope: str, kernel: Kernel, *, kind: str, instructions: str | None
) -> dict:
tree = await _owned_tree(base.tree_id, owner_scope)
node = TreeNode(
tree_id=base.tree_id,
parent_node_id=base.id,
node_type="continuation",
status="budding",
author="character",
content="",
realm=base.realm,
)
async with AsyncSessionLocal() as session:
session.add(node)
await session.flush()
node_id = node.id
await session.commit()
await one_qi.emit(
"tree.node_created",
character_id=tree.character_id,
payload={"tree_id": tree.id, "node_id": node_id, "node_type": "continuation"},
source="mirror_tree",
)
if kind == "continue":
prompt = f"【继续深入】\n请针对以下内容继续深入分析:\n\n{base.content}"
if instructions:
prompt += f"\n\n【本次指引】\n{instructions}"
else:
prompt = f"【续生】\n以下内容被中断,请从其结尾继续补全(不要重复已有内容):\n\n{base.content}"
if instructions:
prompt += f"\n\n【本次指引】\n{instructions}"
run = await kernel.start_foreground_run(
character_id=tree.character_id,
prompt=prompt,
origin=f"tree_{kind}",
on_run_final=_make_finalize(tree.id, tree.character_id, node_id),
)
async with AsyncSessionLocal() as session:
row = await session.get(TreeNode, node_id)
row.generation_meta_json = json.dumps({"run_id": run.id}, ensure_ascii=False)
await session.commit()
return {"node_id": node_id, "run_id": run.id}
async def edit_fork(*, node_id: str, owner_scope: str, new_content: str) -> dict:
"""编辑已发送内容 = 新版本分叉:新节点 supersedes 旧节点,原文与后代保留。"""
old = await _get_node(node_id)
tree = await _owned_tree(old.tree_id, owner_scope)
if old.status == "budding":
raise AppError("HJ-1103", "芽体生成中,不能编辑分叉。", status_code=409)
async with AsyncSessionLocal() as session:
fork = TreeNode(
tree_id=old.tree_id,
parent_node_id=old.parent_node_id,
node_type=old.node_type,
status="solidified",
author=old.author,
content=new_content,
realm=old.realm,
supersedes_node_id=old.id,
)
session.add(fork)
await session.flush()
fork_id = fork.id
await session.commit()
await one_qi.emit(
"tree.node_created",
character_id=tree.character_id,
payload={
"tree_id": tree.id,
"node_id": fork_id,
"node_type": old.node_type,
"supersedes": old.id,
},
source="mirror_tree",
)
return {"node_id": fork_id, "supersedes_node_id": old.id}
# ── 采用与归真 ──────────────────────────────────────────────
async def adopt(*, tree_id: str, owner_scope: str, node_id: str) -> dict:
"""采用:仅改变工作入口;不触碰 canonizations(不变量 3)。"""
tree = await _owned_tree(tree_id, owner_scope)
async with AsyncSessionLocal() as session:
node = await _node_in_tree(session, node_id, tree_id)
if node.node_type == "question":
raise AppError("invalid_request", "工作入口必须是候答或续思节点。")
if node.status != "solidified":
raise AppError("HJ-1103", "只能采用已固化的候答(芽体/残答不可采用)。", status_code=409)
row = await session.get(MirrorTree, tree_id)
row.working_entry_node_id = node_id
await session.commit()
await one_qi.emit(
"tree.adopted",
character_id=tree.character_id,
payload={"tree_id": tree_id, "node_id": node_id},
source="mirror_tree",
)
return {"tree_id": tree_id, "working_entry_node_id": node_id}
async def canonize(
*,
tree_id: str,
owner_scope: str,
endpoint_node_id: str,
scope: str,
world_id: str | None = None,
) -> dict:
"""归真:路径进入明确的 canonical_scope;与既有主脉分叉时后到操作 409(HJ-1102)。"""
tree = await _owned_tree(tree_id, owner_scope)
if scope not in SCOPES:
raise AppError("invalid_request", "归真作用域取值不合法。")
if scope == "persistent_world" and not world_id:
raise AppError("invalid_request", "境界归真必须指定 world_id。")
async with AsyncSessionLocal() as session:
endpoint = await _node_in_tree(session, endpoint_node_id, tree_id)
if endpoint.node_type == "question":
raise AppError("invalid_request", "归真端点必须是候答或续思节点。")
if endpoint.status != "solidified":
raise AppError(
"HJ-1103", "芽体/残答不能归真(未完成固化的内容不进入正式经历)。", status_code=409
)
chain: list[TreeNode] = [endpoint]
cursor = endpoint.parent_node_id
for _ in range(10_000):
if cursor is None:
break
parent = await session.get(TreeNode, cursor)
chain.append(parent)
cursor = parent.parent_node_id
path_ids = [n.id for n in reversed(chain)]
# 主脉分叉检查:同树同作用域已有归真时,新路径必须是主脉的延长(含旧端点)
last = await session.scalar(
select(Canonization)
.where(Canonization.tree_id == tree_id, Canonization.scope == scope)
.order_by(Canonization.created_at.desc())
.limit(1)
)
if last is not None and last.endpoint_node_id not in path_ids:
raise AppError(
"HJ-1102",
"归真路径与当前主脉分叉;已归真经历不会被静默替换,请先明确处理分歧。",
status_code=409,
details={"current_trunk_head": last.endpoint_node_id, "scope": scope},
)
record = Canonization(
tree_id=tree_id,
character_id=tree.character_id,
scope=scope,
world_id=world_id,
endpoint_node_id=endpoint_node_id,
path_nodes_json=json.dumps(path_ids),
confirmed_by=owner_scope,
)
session.add(record)
await session.flush()
record_id = record.id
await session.commit()
# 息壤:归真产生记忆候选证据(M3)——后台任务,不阻塞归真返回
from app.domains.xirang import service as xirang_service
await xirang_service.on_canonized(record_id)
await one_qi.emit(
"tree.canonized",
character_id=tree.character_id,
payload={
"tree_id": tree_id,
"canonization_id": record_id,
"scope": scope,
"world_id": world_id,
"endpoint_node_id": endpoint_node_id,
"path_length": len(path_ids),
},
source="mirror_tree",
)
return {
"canonization_id": record_id,
"scope": scope,
"endpoint_node_id": endpoint_node_id,
"path_length": len(path_ids),
}
# ── 引文锚 ──────────────────────────────────────────────
async def create_citation(
*, node_id: str, owner_scope: str, data: dict
) -> dict:
node = await _get_node(node_id)
await _owned_tree(node.tree_id, owner_scope)
async with AsyncSessionLocal() as session:
row = _citation_row(node_id, data)
session.add(row)
await session.flush()
citation_id = row.id
await session.commit()
return {"citation_id": citation_id}
async def list_citations(node_id: str, owner_scope: str) -> list[dict]:
node = await _get_node(node_id)
await _owned_tree(node.tree_id, owner_scope)
async with AsyncSessionLocal() as session:
own = await _node_citations(session, node_id)
# 正影提示:同 source 存在更高版本时标记,不静默替换(PRD §7.2)
versions: dict[str, int] = {}
all_rows = (
await session.execute(select(Citation).where(Citation.source_id != ""))
).scalars().all()
for c in all_rows:
versions[c.source_id] = max(versions.get(c.source_id, 0), c.source_version)
return [
{
"id": c.id,
"node_id": c.node_id,
"target_type": c.target_type,
"locator": json.loads(c.locator_json or "{}"),
"snapshot": c.snapshot,
"source_id": c.source_id,
"source_version": c.source_version,
"newer_version_available": versions.get(c.source_id, 0) > c.source_version,
}
for c in own
]
# ── 移栽 ──────────────────────────────────────────────
async def transplant(
*, tree_id: str, owner_scope: str, node_id: str, new_title: str
) -> dict:
"""移栽:把分支的祖先路径复制为新树(全新节点 ID),保留来源谱系;两树只引用不合流。"""
tree = await _owned_tree(tree_id, owner_scope)
branch = await _get_node(node_id)
if branch.tree_id != tree_id:
raise AppError("not_found", "节点不属于该树。")
if branch.node_type == "question":
raise AppError("invalid_request", "移栽端点应为候答或续思分支。")
chain = await ancestor_chain(node_id) # root→branch
async with AsyncSessionLocal() as session:
new_tree = MirrorTree(
character_id=tree.character_id,
title=new_title,
default_realm=tree.default_realm,
transplanted_from_json=json.dumps(
{"source_tree_id": tree_id, "source_node_id": node_id}
),
)
session.add(new_tree)
await session.flush()
id_map: dict[str, str] = {}
for old_node in chain:
new_node = TreeNode(
tree_id=new_tree.id,
parent_node_id=id_map.get(old_node.parent_node_id or ""),
node_type=old_node.node_type,
status="solidified",
author=old_node.author,
content=old_node.content,
realm=old_node.realm,
generation_meta_json=json.dumps(
{"transplanted_from_node": old_node.id}, ensure_ascii=False
),
)
session.add(new_node)
await session.flush()
id_map[old_node.id] = new_node.id
# 引文随移栽复制(快照保留)
for c in await _node_citations(session, old_node.id):
session.add(
Citation(
node_id=new_node.id,
target_type=c.target_type,
locator_json=c.locator_json,
snapshot=c.snapshot,
source_id=c.source_id,
source_version=c.source_version,
)
)
new_tree.working_entry_node_id = id_map[node_id]
session.add(
TreeTransplant(
source_tree_id=tree_id, source_node_id=node_id, new_tree_id=new_tree.id
)
)
await session.flush()
new_tree_id, new_entry = new_tree.id, new_tree.working_entry_node_id
await session.commit()
await one_qi.emit(
"tree.transplanted",
character_id=tree.character_id,
payload={
"source_tree_id": tree_id,
"source_node_id": node_id,
"new_tree_id": new_tree_id,
},
source="mirror_tree",
)
return {"new_tree_id": new_tree_id, "working_entry_node_id": new_entry}
async def _tree_character_id(tree_id: str) -> str:
from app.models.mirror_tree import MirrorTree
async with AsyncSessionLocal() as session:
tree = await session.get(MirrorTree, tree_id)
return tree.character_id if tree else ""
async def _activate_lorebook(
character_id: str,
*,
scan_text: str,
expand=None, # noqa: ANN001 — 符文展开函数(char/user 名),无上下文时不展开
char_budget: int = 600,
) -> list[str]:
"""世谱激活管线(mirror-tree PRD 借 ST 范式):关键词命中 + 常驻,预算内按插入序。
扫描范围为近期文本(祖先链尾部 + 当前问题);条目内容经符文展开后注入。
次级键逻辑、递归扫描与时序效果(sticky/cooldown)随 M5 循环轮次状态接入。
"""
from sqlalchemy import select
from app.models.lorebook import Lorebook, LorebookEntry
text = "".join(scan_text.lower().split())
async with AsyncSessionLocal() as session:
books = (
await session.execute(
select(Lorebook.id).where(
Lorebook.character_id == character_id, Lorebook.status == "active"
)
)
).scalars().all()
if not books:
return []
entries = (
(
await session.execute(
select(LorebookEntry)
.where(LorebookEntry.lorebook_id.in_(tuple(books)), LorebookEntry.enabled.is_(True))
.order_by(LorebookEntry.insertion_order)
)
)
.scalars()
.all()
)
picked: list[str] = []
used = 0
for entry in entries:
keys = [k.lower() for k in json.loads(entry.keys_json or "[]") if k]
hit = entry.constant or any("".join(k.split()) in text for k in keys)
if not hit:
continue
content = entry.content if expand is None else expand(entry.content)
if used + len(content) > char_budget:
continue
picked.append(content)
used += len(content)
return picked
+42
View File
@@ -0,0 +1,42 @@
"""领域层一炁事件发射(共享绑定)。
领域服务不持有 Run 上下文时(灵胚创建、采用、归真等)经此写入事件日志;
lifespan 装配时 ``bind_event_store`` 注入进程级 EventStore。事件类型取值以
契约词典 §8.3 为准,新增先登记后使用。
"""
from __future__ import annotations
from app.services.event_store import DbEventStore
_store: DbEventStore | None = None
def bind_event_store(store: DbEventStore) -> None:
global _store
_store = store
async def emit(
event_type: str,
*,
character_id: str | None,
payload: dict,
visibility: str = "public",
run_id: str | None = None,
source: str,
) -> None:
from app.modules.runtime.events import Event
if _store is None:
return
await _store.append(
Event(
type=event_type,
source=source,
payload=payload,
visibility=visibility, # type: ignore[arg-type]
run_id=run_id,
character_id=character_id,
)
)
+25
View File
@@ -0,0 +1,25 @@
"""息壤(记忆域):三级记忆、实录与召回(data-model.md §3)。"""
from app.domains.xirang.service import (
confirm_memory,
correct_memory,
delete_memory,
deny_memory,
memorize_node,
on_canonized,
place_candidates,
recall_for_generation,
record_factual,
)
__all__ = [
"confirm_memory",
"correct_memory",
"delete_memory",
"deny_memory",
"memorize_node",
"on_canonized",
"place_candidates",
"recall_for_generation",
"record_factual",
]
+504
View File
@@ -0,0 +1,504 @@
"""息壤(记忆域)服务:提取管线、分级落位、确认流、正影、删除、检索与激活。
铁律(memory-model.md §5):只有归真结果(或用户手动入忆)能产生长期记忆候选——
微痕不自动升级;未归真旁枝自动入忆率必须为 0(测试钉死)。
"""
from __future__ import annotations
import asyncio
import json
import math
import re
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.domains import one_qi
from app.kernel import Kernel
from app.models.mirror_tree import Canonization, TreeNode
from app.models.xirang import FactualRecord, Memory, MemoryTrigger
# 微痕默认半衰期(天);命中强化 +0.08、上限 3.0(借鉴 Memory-Palace 活力模型)
TRACE_HALF_LIFE_DAYS = 7.0
VITALITY_HIT_BONUS = 0.08
VITALITY_MAX = 3.0
# 召回预算:条数与字符上限
RECALL_LIMIT = 5
RECALL_CHAR_BUDGET = 1200
# 明忆白名单(PRD 三级记忆):低敏客观类自动生效带撤销窗;心痕等主观类入候选箱
EXPLICIT_AUTO_TYPES = ("persona", "instruction", "episodic")
# deny 两次退出检索
DENY_EXIT_THRESHOLD = 2
deny_counts: dict[str, int] = {}
# ── 归真触发:实录 + 提取 ──────────────────────────────────────────────
async def on_canonized(canonization_id: str) -> None:
"""归真后置(由 canonize 同步调用):实录落库 + 调度异步提取。"""
await record_factual(canonization_id)
asyncio.get_running_loop().create_task(_extract_guarded(canonization_id))
async def _extract_guarded(canonization_id: str) -> None:
try:
await _extract_after_canonized(canonization_id)
except Exception as exc: # noqa: BLE001 — 后台任务异常必须可见
from app.core.logging import log_with_context
log_with_context(f"息壤提取任务失败:{exc}", level="ERROR")
async def _extract_after_canonized(canonization_id: str) -> None:
async with AsyncSessionLocal() as session:
record = await session.get(Canonization, canonization_id)
if record is None:
return
path_ids = json.loads(record.path_nodes_json)
nodes = []
for nid in path_ids:
node = await session.get(TreeNode, nid)
if node is not None:
nodes.append(node)
transcript = "\n".join(
f"{'用户' if n.node_type == 'question' else '人物'}:{n.content}" for n in nodes
)
character_id = record.character_id
await _extract_candidates(
character_id=character_id, canonization_id=canonization_id, transcript=transcript
)
async def record_factual(canonization_id: str) -> None:
"""实录(同步,随归真事务后立即落库)。"""
async with AsyncSessionLocal() as session:
record = await session.get(Canonization, canonization_id)
if record is None:
return
path_nodes = []
for nid in json.loads(record.path_nodes_json):
node = await session.get(TreeNode, nid)
if node is not None:
path_nodes.append(node)
transcript = "\n".join(
f"{'用户' if n.node_type == 'question' else '人物'}:{n.content}" for n in path_nodes
)
summary = f"[{record.scope}] " + " / ".join(n.content[:40] for n in path_nodes[-2:])
session.add(
FactualRecord(
character_id=record.character_id,
canonization_id=canonization_id,
evidence_json=json.dumps({"path_nodes": [n.id for n in path_nodes]}, ensure_ascii=False),
summary=summary,
)
)
character_id = record.character_id
scope = record.scope
await session.commit()
_ = scope
async def _extract_candidates(*, character_id: str, canonization_id: str, transcript: str) -> None:
"""提取管线:LLM 抽取 JSON 候选;解析失败回落为确定性单条摘要候选(不阻塞闭环)。"""
from app.shenshi_client.types import ShenshiChatRequest, ShenshiMessage
kernel: Kernel | None = _kernel_ref()
raw = ""
if kernel is not None:
request = ShenshiChatRequest(
messages=[
ShenshiMessage(
role="system",
content=(
"你是记忆抽取器。从对话实录中抽取长期记忆候选,"
'输出 JSON 数组 [{"content","type","priority","confidence","sensitive"}],'
'type ∈ persona/episodic/instruction/relationship_view;宁缺毋滥,无值得记忆的内容输出 []。'
),
),
ShenshiMessage(role="user", content=transcript[:8000]),
],
run_id=f"extract_{canonization_id}",
)
parts: list[str] = []
try:
async for delta in kernel.shenshi.stream_chat(request):
parts.append(delta.delta)
raw = "".join(parts)
except Exception: # noqa: BLE001 — 提取失败回落确定性路径
raw = ""
candidates = _parse_candidates(raw)
if not candidates:
candidates = [
{
"content": f"共同经历:{transcript[-200:]}",
"type": "episodic",
"priority": 50,
"confidence": 0.6,
"sensitive": False,
}
]
await place_candidates(
character_id=character_id,
candidates=candidates,
source_refs=[canonization_id],
source="self_inferred",
realm="reality",
)
def _parse_candidates(raw: str) -> list[dict]:
match = re.search(r"\[.*\]", raw, re.S)
if not match:
return []
try:
data = json.loads(match.group(0))
except json.JSONDecodeError:
return []
if not isinstance(data, list):
return []
out: list[dict] = []
for item in data:
if isinstance(item, dict) and item.get("content"):
out.append(item)
return out
async def place_candidates(
*,
character_id: str,
candidates: list[dict],
source_refs: list[str],
source: str,
realm: str = "reality",
) -> list[Memory]:
"""分级落位 + 确定性去重(同类型同内容跳过)。"""
placed: list[Memory] = []
async with AsyncSessionLocal() as session:
existing = (
(
await session.execute(
select(Memory).where(
Memory.character_id == character_id, Memory.status.in_(("active", "pending"))
)
)
)
.scalars()
.all()
)
existing_norms = {_norm(m.content) for m in existing}
for cand in candidates:
content = str(cand.get("content", "")).strip()
if not content or _norm(content) in existing_norms:
continue
sensitive = bool(cand.get("sensitive"))
mtype = str(cand.get("type", "episodic"))
if sensitive:
tier, status = "sensitive", "pending" # 重忆必须确认(HJ-1201 管线侧)
elif mtype in EXPLICIT_AUTO_TYPES:
# 白名单明忆:低敏 persona/instruction/episodic 自动生效,撤销窗=可删除/可正影
tier, status = "explicit", "active"
else:
# 心痕(relationship_view,主观非事实)等入候选箱待审阅
tier, status = "explicit", "candidate"
memory = Memory(
character_id=character_id,
tier=tier,
type=mtype,
status=status,
source=source,
content=content,
source_refs_json=json.dumps(source_refs, ensure_ascii=False),
sensitivity="high" if sensitive else "low",
priority=int(cand.get("priority", 50)),
confidence=float(cand.get("confidence", 0.5)),
realm=realm,
)
session.add(memory)
await session.flush()
placed.append(memory)
for key in cand.get("keys", []) or []:
session.add(MemoryTrigger(memory_id=memory.id, kind="keyword", pattern=str(key)))
await session.commit()
for m in placed:
await one_qi.emit(
"memory.candidate_created",
character_id=character_id,
payload={"memory_id": m.id, "tier": m.tier, "status": m.status},
source="xirang",
)
return placed
def _norm(text: str) -> str:
return re.sub(r"\s+", "", text).lower()
def _kernel_ref() -> Kernel | None:
from app.main import app
return getattr(app.state, "kernel", None)
# ── 手动入忆 ──────────────────────────────────────────────
async def memorize_node(*, node_id: str, owner_scope: str, label: str) -> Memory:
"""手动入忆:须显式标注 fact/insight/opinion/hypothesis;未归真内容只作 fact_candidate。"""
from app.models.character import Character
if label not in ("fact", "insight", "opinion", "hypothesis"):
raise AppError("invalid_request", "手动入忆必须标注 fact/insight/opinion/hypothesis。")
async with AsyncSessionLocal() as session:
node = await session.get(TreeNode, node_id)
if node is None:
raise AppError("not_found", "节点不存在。")
canonized_node_ids: set[str] = set()
canons = (
await session.execute(select(Canonization).where(Canonization.tree_id == node.tree_id))
).scalars().all()
for canon in canons:
canonized_node_ids.update(json.loads(canon.path_nodes_json))
character = await session.get(Character, await _tree_character(session, node.tree_id))
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "节点不存在。")
character_id = character.id
on_canon = node.id in canonized_node_ids
memory = Memory(
character_id=character.id,
tier="explicit",
type="episodic" if (on_canon and label == "fact") else "fact_candidate",
status="active",
source="user_explicit",
content=f"[{label}] {node.content}",
source_refs_json=json.dumps([node_id], ensure_ascii=False),
sensitivity="low",
realm=node.realm,
)
session.add(memory)
await session.flush()
memory_id = memory.id
await session.commit()
await one_qi.emit(
"memory.candidate_created",
character_id=character_id,
payload={"memory_id": memory_id, "manual": True, "label": label},
source="xirang",
)
async with AsyncSessionLocal() as session:
return await session.get(Memory, memory_id) # type: ignore[return-value]
async def _tree_character(session: AsyncSession, tree_id: str) -> str:
from app.models.mirror_tree import MirrorTree
tree = await session.get(MirrorTree, tree_id)
return tree.character_id if tree else ""
# ── 确认流 / 正影 / 删除 ──────────────────────────────────────────────
async def _owned_memory(memory_id: str, owner_scope: str, *, session: AsyncSession) -> Memory:
from app.models.character import Character
memory = await session.get(Memory, memory_id)
if memory is None:
raise AppError("not_found", "记忆不存在。")
character = await session.get(Character, memory.character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "记忆不存在。")
return memory
async def confirm_memory(memory_id: str, owner_scope: str) -> Memory:
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
if memory.status not in ("pending", "candidate"):
raise AppError("HJ-1103", "只有待确认(pending)或候选箱(candidate)的记忆可确认。", status_code=409)
memory.status = "active"
view = _view(memory)
await session.commit()
await one_qi.emit("memory.confirmed", character_id=view["character_id"], payload={"memory_id": memory_id}, source="xirang")
async with AsyncSessionLocal() as session:
return await session.get(Memory, memory_id) # type: ignore[return-value]
async def deny_memory(memory_id: str, owner_scope: str) -> dict:
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
memory.status = "tombstoned"
await session.commit()
character_id = memory.character_id
deny_counts[memory_id] = deny_counts.get(memory_id, 0) + 1
exited = deny_counts[memory_id] >= DENY_EXIT_THRESHOLD
await one_qi.emit(
"memory.denied", character_id=character_id, payload={"memory_id": memory_id, "exited": exited}, source="xirang"
)
return {"memory_id": memory_id, "exited_retrieval": True}
async def correct_memory(memory_id: str, owner_scope: str, new_content: str) -> Memory:
"""正影:新版本指向旧版本;旧版 superseded 停止普通召回,内容不改写。"""
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
if memory.status == "tombstoned":
raise AppError("HJ-1202", "已删除内容不可正影。", status_code=409)
new_version = Memory(
character_id=memory.character_id,
tier=memory.tier,
type=memory.type,
status="active" if memory.status in ("active", "pending") else memory.status,
source=memory.source,
content=new_content,
source_refs_json=memory.source_refs_json,
sensitivity=memory.sensitivity,
priority=memory.priority,
realm=memory.realm,
prev_version_id=memory.id,
)
session.add(new_version)
memory.status = "superseded"
await session.flush()
new_id = new_version.id
await session.commit()
await one_qi.emit(
"memory.superseded",
character_id=None,
payload={"memory_id": memory_id, "new_version_id": new_id},
source="xirang",
)
async with AsyncSessionLocal() as session:
return await session.get(Memory, new_id) # type: ignore[return-value]
async def delete_memory(memory_id: str, owner_scope: str) -> None:
"""删除:墓碑化——内容不再出现在主存储查询/检索/上下文;墓碑不留原文。"""
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
character_id = memory.character_id
memory.status = "tombstoned"
memory.content = "" # 墓碑不承载被删原文(data-model §10)
await session.commit()
await one_qi.emit(
"memory.deleted", character_id=character_id, payload={"memory_id": memory_id}, source="xirang"
)
# ── 检索(双通道 + RRF 融合 + 预算)与激活 ──────────────────────────────────────────────
async def recall_for_generation(
character_id: str, query_text: str, *, realm: str = "reality", limit: int = RECALL_LIMIT
) -> list[Memory]:
"""获准长期记忆召回:关键词通道 + 活力时近通道 → RRF 融合 → 预算裁剪。
通道 A:关键词/触发词命中(content LIKE 或 trigger pattern 命中);
通道 B:活力值 × 时近衰减排序(标准档接入 pgvector 后升级为语义通道)。
过滤:active、界域匹配、非墓碑/被取代。
"""
async with AsyncSessionLocal() as session:
actives = (
(
await session.execute(
select(Memory).where(
Memory.character_id == character_id,
Memory.status == "active",
Memory.realm.in_(("reality", realm)),
)
)
)
.scalars()
.all()
)
if not actives:
return []
by_id = {m.id: m for m in actives}
now = utc_now()
def vitality_of(m: Memory) -> float:
age_days = max((now - _as_utc(m.created_at)).total_seconds() / 86400, 0)
return m.vitality * math.pow(2, -age_days / TRACE_HALF_LIFE_DAYS)
# 通道 A:关键词命中(含触发词)
keywords = {t.memory_id: t.pattern for t in await _triggers(set(by_id))}
channel_a: list[str] = []
text_norm = _norm(query_text)
for m in actives:
hit = _norm(m.content)[:24] in text_norm or any(
_norm(p) in text_norm for p in ([keywords.get(m.id)] if keywords.get(m.id) else [])
)
if hit or any(_norm(w) and _norm(w) in text_norm for w in _split_terms(m.content)):
channel_a.append(m.id)
# 通道 B:活力 × 时近
channel_b = sorted(by_id, key=lambda mid: vitality_of(by_id[mid]), reverse=True)
# RRF 融合
k = 60
scores: dict[str, float] = {}
for rank, mid in enumerate(channel_a):
scores[mid] = scores.get(mid, 0) + 1 / (k + rank + 1)
for rank, mid in enumerate(channel_b):
scores[mid] = scores.get(mid, 0) + 1 / (k + rank + 1)
ranked = sorted(scores, key=lambda mid: scores[mid], reverse=True)[:limit]
# 字符预算
picked: list[Memory] = []
used = 0
for mid in ranked:
m = by_id[mid]
if used + len(m.content) > RECALL_CHAR_BUDGET:
continue
picked.append(m)
used += len(m.content)
# 命中强化 + 召回统计
if picked:
async with AsyncSessionLocal() as session:
for m in picked:
row = await session.get(Memory, m.id)
row.vitality = min(row.vitality + VITALITY_HIT_BONUS, VITALITY_MAX)
row.recall_count += 1
row.last_recalled_at = utc_now()
await session.commit()
return picked
async def _triggers(memory_ids: set[str]) -> list[MemoryTrigger]:
if not memory_ids:
return []
async with AsyncSessionLocal() as session:
result = await session.execute(
select(MemoryTrigger).where(MemoryTrigger.memory_id.in_(tuple(memory_ids)))
)
return list(result.scalars().all())
def _split_terms(content: str) -> list[str]:
return [w for w in re.split(r"[,。!?、,.!?;;\s]+", content) if len(w) >= 2][:6]
def _as_utc(dt: datetime) -> datetime:
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
def _view(memory: Memory) -> dict:
return {
"id": memory.id,
"character_id": memory.character_id,
"tier": memory.tier,
"type": memory.type,
"status": memory.status,
"content": memory.content,
}
+154
View File
@@ -0,0 +1,154 @@
"""极小硬核 Kernel:不变量裁决、命台并发控制、事件日志写入、Run 循环骨架。
不可插拔(lingtai-runtime.md §1);插件与 Profile 见 ``app.runtime``。
"""
from __future__ import annotations
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.kernel.eventlog import KernelEventLog
from app.kernel.loop import KernelRunLoop, OnRunFinal
from app.kernel.seat import SeatManager
from app.models.runtime import Run
from app.runtime.bus import WaterfallBus
from app.runtime.profile import resolve_profile
from app.shenshi_client.client import ShenshiClient
__all__ = ["Kernel", "KernelEventLog", "KernelRunLoop", "SeatManager"]
class Kernel:
"""门面:持有 Kernel 全部部件,供 lifespan 装配与 API 层调用。"""
def __init__(
self,
*,
shenshi: ShenshiClient,
session_factory: async_sessionmaker[AsyncSession],
event_log: KernelEventLog,
bus: WaterfallBus | None = None,
seats: SeatManager | None = None,
timeout_seconds: float = 120.0,
) -> None:
self._sessions = session_factory
self.bus = bus or WaterfallBus()
self.seats = seats or SeatManager()
self.loop = KernelRunLoop(
event_log=event_log,
seats=self.seats,
bus=self.bus,
shenshi=shenshi,
session_factory=session_factory,
timeout_seconds=timeout_seconds,
)
self._tasks: set[asyncio.Task[None]] = set()
async def start_foreground_run(
self,
*,
character_id: str,
prompt: str,
trace_id: str | None = None,
system_contract: str | None = None,
origin: str = "kernel",
on_run_final: OnRunFinal | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
) -> Run:
"""创建 Run 行(queued)并异步启动循环;前台席位在循环内排队获取。"""
profile = resolve_profile()
async with self._sessions() as session:
run = Run(
character_id=character_id,
status="queued",
kind="foreground",
goal=prompt[:300] if prompt else None,
origin=origin,
model_hint=str(profile.params.get("slot", "chat")),
)
session.add(run)
await session.commit()
run_id = run.id
self._spawn(
run_id=run_id,
character_id=character_id,
prompt=prompt,
trace_id=trace_id,
seat_key=None,
system_contract=system_contract,
on_final=on_run_final,
temperature=temperature,
max_tokens=max_tokens,
)
async with self._sessions() as session:
created = await session.get(Run, run_id)
assert created is not None
return created
async def start_rehearsal_run(
self, *, embryo_id: str, prompt: str, system_contract: str
) -> Run:
"""照心试演(幻境 Run):无正式人物、不占人物命台、不产生人物经历。"""
profile = resolve_profile()
async with self._sessions() as session:
run = Run(
character_id=None,
status="queued",
kind="rehearsal",
goal=prompt[:300] if prompt else None,
origin="embryo_rehearse",
model_hint=str(profile.params.get("slot", "chat")),
)
session.add(run)
await session.commit()
run_id = run.id
self._spawn(
run_id=run_id,
character_id=None,
prompt=prompt,
trace_id=None,
seat_key=f"embryo:{embryo_id}",
system_contract=system_contract,
on_final=None,
)
async with self._sessions() as session:
created = await session.get(Run, run_id)
assert created is not None
return created
@property
def shenshi(self) -> ShenshiClient:
"""神识客户端(息壤提取等功能任务的受控取用点)。"""
return self.loop._shenshi # noqa: SLF001
def _spawn(
self,
*,
run_id: str,
character_id: str | None,
prompt: str,
trace_id: str | None,
seat_key: str | None,
system_contract: str | None,
on_final: OnRunFinal | None,
temperature: float | None = None,
max_tokens: int | None = None,
) -> None:
task = asyncio.create_task(
self.loop.execute(
run_id=run_id,
character_id=character_id,
prompt=prompt,
trace_id=trace_id,
seat_key=seat_key,
system_contract=system_contract,
on_final=on_final,
temperature=temperature,
max_tokens=max_tokens,
)
)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
+63
View File
@@ -0,0 +1,63 @@
"""Kernel 事件日志:一炁(EventStore)之上的 Kernel 写入接口。
- 落库走 ``DbEventStore``(run 内单调 sequence,DB 为事实源,SSE 断线重放依据);
- 双发进程内 ``bus``(RunEventBus)供既有 ``/runs/{id}/stream`` SSE 实时转发。
该双发是 M0 骨架的过渡接法,M2 重建 runs 路由时收敛为 EventStore 单通道。
"""
from __future__ import annotations
from typing import Any
from app.modules.runtime.events import Event, EventStore
from app.schemas.runtime import EventResponse
from app.services.event_bus import bus
class KernelEventLog:
"""Kernel 视角的事件写入与检索。"""
def __init__(self, store: EventStore) -> None:
self._store = store
async def emit(
self,
*,
type: str, # noqa: A002 — 对齐事件字段名
run_id: str,
character_id: str,
source: str = "kernel",
payload: dict[str, Any] | None = None,
visibility: str = "public",
trace_id: str | None = None,
) -> Event:
event = await self._store.append(
Event(
type=type,
source=source,
payload=payload or {},
visibility=visibility, # type: ignore[arg-type]
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
)
)
bus.publish(run_id, _to_response(event))
return event
async def list_by_run(self, run_id: str) -> list[Event]:
return await self._store.list_by_run(run_id)
def _to_response(event: Event) -> EventResponse:
return EventResponse(
event_id=event.id,
run_id=event.run_id or "",
character_id=event.character_id or "",
type=event.type,
sequence=event.sequence if event.sequence is not None else 0,
visibility=event.visibility,
payload=event.payload,
created_at=event.created_at,
trace_id=event.trace_id,
)
+52
View File
@@ -0,0 +1,52 @@
"""Kernel 不可插拔不变量(lingtai-runtime.md §1 硬核例外)。
M0 落地其中两条的可执行断言:
- "模型可见即可重建":投影中的每条模型可见消息必须能在事件日志(llm.requested)中找到;
- 流一致性:llm.delta 事件重组文本必须与进入接纳检查的文本一致。
镜树不变量(重生成不覆盖/未归真不入忆/归真不回滚)随 M2 镜树域落地时进入本模块;
禁域裁决点随 M5 司命落地。断言失败即抛 ``InvariantViolation``:Kernel 视其为缺陷
而非业务错误,Run 进入 interrupted 并带 HJ-9000 段错误码。
"""
from __future__ import annotations
from app.modules.runtime.events import Event
from app.shenshi_client.types import ShenshiChatRequest
class InvariantViolation(Exception):
"""Kernel 不变量被违反:缺陷信号,必须中断并留痕。"""
def __init__(self, invariant: str, detail: str) -> None:
super().__init__(f"[{invariant}] {detail}")
self.invariant = invariant
self.detail = detail
def assert_model_visible_logged(
request: ShenshiChatRequest, events: list[Event]
) -> None:
"""投影消息 ⊆ 已记录的 llm.requested 消息("模型可见即可重建")。"""
requested = next((e for e in events if e.type == "llm.requested"), None)
if requested is None:
raise InvariantViolation(
"model-visible-means-logged", "存在模型请求但事件日志缺少 llm.requested"
)
logged = requested.payload.get("request", {}).get("messages", [])
if logged != [m.model_dump() for m in request.messages]:
raise InvariantViolation(
"model-visible-means-logged", "llm.requested 事件与实际发送的 IR 消息不一致"
)
def assert_stream_reconstruction(events: list[Event], accepted_text: str) -> None:
"""llm.delta 重组文本 == 进入接纳的文本(流不丢帧、不重帧)。"""
reconstructed = "".join(
e.payload.get("delta", "") for e in events if e.type == "llm.delta"
)
if reconstructed != accepted_text:
raise InvariantViolation(
"stream-reconstruction",
f"事件重组文本与接纳文本不一致:{len(reconstructed)} != {len(accepted_text)} 字符",
)
+355
View File
@@ -0,0 +1,355 @@
"""Kernel Run 循环骨架。
事件序列对应 docs/architecture/lingtai-runtime.md §3.1;瀑布节点可被插件否决。
投影当前为「调用方组装」形态:prompt 由领域层按镜树祖先链组装后传入,
三类投影来源(祖先路径/获准记忆/显式引文)的完整接入随 M3 息壤落地。
``RunOutcome`` 携带终态与生成内容,供领域层(镜树节点固化等)在 Run 结束时回调。
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.ids import new_trace_id
from app.kernel.eventlog import KernelEventLog
from app.kernel.invariants import (
InvariantViolation,
assert_model_visible_logged,
assert_stream_reconstruction,
)
from app.kernel.seat import SeatManager
from app.models.runtime import Run
from app.runtime.bus import (
NODE_LLM_PRE_REQUEST,
NODE_RESULT_PRE_ACCEPT,
NODE_RUN_REQUESTED,
NODE_TURN_STARTED,
RunContext,
Veto,
WaterfallBus,
)
from app.runtime.profile import Profile, resolve_profile
from app.shenshi_client.client import ShenshiClient
from app.shenshi_client.types import ShenshiChatRequest, ShenshiMessage
_SYSTEM_CONTRACT_PLACEHOLDER = "你是魂镜人物运行时(骨架占位契约)。"
# Run 结束回调:(生成内容, 终态)。终态仅 completed / interrupted。
OnRunFinal = Callable[[str, str], Awaitable[None]]
@dataclass
class RunOutcome:
"""Run 终态载体:循环内各终止路径写入,execute 返回前触发回调。"""
status: str = "running" # running / completed / interrupted / cancelled
content: str = ""
error_code: str = ""
extras: dict[str, Any] = field(default_factory=dict)
class KernelRunLoop:
"""单次 Run 的执行骨架:命台席位 → 事件序列 → 神识流 → 接纳断言。
``character_id`` 允许为空(照心试演无正式人物),此时必须提供 ``seat_key``。
"""
def __init__(
self,
*,
event_log: KernelEventLog,
seats: SeatManager,
bus: WaterfallBus,
shenshi: ShenshiClient,
session_factory: async_sessionmaker[AsyncSession],
timeout_seconds: float,
) -> None:
self._log = event_log
self._seats = seats
self._bus = bus
self._shenshi = shenshi
self._sessions = session_factory
self._timeout = timeout_seconds
async def execute(
self,
*,
run_id: str,
character_id: str | None,
prompt: str,
trace_id: str | None = None,
seat_key: str | None = None,
system_contract: str | None = None,
on_final: OnRunFinal | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
) -> RunOutcome:
assert character_id or seat_key, "Run 必须绑定人物或提供席位键(试演)"
trace = trace_id or new_trace_id()
outcome = RunOutcome()
try:
async with asyncio.timeout(self._timeout):
await self._execute(
run_id=run_id,
character_id=character_id,
prompt=prompt,
trace_id=trace,
seat_key=seat_key,
system_contract=system_contract,
outcome=outcome,
temperature=temperature,
max_tokens=max_tokens,
)
except TimeoutError:
outcome.status = "interrupted"
outcome.error_code = "run_timeout"
await self._finish_interrupted(
run_id, character_id, trace, code="run_timeout", message="运行超时被中断"
)
except asyncio.CancelledError:
outcome.status = "cancelled"
await self._emit(run_id, character_id, trace, "run.cancelled", visibility="public")
await self._update_run(run_id, status="cancelled")
raise
if on_final is not None and outcome.status in ("completed", "interrupted"):
await on_final(outcome.content, outcome.status)
return outcome
async def _execute(
self,
*,
run_id: str,
character_id: str | None,
prompt: str,
trace_id: str,
seat_key: str | None,
system_contract: str | None,
outcome: RunOutcome,
temperature: float | None = None,
max_tokens: int | None = None,
) -> None:
ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id)
try:
await self._bus.fire(NODE_RUN_REQUESTED, ctx)
except Veto as veto:
outcome.status, outcome.error_code = "interrupted", "HJ-1301"
await self._record_veto(run_id, character_id, trace_id, veto)
await self._finish_interrupted(
run_id, character_id, trace_id, code="HJ-1301", message=veto.reason
)
return
await self._emit(run_id, character_id, trace_id, "run.queued")
async with self._seats.foreground(seat_key or character_id or run_id):
await self._update_run(run_id, status="running")
await self._emit(run_id, character_id, trace_id, "run.started")
await self._turn(
run_id,
character_id,
trace_id,
prompt,
system_contract or _SYSTEM_CONTRACT_PLACEHOLDER,
outcome,
temperature=temperature,
max_tokens=max_tokens,
)
async def _turn(
self,
run_id: str,
character_id: str | None,
trace_id: str,
prompt: str,
system_contract: str,
outcome: RunOutcome,
temperature: float | None = None,
max_tokens: int | None = None,
) -> None:
ctx = RunContext(
run_id=run_id, character_id=character_id, trace_id=trace_id, payload={"prompt": prompt}
)
profile: Profile = resolve_profile()
await self._emit(run_id, character_id, trace_id, "turn.started")
await self._bus.fire(NODE_TURN_STARTED, ctx)
request = ShenshiChatRequest(
model=profile.params.get("model"),
slot=str(profile.params.get("slot", "chat")),
messages=[
ShenshiMessage(role="system", content=system_contract),
ShenshiMessage(role="user", content=prompt),
],
run_id=run_id,
trace_id=trace_id,
character_id=character_id,
temperature=temperature,
max_tokens=max_tokens,
)
await self._emit(
run_id,
character_id,
trace_id,
"context.projected",
payload={
"profile": profile.snapshot(),
"sources": {"system_contract": "assembled", "user_input": True},
},
visibility="internal",
)
try:
await self._bus.fire(NODE_LLM_PRE_REQUEST, ctx)
except Veto as veto:
outcome.status, outcome.error_code = "interrupted", "HJ-1301"
await self._record_veto(run_id, character_id, trace_id, veto)
await self._finish_interrupted(
run_id, character_id, trace_id, code="HJ-1301", message=veto.reason
)
return
# "模型可见即可重建"锚点:完整 IR 请求进事件日志
await self._emit(
run_id,
character_id,
trace_id,
"llm.requested",
payload={"request": request.model_dump(mode="json")},
visibility="internal",
)
text_parts: list[str] = []
usage: dict[str, Any] = {}
try:
async for delta in self._shenshi.stream_chat(request):
if delta.delta:
text_parts.append(delta.delta)
await self._emit(
run_id, character_id, trace_id, "llm.delta", payload={"delta": delta.delta}
)
if delta.finish_reason:
usage = {
"prompt_tokens": delta.usage_prompt_tokens,
"completion_tokens": delta.usage_completion_tokens,
"finish_reason": delta.finish_reason,
}
except Exception as exc: # 神识侧失败:如实上报,不伪装完成
outcome.status, outcome.error_code = "interrupted", "HJ-2001"
await self._emit(
run_id,
character_id,
trace_id,
"llm.failed",
payload={"error": str(exc)},
visibility="internal",
)
await self._finish_interrupted(
run_id, character_id, trace_id, code="HJ-2001", message=f"神识模型调用失败:{exc}"
)
return
await self._emit(run_id, character_id, trace_id, "llm.completed", payload={"usage": usage})
accepted_text = "".join(text_parts)
await self._bus.fire(
NODE_RESULT_PRE_ACCEPT, replace(ctx, payload={"result": accepted_text})
)
events = await self._log.list_by_run(run_id)
try:
assert_model_visible_logged(request, events)
assert_stream_reconstruction(events, accepted_text)
except InvariantViolation as violation:
outcome.status, outcome.error_code = "interrupted", "HJ-9000"
await self._emit(
run_id,
character_id,
trace_id,
"run.invariant_violation",
payload={"invariant": violation.invariant, "detail": violation.detail},
visibility="audit",
)
await self._finish_interrupted(
run_id,
character_id,
trace_id,
code="HJ-9000",
message=f"Kernel 不变量违反:{violation.invariant}",
)
return
await self._emit(
run_id, character_id, trace_id, "result.accepted", payload={"content": accepted_text}
)
await self._emit(run_id, character_id, trace_id, "turn.finished")
await self._emit(run_id, character_id, trace_id, "run.finished")
await self._update_run(run_id, status="completed")
outcome.status = "completed"
outcome.content = accepted_text
async def _record_veto(
self, run_id: str, character_id: str | None, trace_id: str, veto: Veto
) -> None:
await self._emit(
run_id,
character_id,
trace_id,
"safety.ruled",
payload={"reason": veto.reason, "rule": veto.rule, "details": veto.details},
visibility="audit",
)
async def _finish_interrupted(
self,
run_id: str,
character_id: str | None,
trace_id: str,
*,
code: str,
message: str,
) -> None:
await self._emit(run_id, character_id, trace_id, "run.interrupted")
await self._update_run(run_id, status="interrupted", error_code=code, error_message=message)
async def _emit(
self,
run_id: str,
character_id: str | None,
trace_id: str,
type: str, # noqa: A002
payload: dict[str, Any] | None = None,
visibility: str = "public",
) -> None:
await self._log.emit(
type=type,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
payload=payload,
visibility=visibility,
)
async def _update_run(
self,
run_id: str,
*,
status: str,
error_code: str | None = None,
error_message: str | None = None,
) -> None:
async with self._sessions() as session:
run = await session.get(Run, run_id)
if run is None:
return
run.status = status
run.error_code = error_code
run.error_message = error_message
if status == "running":
run.started_at = datetime.now(UTC)
if status in ("completed", "failed", "cancelled", "interrupted"):
run.completed_at = datetime.now(UTC)
await session.commit()
+44
View File
@@ -0,0 +1,44 @@
"""命台席位控制:同一人物同一时刻只有一条前台意识流。
M0 为进程内实现(单人物粒度 asyncio 锁 + 等待计数);多副本部署在标准档
切换 Redis 租约(lingtai-runtime.md §8),接口保持不变。
"""
from __future__ import annotations
from collections import defaultdict
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
import asyncio
class SeatManager:
"""人物粒度的前台席位。后台受托 Run(kind=background_task)不占席位。"""
def __init__(self) -> None:
self._locks: dict[str, asyncio.Lock] = {}
self._waiting: dict[str, int] = defaultdict(int)
def _lock(self, character_id: str) -> asyncio.Lock:
if character_id not in self._locks:
self._locks[character_id] = asyncio.Lock()
return self._locks[character_id]
@asynccontextmanager
async def foreground(self, character_id: str) -> AsyncIterator[None]:
"""占用前台席位;同人物并发请求在此排队(Kernel 不变量 2)。"""
lock = self._lock(character_id)
if lock.locked():
self._waiting[character_id] += 1
try:
async with lock:
yield
finally:
self._waiting[character_id] -= 1
else:
async with lock:
yield
def queue_depth(self, character_id: str) -> int:
"""该人物当前排队等待前台席位的请求数(HJ-1101 附带信息)。"""
return self._waiting.get(character_id, 0)
+17 -10
View File
@@ -30,12 +30,10 @@ from app.core.logging import (
from app.db.init_data import seed_default_data from app.db.init_data import seed_default_data
from app.db.session import AsyncSessionLocal, close_engine from app.db.session import AsyncSessionLocal, close_engine
from app.integrations.sandbox.client import SandboxClient from app.integrations.sandbox.client import SandboxClient
from app.modules.knocking.pivot import KnockingPivot
from app.modules.safety.confirmation import InMemoryConfirmationStore from app.modules.safety.confirmation import InMemoryConfirmationStore
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.services.dispatcher import Dispatcher from app.services.dispatcher import Dispatcher
from app.services.event_store import DbEventStore from app.services.event_store import DbEventStore
from app.services.personality_emotion import handle_time_event
from app.services.safety import load_governor_from_db from app.services.safety import load_governor_from_db
# 模块级初始化日志系统:import 本模块即完成 loguru 配置并接管标准 logging, # 模块级初始化日志系统:import 本模块即完成 loguru 配置并接管标准 logging,
@@ -86,6 +84,23 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 事件流与历史查询不依赖沙箱,始终可用。 # 事件流与历史查询不依赖沙箱,始终可用。
_app.state.event_store = DbEventStore(AsyncSessionLocal) _app.state.event_store = DbEventStore(AsyncSessionLocal)
# Kernel(rewrite/1.0 M0):极小硬核 + 事件日志 + 前台席位;神识客户端按配置装配,
# 未配置神识地址时为进程内回声桩(骨架形态,见 app.shenshi_client)。
from app.kernel import Kernel, KernelEventLog
from app.shenshi_client import build_shenshi_client
_app.state.kernel = Kernel(
shenshi=build_shenshi_client(settings.shenshi_base_url, settings.shenshi_service_token),
session_factory=AsyncSessionLocal,
event_log=KernelEventLog(_app.state.event_store),
timeout_seconds=float(settings.run_timeout_seconds),
)
# 镜生域事件写入绑定一炁(灵胚/启镜/命台/死亡事件落 events 表)
from app.domains.jingsheng import bind_event_store
bind_event_store(_app.state.event_store)
# ── MCP 进程管理器(stdio 子进程生命周期管理) ────────────── # ── MCP 进程管理器(stdio 子进程生命周期管理) ──────────────
from app.integrations.mcp.process_manager import ( from app.integrations.mcp.process_manager import (
ProcessManager, ProcessManager,
@@ -100,12 +115,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 一炁进程内订阅分发器:订阅 EventStore 全量事件,按 glob pattern 分发给注册的 handler。 # 一炁进程内订阅分发器:订阅 EventStore 全量事件,按 glob pattern 分发给注册的 handler。
# 各枢核心就绪后在 start() 前 register;lifespan 管理 start/stop。 # 各枢核心就绪后在 start() 前 register;lifespan 管理 start/stop。
_app.state.dispatcher = Dispatcher(_app.state.event_store) _app.state.dispatcher = Dispatcher(_app.state.event_store)
# 灵核情绪状态机:订阅辰正 time.* 事件驱动 EmotionState(设计 §11,首个真实 handler)
_app.state.dispatcher.register("time.*", handle_time_event)
await _app.state.dispatcher.start() await _app.state.dispatcher.start()
# 叩心枢主动对话守护进程:后台常驻循环,在 dispatcher 就绪后启动
_app.state.knocking_pivot = KnockingPivot(_app.state.event_store)
await _app.state.knocking_pivot.start()
_app.state.confirmation_store = InMemoryConfirmationStore() _app.state.confirmation_store = InMemoryConfirmationStore()
if settings.use_db_confirmation_store: if settings.use_db_confirmation_store:
from app.modules.safety.confirmation import DbConfirmationStore from app.modules.safety.confirmation import DbConfirmationStore
@@ -160,9 +170,6 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
yield yield
# 叩心枢先停,再停 dispatcher
if getattr(_app.state, "knocking_pivot", None) is not None:
await _app.state.knocking_pivot.stop()
# Dispatcher 先停(取消 pump/consumer task + unsubscribe all_queue),再关 http/engine # Dispatcher 先停(取消 pump/consumer task + unsubscribe all_queue),再关 http/engine
if getattr(_app.state, "dispatcher", None) is not None: if getattr(_app.state, "dispatcher", None) is not None:
await _app.state.dispatcher.stop() await _app.state.dispatcher.stop()
+9 -10
View File
@@ -3,7 +3,6 @@
from app.models.big_cycle import BigCycle from app.models.big_cycle import BigCycle
from app.models.candidates import MemoryCandidate, ProfileCandidate from app.models.candidates import MemoryCandidate, ProfileCandidate
from app.models.character import Character from app.models.character import Character
from app.models.chat import ChatMessage, ChatSession
from app.models.drive import CuriosityQuestion, ExploratorySubgoal from app.models.drive import CuriosityQuestion, ExploratorySubgoal
from app.models.evolution import EvolutionCandidate from app.models.evolution import EvolutionCandidate
from app.models.governance import ( from app.models.governance import (
@@ -16,16 +15,22 @@ from app.models.governance import (
) )
from app.models.idempotency import IdempotencyKey from app.models.idempotency import IdempotencyKey
from app.models.imports import CharacterImportJob, ImportItem from app.models.imports import CharacterImportJob, ImportItem
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat
from app.models.lorebook import Lorebook, LorebookEntry from app.models.lorebook import Lorebook, LorebookEntry
from app.models.mcp_servers import McpServer from app.models.mcp_servers import McpServer
from app.models.memory import Memory, MemoryLink from app.models.mirror_tree import (
Canonization,
Citation,
MirrorTree,
TreeNode,
TreeTransplant,
)
from app.models.personality import PersonalityProposal, PersonalityVersion from app.models.personality import PersonalityProposal, PersonalityVersion
from app.models.personality_emotion import CharacterEmotionState
from app.models.rbac import Permission, Role, RolePermission, UserRole from app.models.rbac import Permission, Role, RolePermission, UserRole
from app.models.runtime import Event, Run from app.models.runtime import Event, Run
from app.models.xirang import FactualRecord, Memory as XirangMemory, MemoryTrigger
from app.models.safety import AuditEvent, PendingConfirmationRecord from app.models.safety import AuditEvent, PendingConfirmationRecord
from app.models.skills import Skill from app.models.skills import Skill
from app.models.time_anniversary import TimeAnniversary
from app.models.user import User from app.models.user import User
from app.models.world import CharacterWorldEntity, WorldRelation from app.models.world import CharacterWorldEntity, WorldRelation
@@ -34,11 +39,8 @@ __all__ = [
"AuditEvent", "AuditEvent",
"BigCycle", "BigCycle",
"Character", "Character",
"CharacterEmotionState",
"CharacterImportJob", "CharacterImportJob",
"CharacterWorldEntity", "CharacterWorldEntity",
"ChatMessage",
"ChatSession",
"CuriosityQuestion", "CuriosityQuestion",
"Event", "Event",
"EvolutionCandidate", "EvolutionCandidate",
@@ -50,9 +52,7 @@ __all__ = [
"Lorebook", "Lorebook",
"LorebookEntry", "LorebookEntry",
"McpServer", "McpServer",
"Memory",
"MemoryCandidate", "MemoryCandidate",
"MemoryLink",
"PendingConfirmationRecord", "PendingConfirmationRecord",
"PerceptionChannel", "PerceptionChannel",
"Permission", "Permission",
@@ -64,7 +64,6 @@ __all__ = [
"RolePermission", "RolePermission",
"Run", "Run",
"Skill", "Skill",
"TimeAnniversary",
"User", "User",
"UserRole", "UserRole",
"WorldEntity", "WorldEntity",
+10
View File
@@ -41,3 +41,13 @@ class Character(Base):
) )
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# ── rewrite/1.0 镜生域增量列(data-model.md §1):来源、道体锚点、死亡与冷静期 ──
source: Mapped[str] = mapped_column(
String(40), nullable=False, default="original", server_default="original", index=True
)
identity_baseline_json: Mapped[str] = mapped_column(
Text, nullable=False, default="{}", server_default="{}"
)
death_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
calm_period_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
died_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
-52
View File
@@ -1,52 +0,0 @@
"""聊天会话与消息模型。"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_message_id, new_session_id
from app.core.time import utc_now
from app.db.base import Base
class ChatSession(Base):
__tablename__ = "chat_sessions"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_session_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
title: Mapped[str | None] = mapped_column(String(200))
status: Mapped[str] = mapped_column(String(40), nullable=False, default="active", index=True)
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, default="", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class ChatMessage(Base):
__tablename__ = "chat_messages"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_message_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
session_id: Mapped[str] = mapped_column(String(80), ForeignKey("chat_sessions.id"), index=True)
role: Mapped[str] = mapped_column(String(40), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
modality: Mapped[str] = mapped_column(String(40), nullable=False, default="text")
run_id: Mapped[str | None] = mapped_column(String(80), ForeignKey("runs.id"), index=True)
metadata_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
sensitivity: Mapped[str] = mapped_column(String(40), nullable=False, default="low")
versions_json: Mapped[str] = mapped_column(
Text,
nullable=False,
default="[]",
comment="版本历史 JSON 数组:[{content, created_at}],按生成顺序排列,末尾最新",
)
active_version_idx: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="当前活跃版本在 versions_json 中的索引(0-based)",
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+84
View File
@@ -0,0 +1,84 @@
"""镜生域模型:灵胚、执镜关系、命台、魂籍(data-model.md §1)。
- 灵胚只有预备身份,启镜(activate)才产生正式人物与执镜关系;
- 命台人物粒度唯一(UNIQUE(character_id)),租约到期可被接管,epoch 递增;
- 魂籍版本不可变(UNIQUE(character_id, version)),签名绑定人物与版本。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_custodianship_id, new_embryo_id, new_identity_record_id, new_seat_id
from app.core.time import utc_now
from app.db.base import Base
class EmbryoDraft(Base):
"""灵胚:启镜前的可审阅草稿。放弃灵胚是删除草稿,不是人物死亡。"""
__tablename__ = "embryo_drafts"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_embryo_id)
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# drafting / previewing / confirming / activated / abandoned
status: Mapped[str] = mapped_column(String(40), nullable=False, default="drafting", index=True)
# original / adopted / imported / serendipity
source: Mapped[str] = mapped_column(String(40), nullable=False, default="original")
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
identity_baseline_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 照心试演引用:[{run_id, prompt, performed_at}],内容在事件日志,不入正式经历
rehearsals_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class Custodianship(Base):
"""执镜关系:治理责任(1.0 一对一),与缘契(covenants)严格分离。"""
__tablename__ = "custodianships"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_custodianship_id)
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, unique=True, index=True
)
custodian_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class RuntimeSeat(Base):
"""命台:人物唯一权威运行位置;holder 持有租约,过期可接管,epoch 每次获取递增。"""
__tablename__ = "runtime_seats"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_seat_id)
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, unique=True, index=True
)
epoch: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
holder: Mapped[str | None] = mapped_column(String(120))
lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class IdentityRecord(Base):
"""魂籍:可验证、版本化的身份与状态包。版本不可变;复制不能凭相同 ID 冒充原人物。"""
__tablename__ = "identity_records"
__table_args__ = (UniqueConstraint("character_id", "version", name="uq_identity_char_version"),)
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_identity_record_id)
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, index=True
)
version: Mapped[int] = mapped_column(Integer, nullable=False)
payload_json: Mapped[str] = mapped_column(Text, nullable=False)
digest: Mapped[str] = mapped_column(String(64), nullable=False)
signature: Mapped[str] = mapped_column(String(128), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
-46
View File
@@ -1,46 +0,0 @@
"""记忆模型。"""
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_memory_id, new_memory_link_id
from app.core.time import utc_now
from app.db.base import Base
class Memory(Base):
__tablename__ = "memories"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
type: Mapped[str] = mapped_column(String(60), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
source: Mapped[str] = mapped_column(String(80), nullable=False, default="user_explicit")
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
importance: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
sensitivity: Mapped[str] = mapped_column(String(40), nullable=False, default="low")
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
status: Mapped[str] = mapped_column(String(40), nullable=False, default="active", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
temporal_context_json: Mapped[str] = mapped_column(
Text, nullable=False, default="{}", server_default="{}"
)
class MemoryLink(Base):
__tablename__ = "memory_links"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_link_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
source_memory_id: Mapped[str] = mapped_column(String(80), ForeignKey("memories.id"), index=True)
target_memory_id: Mapped[str] = mapped_column(String(80), ForeignKey("memories.id"), index=True)
relation: Mapped[str] = mapped_column(String(80), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
+132
View File
@@ -0,0 +1,132 @@
"""镜树域模型(data-model.md §2):主题镜树、节点、引文锚、归真记录、移栽谱系。
不变量(应用层强制 + 测试钉死):
- 候答重生成=插入新行,旧节点永不 UPDATE 内容或状态回退;
- 后代节点绑定用户实际采用的候答(问节默认挂工作入口);
- working_entry 变更不触碰 canonizations(采用与归真解耦);
- canonizations 不可变,正影=新版本节点 + 新归真记录。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import (
new_canonization_id,
new_citation_id,
new_mirror_tree_id,
new_tree_node_id,
new_transplant_id,
)
from app.core.time import utc_now
from app.db.base import Base
class MirrorTree(Base):
"""主题镜树:一条归真主脉 + 多条推演枝 + 一个工作入口。"""
__tablename__ = "mirror_trees"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_mirror_tree_id)
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(300), nullable=False)
# active / archived
status: Mapped[str] = mapped_column(String(40), nullable=False, default="active", index=True)
# 默认界域(reality / persistent_world / simulation),问节未显式指定时继承
default_realm: Mapped[str] = mapped_column(String(40), nullable=False, default="reality")
# 采用:当前阅读与继续生长的工作入口(answer/continuation 节点)
working_entry_node_id: Mapped[str | None] = mapped_column(String(80))
# 移栽来源谱系:{source_tree_id, source_node_id};原生树为空
transplanted_from_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class TreeNode(Base):
"""统一节点表:问节 / 候答 / 续思。
拓扑:answer.parent=question;question.parent=所依据的候答(默认工作入口);
continuation.parent=被延展的候答或续思。编辑已发送内容=新建分叉节点(supersedes 指旧节点)。
"""
__tablename__ = "tree_nodes"
__table_args__ = (
UniqueConstraint("tree_id", "supersedes_node_id", name="uq_tree_node_supersedes"),
)
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_tree_node_id)
tree_id: Mapped[str] = mapped_column(String(80), ForeignKey("mirror_trees.id"), index=True)
parent_node_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("tree_nodes.id"), index=True
)
# question / answer / continuation(契约词典 §9.1)
node_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
# budding / sealed_residual / solidified(契约词典 §9.2)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="budding", index=True)
# user / character(问节作者为 user;候答为 character)
author: Mapped[str] = mapped_column(String(40), nullable=False, default="user")
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 节点界域(契约词典 §12);默认继承树默认值
realm: Mapped[str] = mapped_column(String(40), nullable=False, default="reality")
# 生成元数据:{model, run_id, trace_id, instructions}
generation_meta_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 正影/编辑分叉:新节点指向被替代的旧节点(旧节点原样保留)
supersedes_node_id: Mapped[str | None] = mapped_column(String(80))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class Citation(Base):
"""引文锚:稳定引用 + 原文快照。来源正影时旧锚不静默替换,仅提示后继版本。"""
__tablename__ = "citations"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_citation_id)
node_id: Mapped[str] = mapped_column(String(80), ForeignKey("tree_nodes.id"), index=True)
# text_range / image_region / pdf_region / attachment / av_segment(契约词典 §9.5)
target_type: Mapped[str] = mapped_column(String(40), nullable=False)
# 定位信息:{文本区间 / 页码区域 / 时间段等}
locator_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 引用时的原文/媒体快照(来源变化不被覆盖)
snapshot: Mapped[str] = mapped_column(Text, nullable=False, default="")
source_id: Mapped[str] = mapped_column(String(200), nullable=False)
source_version: Mapped[int] = mapped_column(nullable=False, default=1)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class Canonization(Base):
"""归真记录(不可变):路径进入明确的 canonical_scope;主脉=同作用域记录的路径并集。"""
__tablename__ = "canonizations"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_canonization_id)
tree_id: Mapped[str] = mapped_column(String(80), ForeignKey("mirror_trees.id"), index=True)
character_id: Mapped[str] = mapped_column(String(80), index=True)
# reality / persistent_world / simulation(契约词典 §9.4)
scope: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
world_id: Mapped[str | None] = mapped_column(String(80))
# 路径端点(answer/continuation 节点)
endpoint_node_id: Mapped[str] = mapped_column(String(80), index=True)
# 端点祖先链快照(root→endpoint 的节点 ID 序列)
path_nodes_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
confirmed_by: Mapped[str] = mapped_column(String(80), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class TreeTransplant(Base):
"""移栽谱系:分支复制为新树,保留来源引用;树间只引用不物理合并。"""
__tablename__ = "tree_transplants"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_transplant_id)
source_tree_id: Mapped[str] = mapped_column(String(80), index=True)
source_node_id: Mapped[str] = mapped_column(String(80))
new_tree_id: Mapped[str] = mapped_column(String(80), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
-32
View File
@@ -1,32 +0,0 @@
"""灵核情绪状态持久化(设计 §11.4)。"""
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.time import utc_now
from app.db.base import Base
class CharacterEmotionState(Base):
"""角色当前情绪状态(单行 per character,最新状态 + 触发溯源)。
设计 §11.4:``mood`` / ``intensity`` 由辰正 ``time.*`` 事件驱动动态维护;
``baseline_*`` 同步自人格版本 ``emotion_baseline``,作为衰减回归锚点。
同步写 ``Character.current_mood_id``(激活既有字段,前端列表无需 join)。
"""
__tablename__ = "character_emotion_states"
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), primary_key=True
)
mood: Mapped[str] = mapped_column(String(40), nullable=False, default="neutral")
intensity: Mapped[float] = mapped_column(Float, nullable=False, default=0.2)
baseline_mood: Mapped[str] = mapped_column(String(40), nullable=False, default="neutral")
baseline_intensity: Mapped[float] = mapped_column(Float, nullable=False, default=0.2)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
last_trigger_event_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
+8 -3
View File
@@ -14,11 +14,16 @@ class Run(Base):
__tablename__ = "runs" __tablename__ = "runs"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_run_id) id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_run_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True) # rewrite/1.0:照心试演(kind=rehearsal)无正式人物,character_id 允许为空;
session_id: Mapped[str | None] = mapped_column( # 旧 chat_sessions 外键随线性会话模型退役移除
String(80), ForeignKey("chat_sessions.id"), index=True character_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("characters.id"), index=True, nullable=True
) )
status: Mapped[str] = mapped_column(String(40), nullable=False, default="queued", index=True) status: Mapped[str] = mapped_column(String(40), nullable=False, default="queued", index=True)
# run.kind:foreground / background_task / awakening / rehearsal(契约词典 §8.2)
kind: Mapped[str] = mapped_column(
String(40), nullable=False, default="foreground", server_default="foreground"
)
goal: Mapped[str | None] = mapped_column(String(300)) goal: Mapped[str | None] = mapped_column(String(300))
origin: Mapped[str] = mapped_column(String(40), nullable=False, default="chat") origin: Mapped[str] = mapped_column(String(40), nullable=False, default="chat")
model_hint: Mapped[str | None] = mapped_column(String(120)) model_hint: Mapped[str | None] = mapped_column(String(120))
-41
View File
@@ -1,41 +0,0 @@
"""时态纪念日模型(辰正 §5.3)。"""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_anniversary_id
from app.core.time import utc_now
from app.db.base import Base
class TimeAnniversary(Base):
"""纪念日 / 特殊日子(辰正设计 §5.3)。
枚举值(calendar / kind / subject_type / source)以契约字典
``docs/contracts/contract-glossary.md`` §11.4.1–§11.4.4 为单一事实源。
"""
__tablename__ = "time_anniversaries"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_anniversary_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
calendar: Mapped[str] = mapped_column(String(20), nullable=False) # solar / lunar
date: Mapped[str] = mapped_column(String(20), nullable=False) # 公历 ISO 或农历日期编码
recurs_yearly: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
kind: Mapped[str] = mapped_column(
String(40), nullable=False
) # birthday / anniversary / milestone / custom
subject_type: Mapped[str] = mapped_column(
String(40), nullable=False
) # user / character_self / relationship / custom
source: Mapped[str] = mapped_column(
String(40), nullable=False, default="manual"
) # auto / manual / imported
metadata_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
+80
View File
@@ -0,0 +1,80 @@
"""息壤(记忆域)模型(data-model.md §3)。
- 三级:微痕 trace(短期)/ 明忆 explicit(低敏长期)/ 重忆 sensitive(须确认);
- 双源:user_explicit / self_inferred;
- 版本链:正影=新行 prev_version_id 指旧行,旧行 superseded 停止普通召回,内容不改写;
- 删除:tombstoned 墓碑不承载原文,主存储/检索/上下文全链路停用。
"""
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_factual_record_id, new_memory_id, new_memory_trigger_id
from app.core.time import utc_now
from app.db.base import Base
class Memory(Base):
"""统一记忆表:三级 × 类型 × 状态 × 双源(契约词典 §10)。"""
__tablename__ = "memories"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_id)
character_id: Mapped[str] = mapped_column(String(80), index=True, nullable=False)
# trace / explicit / sensitive(§10.1)
tier: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
# persona / episodic / instruction / relationship_view / fact_candidate(§10.2)
type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
# candidate / pending / active / superseded / tombstoned(§10.3)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="candidate", index=True)
# user_explicit / self_inferred(§10.4)
source: Mapped[str] = mapped_column(String(20), nullable=False, default="self_inferred")
content: Mapped[str] = mapped_column(Text, nullable=False)
# 可解释性:指向镜树节点/事件(撤销与来源展开的依据)
source_refs_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
sensitivity: Mapped[str] = mapped_column(String(20), nullable=False, default="none")
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=50)
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
# 活力值:2^(-Δdays/半衰期) 衰减、命中强化(§6.2)
vitality: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
# 微痕有效期;空表示不过期
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 记忆界域:境界记忆不进真界上下文(§5)
realm: Mapped[str] = mapped_column(String(40), nullable=False, default="reality")
prev_version_id: Mapped[str | None] = mapped_column(String(80), index=True)
# 召回统计(召回透明)
recall_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_recalled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class MemoryTrigger(Base):
"""记忆触发条件:keyword(微痕激活管线共用)与 disclosure(单一触发)。"""
__tablename__ = "memory_triggers"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_trigger_id)
memory_id: Mapped[str] = mapped_column(String(80), ForeignKey("memories.id"), index=True)
# keyword / disclosure / vector(§10.5)
kind: Mapped[str] = mapped_column(String(20), nullable=False)
pattern: Mapped[str] = mapped_column(String(200), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class FactualRecord(Base):
"""实录(不可变):发生了什么 + 证据引用;与心痕(relationship_view)分离。"""
__tablename__ = "factual_records"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_factual_record_id)
character_id: Mapped[str] = mapped_column(String(80), index=True, nullable=False)
canonization_id: Mapped[str] = mapped_column(String(80), index=True)
# 证据:路径节点引用 + 摘要
evidence_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
Whitespace-only changes.
-18
View File
@@ -1,18 +0,0 @@
"""聊天历史纯组装函数(IO 查询已迁 services/chat)。
``history_pairs`` 把已加载的消息列表转成 ``(role, content)`` 对,供上下文组装使用。
本模块只接收已加载数据。
"""
from __future__ import annotations
from app.models.chat import ChatMessage
def history_pairs(messages: list[ChatMessage]) -> list[tuple[str, str]]:
"""把消息列表转成 (role, content) 对,供上下文组装使用。"""
return [
(message.role, message.content)
for message in messages
if message.role in {"user", "assistant", "system"}
]
-11
View File
@@ -1,11 +0,0 @@
"""聊天策略模块。"""
from app.core.config import settings
from app.core.errors import AppError
def validate_text_message(*, modality: str, content: str) -> None:
if modality != "text":
raise AppError("unsupported_media_type", "当前阶段仅支持文本消息。")
if len(content.encode("utf-8")) > settings.max_text_message_bytes:
raise AppError("payload_too_large", "单条文本消息超过大小限制。")
@@ -1 +0,0 @@
"""大周天记忆/世界抽取领域纯逻辑(造消息 / 解析记忆候选与世界实体草稿,禁止 IO)。"""
-140
View File
@@ -1,140 +0,0 @@
"""解析模型返回为记忆/世界抽取草稿(纯函数,无 IO)。
模型输出对象 ``{"memories": [...], "entities": [...]}``。缺省取**安全悲观默认**:
记忆 ``sensitivity=low``、世界 ``state_type=inference``(非 fact,召回降权,见
[世界模型设计] ../../docs/domain/core-domains.md §8)。
"""
import json
import re
from dataclasses import dataclass, field
from typing import Any
@dataclass
class MemoryCandidateDraft:
type: str
title: str
content: str
confidence: float = 0.5
importance: float = 0.5
sensitivity: str = "low"
reason: str = ""
@dataclass
class WorldEntityDraft:
type: str
name: str
attributes: dict[str, Any] = field(default_factory=dict)
state_type: str = "inference"
confidence: float = 0.5
_MEMORY_TYPES = {
"user",
"preference",
"event",
"relationship",
"skill",
"goal",
"other",
}
_ENTITY_TYPES = {
"self",
"person",
"object",
"place",
"device",
"project",
"task",
"concept",
}
_STATE_TYPES = {"fact", "observation", "inference", "hypothesis", "stale"}
_SENSITIVITY = {"low", "medium", "high"}
def parse_extraction(
raw: str,
) -> tuple[list[MemoryCandidateDraft], list[WorldEntityDraft]]:
data = extract_json_object(raw)
if not isinstance(data, dict):
return [], []
return parse_memory_drafts(data.get("memories")), _parse_entities(data.get("entities"))
def parse_memory_drafts(value: Any) -> list[MemoryCandidateDraft]:
if not isinstance(value, list):
return []
result: list[MemoryCandidateDraft] = []
for item in value:
if not isinstance(item, dict):
continue
title = str(item.get("title", "")).strip()
content = str(item.get("content", "")).strip()
if not title or not content:
continue
result.append(
MemoryCandidateDraft(
type=_enum(item.get("type"), _MEMORY_TYPES, "other"),
title=title,
content=content,
confidence=_clamp(item.get("confidence"), 0.5),
importance=_clamp(item.get("importance"), 0.5),
sensitivity=_enum(item.get("sensitivity"), _SENSITIVITY, "low"),
reason=str(item.get("reason", "")).strip(),
)
)
return result
def _parse_entities(value: Any) -> list[WorldEntityDraft]:
if not isinstance(value, list):
return []
result: list[WorldEntityDraft] = []
for item in value:
if not isinstance(item, dict):
continue
name = str(item.get("name", "")).strip()
if not name:
continue
attrs = item.get("attributes")
result.append(
WorldEntityDraft(
type=_enum(item.get("type"), _ENTITY_TYPES, "concept"),
name=name,
attributes=attrs if isinstance(attrs, dict) else {},
state_type=_enum(item.get("state_type"), _STATE_TYPES, "inference"),
confidence=_clamp(item.get("confidence"), 0.5),
)
)
return result
def extract_json_object(raw: str) -> dict[str, Any]:
text = raw.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
start = text.find("{")
end = text.rfind("}")
if start != -1 and end > start:
text = text[start : end + 1]
try:
data = json.loads(text)
except json.JSONDecodeError:
return {}
return data if isinstance(data, dict) else {}
def _enum(value: Any, valid: set[str], default: str) -> str:
text = str(value or "").strip().lower()
return text if text in valid else default
def _clamp(value: Any, default: float) -> float:
try:
num = float(value)
except (TypeError, ValueError):
return default
return max(0.0, min(1.0, num))
-46
View File
@@ -1,46 +0,0 @@
"""大周天记忆/世界抽取 prompt 构建(纯函数,无 IO)。
复用 ``EvaluationSnapshot``(态势快照),产出 ``(system, user)`` 消息对,喂给
模型网关。模型返回的 JSON 对象由 ``extract`` 模块解析。
枚举取值以 [记忆系统设计](../../docs/domain/memory-model.md) §6、
[世界模型设计](../../docs/domain/core-domains.md) §8 与
[数据库 schema](../../docs/contracts/contract-glossary.md) §20 为准。
"""
from app.modules.big_cycle.prompts import EvaluationSnapshot
def build_extraction_messages(
snapshot: EvaluationSnapshot,
) -> tuple[str, str]:
system = (
"你是魂镜的大周天记忆/世界抽取器。基于本轮自我评估的态势快照与近期记忆,"
"抽取值得长期记住的记忆候选与世界实体候选(用户、项目、地点、对象等)。"
"只抽取有长期价值、非琐碎的信息;低置信度或推断信息标注为 inference 而非 fact,"
"不得把推断伪装成已确认事实。"
'以 JSON 对象返回:{"memories": [...], "entities": [...]}。'
'memories 每项:{"type": str(user/preference/event/relationship/skill/goal/fact/other), '
'"title": str, "content": str, "importance": 0~1, '
'"sensitivity": "low|medium|high", "confidence": 0~1, "reason": str}。'
'entities 每项:{"type": str(self/person/object/place/device/project/task/concept), '
'"name": str, "attributes": 对象, '
'"state_type": str(fact/observation/inference/hypothesis/stale), "confidence": 0~1}。'
'无值得抽取的内容时返回 {"memories": [], "entities": []}。'
"只返回 JSON 对象,不要解释或 markdown。"
)
user = (
f"记忆总数:{snapshot.memory_count}\n"
f"世界实体数:{snapshot.world_entity_count}\n"
f"自我评估结论:{snapshot.conclusion or '(无明显态势)'}\n"
f"当前人格摘要:\n{snapshot.personality_summary or '(尚未形成)'}\n"
f"近期记忆摘要:\n{_format_memories(snapshot.recent_memories)}"
)
return system, user
def _format_memories(memories: list[tuple[str, str]]) -> str:
if not memories:
return "(暂无)"
lines = [f"[{idx}] {title}:{content}" for idx, (title, content) in enumerate(memories)]
return "\n".join(lines)
+4 -1
View File
@@ -44,8 +44,11 @@ def character_book_to_entries(book: dict | None) -> list[dict]:
"""ST character_book entries → 世谱条目构造参数。""" """ST character_book entries → 世谱条目构造参数。"""
if not book: if not book:
return [] return []
raw_entries = book.get("entries") or []
if isinstance(raw_entries, dict): # ST V2 规范:entries 以 uid 为键的对象
raw_entries = list(raw_entries.values())
result = [] result = []
for e in book.get("entries") or []: for e in raw_entries:
enabled_raw = e.get("enabled", True) enabled_raw = e.get("enabled", True)
result.append( result.append(
{ {
-40
View File
@@ -1,40 +0,0 @@
"""叩心枢(KnockingPivot):人物主动对话守护模块。
设计见 docs/domain/core-domains.md。
叩心枢与辰正(时感)、灵核(情绪)并列——辰正提供时态语境,
灵核产出情绪状态,叩心判断「此时该不该开口」。
记忆浮现、情绪波动、时光流逝 → 叩动心弦 → 自然想说话。
"""
from app.modules.knocking.conditions import (
AnniversaryCondition,
BigCycleCompletedCondition,
ConditionResult,
CuriosityCondition,
EmotionChangeCondition,
EvolutionAppliedCondition,
MemoryRecallCondition,
ProactiveCondition,
RelationshipMilestoneCondition,
TimeElapsedCondition,
TimeOfDayCondition,
UserReunionCondition,
)
from app.modules.knocking.pivot import KnockingPivot
__all__ = [
"AnniversaryCondition",
"BigCycleCompletedCondition",
"ConditionResult",
"CuriosityCondition",
"EmotionChangeCondition",
"EvolutionAppliedCondition",
"KnockingPivot",
"MemoryRecallCondition",
"ProactiveCondition",
"RelationshipMilestoneCondition",
"TimeElapsedCondition",
"TimeOfDayCondition",
"UserReunionCondition",
]
-573
View File
@@ -1,573 +0,0 @@
"""叩心枢触发条件引擎:接口与实现。
设计 docs/domain/core-domains.md §5。
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.big_cycle import BigCycle
from app.models.chat import ChatMessage
from app.models.drive import CuriosityQuestion
from app.models.evolution import EvolutionCandidate
from app.models.memory import Memory
from app.models.personality_emotion import CharacterEmotionState
from app.models.time_anniversary import TimeAnniversary
from app.modules.personality.emotion import EmotionState
# ---------------------------------------------------------------------------
# 条件接口
# ---------------------------------------------------------------------------
@dataclass
class ConditionResult:
"""条件检查结果。"""
triggered: bool = False
"""是否触发。"""
trigger_reason: str = ""
"""触发理由描述(传入 opener prompt)。"""
context: dict = field(default_factory=dict)
"""开场上下文数据(可选的纪念日/好奇心问题等)。"""
condition_name: str = ""
"""触发条件的唯一名称(用于事件推送的 trigger 字段)。"""
class ProactiveCondition:
"""主动触发条件检查器基类。
子类重写 check() 方法,返回 ConditionResult。
纯逻辑,不修改 DB——叩心枢主循环在 check 通过后才执行写入。
"""
async def check(
self,
character_id: str,
session: AsyncSession,
*,
now: datetime | None = None,
) -> ConditionResult:
raise NotImplementedError
# ---------------------------------------------------------------------------
# 第一梯队 ▸ 数据已在系统中流动
# ---------------------------------------------------------------------------
class TimeElapsedCondition(ProactiveCondition):
"""空闲超时:查该角色最后一条消息时间,超过 min_idle_minutes 触发。
初次创建的角色(无历史消息)不触发——有意为之,避免新建角色立刻问候。
"""
def __init__(self, min_idle_minutes: int = 360) -> None:
self.min_idle_minutes = min_idle_minutes
async def check(
self,
character_id: str,
session: AsyncSession,
*,
now: datetime | None = None,
) -> ConditionResult:
now = now or datetime.now(UTC)
result = await session.execute(
select(func.max(ChatMessage.created_at)).where(ChatMessage.character_id == character_id)
)
last_time: datetime | None = result.scalar()
if last_time is None:
return ConditionResult() # 无历史消息,不触发
if last_time.tzinfo is None:
last_time = last_time.replace(tzinfo=UTC)
idle = (now - last_time).total_seconds() / 60
if idle >= self.min_idle_minutes:
hours = int(idle // 60)
return ConditionResult(
triggered=True,
trigger_reason=f"距离上次对话已过去 {hours} 小时",
condition_name="idle",
)
return ConditionResult()
class TimeOfDayCondition(ProactiveCondition):
"""时段问候:若当前时间在配置的时段窗口内,按窗口名触发。
windows 配置示例:["morning", "late_night"]
默认时段映射:
morning 06:00–09:00
afternoon 12:00–14:00
evening 17:00–19:00
late_night 22:00–24:00
"""
_WINDOWS: dict[str, tuple[int, int]] = {
"morning": (6, 9),
"afternoon": (12, 14),
"evening": (17, 19),
"late_night": (22, 24),
}
def __init__(self, windows: list[str] | None = None) -> None:
self.windows = windows or ["morning", "late_night"]
async def check(
self,
_character_id: str,
_session: AsyncSession,
*,
now: datetime | None = None,
) -> ConditionResult:
now = now or datetime.now(UTC)
hour = now.hour
matched: str | None = None
for name in self.windows:
start, end = self._WINDOWS.get(name, (0, 0))
if start <= hour < end:
matched = name
break
if matched is None:
return ConditionResult()
label = {
"morning": "早晨",
"afternoon": "午后",
"evening": "傍晚",
"late_night": "深夜",
}.get(matched, matched)
return ConditionResult(
triggered=True,
trigger_reason=f"当前是{label}时段",
context={"time_of_day": matched},
condition_name="time_of_day",
)
class AnniversaryCondition(ProactiveCondition):
"""纪念日匹配:每日首次 tick 检查 TimeAnniversary 表,匹配当天日期。
已通知标记:每次触发后写一条审计事件或专用记录 key,当天不重复。
使用简单的轮次标记:本实现通过检查该 character 当天是否有
已推送的 AnniversaryCondition 触发记录来判断——非精确消费语义,
但对 30s tick 的场景已足够(最多漏 1 个 tick,不影响)。
"""
def __init__(self) -> None:
self._checked_today: set[str] = set()
"""{(character_id, date.isoformat())} 已检查过的组合——防止同一天多次 tick 重复推送。"""
async def check(
self,
character_id: str,
session: AsyncSession,
*,
now: datetime | None = None,
) -> ConditionResult:
now = now or datetime.now(UTC)
today = now.date().isoformat()
key = f"{character_id}:{today}"
if key in self._checked_today:
return ConditionResult()
self._checked_today.add(key)
result = await session.execute(
select(TimeAnniversary).where(
TimeAnniversary.character_id == character_id,
TimeAnniversary.date == today,
)
)
anniversaries: Sequence[TimeAnniversary] = result.scalars().all()
if not anniversaries:
return ConditionResult()
# 取第一个匹配的纪念日作为上下文
ann = anniversaries[0]
kind_labels = {"birthday": "生日", "reunion": "重逢纪念日", "custom": "纪念日"}
kind_label = kind_labels.get(ann.kind, ann.kind)
return ConditionResult(
triggered=True,
trigger_reason=f"今天是{kind_label}",
context={
"anniversary_kind": ann.kind,
"anniversary_label": ann.name or kind_label,
"anniversary_date": ann.date,
},
condition_name="anniversary",
)
class UserReunionCondition(ProactiveCondition):
"""用户归来检测:通过 audit_event 或辰正事件判断用户是否刚回来。
查询最近一次 time.reunion 类型审计事件,若比上次消费的时间更新则触发。
"""
def __init__(self) -> None:
self._last_seen_reunion: str | None = None
"""上次已消费的 reunion 事件的 ID——避免重复触发。"""
async def check(
self,
character_id: str,
session: AsyncSession,
*,
now: datetime | None = None, # noqa: ARG002
) -> ConditionResult:
from app.models.safety import AuditEvent
result = await session.execute(
select(AuditEvent)
.where(
AuditEvent.character_id == character_id,
AuditEvent.action == "time.reunion",
)
.order_by(AuditEvent.created_at.desc())
.limit(1)
)
event = result.scalar_one_or_none()
if event is None:
return ConditionResult()
if self._last_seen_reunion == event.id:
return ConditionResult()
self._last_seen_reunion = event.id
return ConditionResult(
triggered=True,
trigger_reason="用户回到了灵台",
condition_name="reunion",
)
class EmotionChangeCondition(ProactiveCondition):
"""情绪变化:对比缓存的上一轮 EmotionState 快照,强度变化超过阈值触发。
同一 mood 变化后至少 recheck_cooldown_ticks 个 tick 内不再检查。
"""
def __init__(self, min_delta: float = 0.3, recheck_cooldown_ticks: int = 10) -> None:
self.min_delta = min_delta
self.recheck_cooldown_ticks = recheck_cooldown_ticks
self._previous: dict[str, EmotionState] = {}
self._cooldown: dict[str, int] = {}
async def check(
self,
character_id: str,
session: AsyncSession,
*,
now: datetime | None = None, # noqa: ARG002
) -> ConditionResult:
# 冷却检查
if character_id in self._cooldown:
remaining = self._cooldown[character_id]
if remaining > 0:
self._cooldown[character_id] = remaining - 1
return ConditionResult()
del self._cooldown[character_id]
# 读当前情绪
result = await session.execute(
select(CharacterEmotionState).where(CharacterEmotionState.character_id == character_id)
)
row = result.scalar_one_or_none()
if row is None:
return ConditionResult()
current = EmotionState(mood=row.mood, intensity=row.intensity)
# 对比快照
previous = self._previous.get(character_id)
self._previous[character_id] = current # 更新快照
if previous is None:
return ConditionResult() # 首次无对比基线
if abs(current.intensity - previous.intensity) < self.min_delta:
return ConditionResult()
# 触发
mood_labels = {
"neutral": "平静",
"pleased": "愉悦",
"touched": "感动",
"wistful": "怅然怀念",
"concerned": "关切",
"festive": "喜庆",
"melancholy": "惆怅",
}
from_label = mood_labels.get(previous.mood, previous.mood)
to_label = mood_labels.get(current.mood, current.mood)
intensity_pct = round(current.intensity * 100)
self._cooldown[character_id] = self.recheck_cooldown_ticks
return ConditionResult(
triggered=True,
trigger_reason=f"情绪从「{from_label}」变为「{to_label}(强度 {intensity_pct}%)」",
context={
"from_mood": previous.mood,
"to_mood": current.mood,
"from_intensity": previous.intensity,
"to_intensity": current.intensity,
},
condition_name="emotion",
)
class CuriosityCondition(ProactiveCondition):
"""好奇心触发:查该角色是否有 CuriosityQuestion(status=active)未被用户看到。
取最新一条作为开场上下文。消费后应标记该问题为 'answered' 或 'seen'(由外部驱动,
叩心枢只读不写)。
"""
async def check(
self,
character_id: str,
session: AsyncSession,
*,
now: datetime | None = None, # noqa: ARG002
) -> ConditionResult:
result = await session.execute(
select(CuriosityQuestion)
.where(CuriosityQuestion.character_id == character_id)
.order_by(CuriosityQuestion.created_at.desc())
.limit(1)
)
question = result.scalar_one_or_none()
if question is None:
return ConditionResult()
return ConditionResult(
triggered=True,
trigger_reason="角色有一个想问的问题",
context={
"curiosity_question": question.question,
"curiosity_id": question.id,
},
condition_name="curiosity",
)
# ---------------------------------------------------------------------------
# 第二梯队 ▸ 需跨模块联动
# ---------------------------------------------------------------------------
class EvolutionAppliedCondition(ProactiveCondition):
"""演化应用:查 evolution_candidates 表,自上次检查后有新 applied 的记录则触发。
取最近一条 status=applied 的记录,用 title 和 proposal 作为开场上下文。
这样角色可以自然地谈起自己的变化。
"""
def __init__(self) -> None:
self._last_seen_at: datetime | None = None
"""上次已触发的演化候选的 created_at。"""
async def check(
self,
character_id: str,
session: AsyncSession,
*,
now: datetime | None = None, # noqa: ARG002
) -> ConditionResult:
result = await session.execute(
select(EvolutionCandidate)
.where(
EvolutionCandidate.character_id == character_id,
EvolutionCandidate.status == "applied",
)
.order_by(EvolutionCandidate.created_at.desc())
.limit(1)
)
candidate = result.scalar_one_or_none()
if candidate is None:
return ConditionResult()
if self._last_seen_at is not None and candidate.created_at <= self._last_seen_at:
return ConditionResult()
self._last_seen_at = candidate.created_at
return ConditionResult(
triggered=True,
trigger_reason=f"发生了新的变化:{candidate.title}",
context={
"evolution_title": candidate.title,
"evolution_proposal": candidate.proposal[:200],
"evolution_level": candidate.level,
},
condition_name="evolution_applied",
)
class BigCycleCompletedCondition(ProactiveCondition):
"""大周天完成:查 big_cycles 表,自上次检查后有新完成的循环则触发。
取最近一条 status=completed 的记录,从 summary 提取可分享的体悟。
"""
def __init__(self) -> None:
self._last_seen_at: datetime | None = None
"""上次已触发的循环的 completed_at。"""
async def check(
self,
character_id: str,
session: AsyncSession,
*,
now: datetime | None = None, # noqa: ARG002
) -> ConditionResult:
result = await session.execute(
select(BigCycle)
.where(
BigCycle.character_id == character_id,
BigCycle.status == "completed",
BigCycle.completed_at.isnot(None),
)
.order_by(BigCycle.completed_at.desc())
.limit(1)
)
cycle = result.scalar_one_or_none()
if cycle is None:
return ConditionResult()
if self._last_seen_at is not None and cycle.completed_at <= self._last_seen_at:
return ConditionResult()
self._last_seen_at = cycle.completed_at
summary = cycle.summary or "回顾了一下最近的记忆"
return ConditionResult(
triggered=True,
trigger_reason="大周天自我评估完成,有了新的体悟",
context={
"big_cycle_summary": summary,
"big_cycle_trigger": cycle.trigger,
},
condition_name="big_cycle_completed",
)
class MemoryRecallCondition(ProactiveCondition):
"""记忆唤醒:查角色的 memories 表,自上次检查后有新记忆则触发。
新记忆的写入源可能是:大周天抽取、资料导入、用户主动创建。
每次 tick 取最新的未消费记忆作为开场素材。
"""
def __init__(self) -> None:
self._last_seen_at: datetime | None = None
"""上次已触发的记忆的 created_at。"""
async def check(
self,
character_id: str,
session: AsyncSession,
*,
now: datetime | None = None, # noqa: ARG002
) -> ConditionResult:
result = await session.execute(
select(Memory)
.where(
Memory.character_id == character_id,
Memory.status == "active",
)
.order_by(Memory.created_at.desc())
.limit(1)
)
memory = result.scalar_one_or_none()
if memory is None:
return ConditionResult()
if self._last_seen_at is not None and memory.created_at <= self._last_seen_at:
return ConditionResult()
self._last_seen_at = memory.created_at
return ConditionResult(
triggered=True,
trigger_reason=f"想起了一件关于「{memory.title}」的事",
context={
"memory_title": memory.title,
"memory_content": memory.content[:200],
"memory_type": memory.type,
},
condition_name="memory_recall",
)
class RelationshipMilestoneCondition(ProactiveCondition):
"""关系里程碑:统计对话消息数与认识天数,达到阈值时触发。
每个角色只触发一次同类型的里程碑(记录到 _triggered 集合去重)。
按 message_milestones → day_milestones 顺序检查,命中即停。
"""
def __init__(
self,
message_milestones: list[int] | None = None,
day_milestones: list[int] | None = None,
) -> None:
self.message_milestones = message_milestones or [100, 500, 1000]
self.day_milestones = day_milestones or [30, 180, 365]
self._triggered: set[str] = set()
"""已触发过的标记集合。格式:{character_id}:messages|days:{value}"""
async def check(
self,
character_id: str,
session: AsyncSession,
*,
now: datetime | None = None,
) -> ConditionResult:
now = now or datetime.now(UTC)
# 1. 消息数里程碑
count_result = await session.execute(
select(func.count())
.select_from(ChatMessage)
.where(ChatMessage.character_id == character_id)
)
total_messages: int = count_result.scalar() or 0
for threshold in sorted(self.message_milestones):
key = f"{character_id}:messages:{threshold}"
if key in self._triggered:
continue
if total_messages >= threshold:
self._triggered.add(key)
return ConditionResult(
triggered=True,
trigger_reason=f"我们已聊了 {total_messages} 条消息",
context={
"milestone_type": "messages",
"milestone_value": total_messages,
"milestone_threshold": threshold,
},
condition_name="relationship_milestone",
)
# 2. 天数里程碑
first_result = await session.execute(
select(ChatMessage.created_at)
.where(ChatMessage.character_id == character_id)
.order_by(ChatMessage.created_at.asc())
.limit(1)
)
first_msg_at: datetime | None = first_result.scalar()
if first_msg_at is not None:
if first_msg_at.tzinfo is None:
first_msg_at = first_msg_at.replace(tzinfo=UTC)
days = (now - first_msg_at).days
for threshold in sorted(self.day_milestones):
key = f"{character_id}:days:{threshold}"
if key in self._triggered:
continue
if days >= threshold:
self._triggered.add(key)
return ConditionResult(
triggered=True,
trigger_reason=f"我们已认识了 {days} 天",
context={
"milestone_type": "days",
"milestone_value": days,
"milestone_threshold": threshold,
},
condition_name="relationship_milestone",
)
return ConditionResult()
-99
View File
@@ -1,99 +0,0 @@
"""叩心枢会话/消息创建与一炁事件推送。
设计 docs/domain/core-domains.md§7。
主动会话优先复用已有活跃会话,避免每次触发都新建会话。
只有在角色无活跃会话时才会创建新会话。
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import logger
from app.models.chat import ChatMessage, ChatSession
from app.modules.runtime.events import Event
from app.services.event_store import DbEventStore
async def create_proactive_message(
session: AsyncSession,
event_store: DbEventStore,
character_id: str,
opener_text: str,
condition_name: str,
*,
session_id: str | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
"""创建叩心枢主动发起的消息,可追加到现有会话或新建会话。
策略:
- 若 ``session_id`` 不为空,追加消息到该会话(不创建新会话)
- 若 ``session_id`` 为空,新建会话 + 消息
Returns:
{"session_id": str, "message_id": str}: 实际写入的会话 ID 和消息 ID。
"""
now = now or datetime.now(UTC)
is_new = session_id is None
if is_new:
# 创建新会话(status 默认 'active')
chat_session = ChatSession(
character_id=character_id,
title=opener_text[:60],
)
session.add(chat_session)
await session.flush()
session_id = chat_session.id
event_type = "proactive.session"
else:
event_type = "proactive.message"
# 创建开场消息(无 run_id——非流式、无推理过程)
message = ChatMessage(
character_id=character_id,
session_id=session_id,
role="assistant",
modality="text",
content=opener_text,
)
session.add(message)
await session.flush()
# 推一炁事件
payload: dict[str, Any] = {
"session_id": session_id,
"message_id": message.id,
"character_id": character_id,
"preview": opener_text[:100],
"trigger": condition_name,
"is_new": is_new,
}
if is_new:
payload["title_preview"] = opener_text[:60]
event = Event(
type=event_type,
source="knocking_pivot",
character_id=character_id,
payload=payload,
visibility="public",
created_at=now.isoformat(),
)
# 复用调用方的 session——同一事务写 events 表,避免 session A 写 chat 表、
# session B 写 events 表时两条连接争写导致锁冲突
await event_store.append(event, session=session)
logger.info(
f"knocking_pivot_{'session_created' if is_new else 'message_appended'}",
character_id=character_id,
session_id=session_id,
message_id=message.id,
trigger=condition_name,
)
return {"session_id": session_id, "message_id": message.id}
-190
View File
@@ -1,190 +0,0 @@
"""叩心枢开场消息生成器。
设计 docs/domain/core-domains.md §6。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from app.ai_provider.manager import ProviderManagerFactory
from app.core.config import settings
from app.core.logging import logger
from app.modules.knocking.conditions import ConditionResult
from app.modules.personality.emotion import EmotionState, to_prompt_text
# ---------------------------------------------------------------------------
# 回退模板
# ---------------------------------------------------------------------------
_FALLBACK_TEMPLATES: dict[str, str] = {
"idle": "{{name}}默默地看了你一眼。",
"time_of_day": "{{name}}正望着窗外出神。",
"anniversary": "今日似乎是个特别的日子。",
"reunion": "{{name}}注意到你回来了。",
"emotion": "{{name}}轻轻叹了口气。",
"curiosity": "{{name}}似乎有什么话想说。",
"memory_recall": "{{name}}若有所思地望向远方。",
"big_cycle_completed": "{{name}}似乎有所感悟。",
"evolution_applied": "{{name}}看上去有些不一样了。",
"relationship_milestone": "{{name}}望着你,嘴角泛起一丝笑意。",
"default": "{{name}}若有所思。",
}
_FALLBACK = "{{name}}正望着窗外出神。"
@dataclass
class ChatMessageBrief:
"""简略对话记录(只携带必要字段,避免依赖 ORM)。"""
role: str = ""
content: str = ""
@dataclass
class OpenerResult:
"""开场消息生成结果。"""
text: str
used_fallback: bool = False
async def generate_opener(
character_id: str,
character_name: str,
personality_summary: str | None,
emotion: EmotionState | None,
condition_result: ConditionResult,
*,
chat_history: list[ChatMessageBrief] | None = None,
memory_context: str | None = None,
now: datetime | None = None,
) -> OpenerResult:
"""根据条件触发结果生成一句开场白。
依次尝试:
1. 调用神识(LLM)生成带人格注入的开场白
2. 失败时使用模板化回退
若提供 ``chat_history``,会将其拼入 prompt 使开场更贴合最近对话脉络。
若提供 ``memory_context``,会将其作为角色的长期记忆注入 prompt。
"""
now = now or datetime.now(UTC)
timestamp = now.strftime("%Y-%m-%d %H:%M")
# 构造 prompt
emotion_text = to_prompt_text(emotion) if emotion else "平静"
summary = personality_summary or f"你是一个名叫{character_name}的角色。{_DEFAULT_PERSONA}"
trigger_reason = condition_result.trigger_reason or ""
context_parts = _build_context_parts(condition_result)
system_prompt = _build_system_prompt(summary, emotion_text, timestamp)
user_prompt = _build_user_prompt(
character_name, trigger_reason, context_parts, chat_history, memory_context
)
try:
manager = ProviderManagerFactory.create()
result = await manager.chat(
manager.to_message_list([system_prompt], [("user", user_prompt)]),
model_hint=settings.big_cycle_model,
)
text = result.content.strip()
if not text:
raise ValueError("模型返回空开场文本")
return OpenerResult(text=text, used_fallback=False)
except Exception as exc:
logger.warning(
"knocking_pivot_opener_fallback",
character_id=character_id,
condition=condition_result.condition_name,
error=str(exc),
)
template = _FALLBACK_TEMPLATES.get(condition_result.condition_name, _FALLBACK)
fallback = template.replace("{{name}}", character_name)
return OpenerResult(text=fallback, used_fallback=True)
def _build_context_parts(condition_result: ConditionResult) -> list[str]:
"""组装条件相关的上下文片段。"""
parts: list[str] = []
if condition_result.context.get("anniversary_kind"):
kind_label = condition_result.context.get("anniversary_label", "")
parts.append(f"今天是一个特别的日子:{kind_label}。")
if condition_result.context.get("curiosity_question"):
parts.append(
f"你一直想问用户一个问题:「{condition_result.context['curiosity_question']}」"
)
if condition_result.context.get("time_of_day") == "late_night":
parts.append("夜深了,万籁俱寂。")
if condition_result.context.get("memory_title"):
title = condition_result.context["memory_title"]
parts.append(f"你最近想起了一件事——「{title}」。")
if condition_result.context.get("big_cycle_summary"):
parts.append("你刚刚审视过自己的内心,有了一些新的思考。")
if condition_result.context.get("evolution_title"):
title = condition_result.context["evolution_title"]
parts.append(f"你感觉自己有了一些新的变化:{title}。")
if condition_result.context.get("milestone_type") == "messages":
count = condition_result.context["milestone_value"]
parts.append(f"你和用户的对话已经累积了 {count} 条。")
if condition_result.context.get("milestone_type") == "days":
days = condition_result.context["milestone_value"]
parts.append(f"从你们第一次对话至今,已经过去了 {days} 天。")
return parts
def _format_chat_history(history: list[ChatMessageBrief]) -> str:
"""将最近对话历史格式化为文本。"""
lines = ["\n[最近对话记录]"]
for msg in history:
speaker = "你" if msg.role == "assistant" else "用户"
lines.append(f"{speaker}:{msg.content[:200]}")
return "\n".join(lines)
def _build_system_prompt(personality_summary: str, emotion_text: str, timestamp: str) -> str:
return (
"你是一位角色扮演助手。以下是你的人物设定:\n"
f"{personality_summary}\n\n"
"[当前状态]\n"
f"- 当前时间: {timestamp}\n"
f"- 情绪状态: {emotion_text}\n"
)
def _build_user_prompt(
character_name: str,
trigger_reason: str,
context_parts: list[str],
chat_history: list[ChatMessageBrief] | None = None,
memory_context: str | None = None,
) -> str:
lines = ["[任务]"]
lines.append(f"根据以上设定,生成一句自然、贴合{character_name}性格和当前情绪的开场白。")
lines.append("要求:")
lines.append("1. 一句话,不超过 80 字")
lines.append("2. 符合角色的表达风格和说话方式、语气")
lines.append("3. 反映当前情绪状态")
lines.append("4. 不要问「你还在吗?」之类的问题")
lines.append("5. 直接输出开场白,不要任何前缀或引号")
if trigger_reason:
lines.append(f"\n触发原因:{trigger_reason}")
if context_parts:
lines.append(f"\n{' '.join(context_parts)}")
if memory_context:
lines.append(f"\n{memory_context}")
if chat_history:
lines.append(_format_chat_history(chat_history))
lines.append("\n注意:延续上次对话的语境自然地开启新话题,不要重复上一句的内容。")
return "\n".join(lines)
_DEFAULT_PERSONA = (
"你对用户保持着温和的关注,"
"平日里话不多,但每次开口都带着真诚。"
"你善于观察,能从细微之处感知对方的情绪。"
)
-366
View File
@@ -1,366 +0,0 @@
"""叩心枢(KnockingPivot)主循环。
人物主动对话守护进程:后台常驻循环(~30s tick),
遍历活跃角色 → 检查触发条件 → 生成开场 → 创建会话/消息 → 推一炁事件。
同时订阅一炁事件流(big_cycle.* / reflection.*)实现近实时快速路径。
叩心枢与辰正(时感)、灵核(情绪)并列——辰正提供时态语境,
灵核产出情绪状态,叩心判断「此时该不该开口」。
设计 docs/domain/core-domains.md §4。
"""
from __future__ import annotations
import asyncio
import contextlib
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.logging import logger
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.chat import ChatMessage, ChatSession
from app.models.personality_emotion import CharacterEmotionState
from app.modules.knocking.conditions import (
AnniversaryCondition,
BigCycleCompletedCondition,
CuriosityCondition,
EmotionChangeCondition,
EvolutionAppliedCondition,
MemoryRecallCondition,
ProactiveCondition,
RelationshipMilestoneCondition,
TimeElapsedCondition,
TimeOfDayCondition,
UserReunionCondition,
)
from app.modules.knocking.creator import create_proactive_message
from app.modules.knocking.opener import ChatMessageBrief, generate_opener
from app.modules.personality.emotion import EmotionState
from app.modules.runtime.events import Event
from app.services.event_store import DbEventStore
from app.services.memory import memory_context as _fmt_memory_context
from app.services.personality import build_personality_summary
class KnockingPivot:
"""叩心枢主动对话守护进程。
用法:
pivot = KnockingPivot(event_store)
await pivot.start()
...
await pivot.stop()
"""
# 条件注册表(第一梯队 + 第二梯队 ▸ 按优先级排列)
conditions: list[ProactiveCondition] = [
EvolutionAppliedCondition(),
BigCycleCompletedCondition(),
TimeElapsedCondition(min_idle_minutes=360),
TimeOfDayCondition(windows=["morning", "late_night"]),
AnniversaryCondition(),
UserReunionCondition(),
EmotionChangeCondition(min_delta=0.3),
MemoryRecallCondition(),
RelationshipMilestoneCondition(),
CuriosityCondition(),
]
def __init__(
self,
event_store: DbEventStore,
session_factory: async_sessionmaker[AsyncSession] | None = None,
*,
tick_interval: float = 30.0,
cooldown_minutes: int = 30,
daily_limit: int = 3,
quiet_hours_start: int = 23,
quiet_hours_end: int = 8,
) -> None:
self._event_store = event_store
self._session_factory = session_factory or AsyncSessionLocal
self._tick_interval = tick_interval
self._cooldown_minutes = cooldown_minutes
self._daily_limit = daily_limit
self._quiet_hours_start = quiet_hours_start
self._quiet_hours_end = quiet_hours_end
self._task: asyncio.Task[None] | None = None
self._event_task: asyncio.Task[None] | None = None
self._stop_event = asyncio.Event()
self._event_queue: asyncio.Queue[Event] | None = None
# per-character 冷启动冷却(character_id → 下次可触发时间)
self._cooldowns: dict[str, datetime] = {}
# per-character 日计数(character_id → 今日日期 → 次数)
self._daily_counts: dict[str, tuple[str, int]] = {}
# ------------------------------------------------------------------
# 生命周期
# ------------------------------------------------------------------
async def start(self) -> None:
if self._task is not None:
logger.warning("knocking_pivot_already_running")
return
self._stop_event.clear()
# 订阅一炁事件流(大周天/小周天完成等),实现近实时快速路径
self._event_queue = self._event_store.subscribe_by_pattern("big_cycle.*,reflection.*")
self._event_task = asyncio.create_task(self._run_event_consumer())
self._task = asyncio.create_task(self._run_loop())
logger.info("knocking_pivot_started", tick_interval=self._tick_interval)
async def stop(self) -> None:
if self._task is None:
return
self._stop_event.set()
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
if self._event_task is not None:
self._event_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._event_task
self._event_task = None
if self._event_queue is not None:
self._event_store.unsubscribe_by_pattern("big_cycle.*,reflection.*", self._event_queue)
self._event_queue = None
logger.info("knocking_pivot_stopped")
# ------------------------------------------------------------------
# 主循环
# ------------------------------------------------------------------
async def _run_loop(self) -> None:
while not self._stop_event.is_set():
try:
await self._tick()
except asyncio.CancelledError:
break
except Exception as exc:
err_str = str(exc)
if "database is locked" in err_str:
logger.warning(
"knocking_pivot_db_locked_retry",
error=err_str[:120],
)
else:
logger.error("knocking_pivot_tick_error", error=err_str)
await asyncio.sleep(self._tick_interval)
async def _run_event_consumer(self) -> None:
"""事件消费者:订阅 big_cycle.* / reflection.* 事件,近实时触发叩心检查。
与 _run_loop 并行运行,事件驱动而非轮询驱动。
每个事件提取 character_id,立即做一次条件检查(跳过冷却/日限等节流逻辑用轮询管理)。
"""
queue = self._event_queue
if queue is None:
return
while not self._stop_event.is_set():
try:
event = await asyncio.wait_for(queue.get(), timeout=30)
except TimeoutError:
continue
except asyncio.CancelledError:
break
try:
await self._handle_event(event)
except Exception: # noqa: BLE001
logger.warning("knocking_pivot_event_error", event_type=event.type)
async def _handle_event(self, event: Event) -> None:
"""响应一炁事件:提取 character_id,尝试用对应条件触发。"""
character_id = event.character_id
if not character_id:
return
now = datetime.now(UTC)
if self._in_quiet_hours(now):
return
if not self._can_trigger(character_id, now):
return
# 大周天完成 → 快速检查 BigCycleCompletedCondition
if event.type == "big_cycle.completed":
async with self._session_factory() as session:
cond = BigCycleCompletedCondition()
result = await cond.check(character_id, session, now=now)
if result.triggered:
await self._execute_trigger(character_id, result, session, now)
self._mark_triggered(character_id, now)
return
# 反思完成 → 快速检查 MemoryRecallCondition
if event.type == "reflection.completed":
async with self._session_factory() as session:
cond = MemoryRecallCondition()
result = await cond.check(character_id, session, now=now)
if result.triggered:
await self._execute_trigger(character_id, result, session, now)
self._mark_triggered(character_id, now)
return
async def _tick(self) -> None:
"""一次 tick:找出活跃角色 → 逐角色检查条件。"""
now = datetime.now(UTC)
if self._in_quiet_hours(now):
return
async with self._session_factory() as session:
active_characters = await self._list_active_characters(session)
if not active_characters:
return
for character_id in active_characters:
if not self._can_trigger(character_id, now):
continue
if await self._check_and_trigger(character_id, session, now):
# 每次 tick 只触发一个角色——命中即停
break
# ------------------------------------------------------------------
# 内部
# ------------------------------------------------------------------
async def _list_active_characters(self, session: AsyncSession) -> list[str]:
"""返回全部 status='active' 的 character_id。"""
result = await session.execute(select(Character.id).where(Character.status == "active"))
return [row[0] for row in result.all()]
def _in_quiet_hours(self, now: datetime) -> bool:
hour = now.hour
if self._quiet_hours_start <= self._quiet_hours_end:
return self._quiet_hours_start <= hour < self._quiet_hours_end
return hour >= self._quiet_hours_start or hour < self._quiet_hours_end
def _can_trigger(self, character_id: str, now: datetime) -> bool:
# 冷却
cooldown_until = self._cooldowns.get(character_id)
if cooldown_until is not None and now < cooldown_until:
return False
# 日上限
today = now.date().isoformat()
last_date, count = self._daily_counts.get(character_id, ("", 0))
return not (last_date == today and count >= self._daily_limit)
def _mark_triggered(self, character_id: str, now: datetime) -> None:
self._cooldowns[character_id] = now + timedelta(minutes=self._cooldown_minutes)
today = now.date().isoformat()
last_date, count = self._daily_counts.get(character_id, ("", 0))
if last_date == today:
self._daily_counts[character_id] = (today, count + 1)
else:
self._daily_counts[character_id] = (today, 1)
async def _check_and_trigger(
self, character_id: str, session: AsyncSession, now: datetime
) -> bool:
"""遍历条件列表,任一触发则执行并返回 True;否则返回 False。"""
for condition in self.conditions:
result = await condition.check(character_id, session, now=now)
if not result.triggered:
continue
await self._execute_trigger(character_id, result, session, now)
self._mark_triggered(character_id, now)
return True
return False
async def _execute_trigger(
self,
character_id: str,
condition_result,
session: AsyncSession,
now: datetime,
) -> None:
"""条件触发后的执行链:人格注入 → 开场(带上下文)→ 追加/新建会话 → 推事件。"""
try:
# 1. 加载角色对象(供人格摘要使用)
result = await session.execute(select(Character).where(Character.id == character_id))
character = result.scalar_one_or_none()
if character is None:
logger.warning(
"knocking_pivot_character_not_found",
character_id=character_id,
)
return
# 2. 人格注入
summary = await build_personality_summary(session, character)
# 3. 读取情绪
emotion = await self._get_emotion(session, character_id)
# 4. 查找该角色已有的活跃会话(优先复用)
session_result = await session.execute(
select(ChatSession)
.where(
ChatSession.character_id == character_id,
ChatSession.status == "active",
)
.order_by(ChatSession.updated_at.desc())
.limit(1)
)
existing_session = session_result.scalar_one_or_none()
# 5. 读取最近对话记录作为上下文
chat_history: list[ChatMessageBrief] = []
if existing_session:
msg_result = await session.execute(
select(ChatMessage)
.where(ChatMessage.session_id == existing_session.id)
.order_by(ChatMessage.created_at.desc())
.limit(5)
)
raw = list(reversed(msg_result.scalars().all()))
chat_history = [ChatMessageBrief(role=m.role, content=m.content) for m in raw]
# 6. 检索角色的长期记忆作为上下文
memory_context: str | None = None
mc = await _fmt_memory_context(session, character_id, limit=5)
memory_context = mc if mc else None
# 7. 生成开场(携带上下文与记忆)
character_name = character.display_name or character.name or character_id
opener = await generate_opener(
character_id=character_id,
character_name=character_name,
personality_summary=summary.text if summary and summary.text else None,
emotion=emotion,
condition_result=condition_result,
chat_history=chat_history,
memory_context=memory_context,
now=now,
)
# 8. 追加消息(复用已有会话,或创建新会话)
existing_id = existing_session.id if existing_session else None
await create_proactive_message(
session=session,
event_store=self._event_store,
character_id=character_id,
session_id=existing_id,
opener_text=opener.text,
condition_name=condition_result.condition_name,
now=now,
)
await session.commit()
except Exception:
await session.rollback()
raise
async def _get_emotion(self, session: AsyncSession, character_id: str) -> EmotionState | None:
result = await session.execute(
select(CharacterEmotionState).where(CharacterEmotionState.character_id == character_id)
)
row = result.scalar_one_or_none()
if row is None:
return None
return EmotionState(mood=row.mood, intensity=row.intensity)
Whitespace-only changes.
-91
View File
@@ -1,91 +0,0 @@
"""记忆上下文组装与检索结果数据结构(纯领域,无 IO)。
``MemoryRetrievalResult`` 持有已加载的 Memory ORM 实例(由 services/memory 检索后传入),
``build_memory_context`` 把它组装成注入上下文的文本。本模块只接收已加载数据、输出结构化对象。
"""
from __future__ import annotations
from datetime import UTC, datetime
from pydantic import BaseModel, ConfigDict
from app.core.time import utc_now
from app.models.memory import Memory
class MemoryRetrievalResult(BaseModel):
"""记忆检索结果(持有已加载的 Memory ORM 实例列表)。"""
model_config = ConfigDict(arbitrary_types_allowed=True)
memories: list[Memory]
def _to_aware(dt: datetime | None) -> datetime | None:
"""确保 datetime 有时区信息,naive 视为 UTC。"""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
def build_memory_context(result: MemoryRetrievalResult, *, max_length: int = 3000) -> str | None:
"""把检索到的记忆组装成注入上下文的文本;无记忆时返回 None。
按**重要性降序**排列,每条附带重要性标签和相对时间。
超过 ``max_length`` 字符时截断,避免上下文窗口膨胀。
"""
if not result.memories:
return None
now = utc_now()
def _mem_sort_key(m: Memory) -> float:
"""排序键:重要性(越高越优先),同时辅助时间衰减。"""
ca = _to_aware(m.created_at)
hours_ago = (now - ca).total_seconds() / 3600 if ca else 0
decay = max(0.5, 1.0 - hours_ago / 720) # 30 天内缓慢衰减
return (m.importance or 0.5) * decay
sorted_mems = sorted(result.memories, key=_mem_sort_key, reverse=True)
lines: list[str] = []
total_chars = 0
for idx, item in enumerate(sorted_mems):
importance_label = _importance_label(item.importance)
ca = _to_aware(item.created_at)
hours_ago = (now - ca).total_seconds() / 3600 if ca else 0
time_label = _time_ago_str(hours_ago)
line = f"- 「{item.title}」{time_label} [{importance_label}]\n {item.content}"
line_len = len(line) + 1 # +1 for newline
if total_chars + line_len > max_length:
remaining = len(sorted_mems) - idx
if remaining > 0:
lines.append(f"…以及 {remaining} 条其他记忆(已截断)")
break
lines.append(line)
total_chars += line_len
return "相关记忆:\n" + "\n".join(lines)
def _importance_label(importance: float) -> str:
if importance >= 0.8:
return "★重要"
if importance >= 0.5:
return "●一般"
return "○参考"
def _time_ago_str(hours_ago: float) -> str:
if hours_ago < 1:
return "刚刚"
if hours_ago < 24:
return f"{int(hours_ago)}小时前"
days = int(hours_ago / 24)
if days < 30:
return f"{days}天前"
months = int(days / 30)
return f"{months}个月前"
@@ -1 +0,0 @@
"""小周天每轮反思领域纯逻辑(造消息 / 解析记忆候选与反思,禁止 IO)。"""
-22
View File
@@ -1,22 +0,0 @@
"""解析模型返回为小周天记忆候选 + 反思(纯函数,无 IO)。
模型输出对象 ``{"memories": [...], "reflection": str}``。记忆候选解析复用 extraction
的 ``parse_memory_drafts``;``reflection`` 为本轮复盘文本。
"""
from app.modules.extraction.extract import (
MemoryCandidateDraft,
extract_json_object,
parse_memory_drafts,
)
def parse_reflection(
raw: str,
) -> tuple[list[MemoryCandidateDraft], str]:
data = extract_json_object(raw)
if not isinstance(data, dict):
return [], ""
memories = parse_memory_drafts(data.get("memories"))
reflection = str(data.get("reflection", "")).strip()
return memories, reflection
-22
View File
@@ -1,22 +0,0 @@
"""小周天每轮记忆候选 + 反思 prompt 构建(纯函数,无 IO)。"""
def build_reflection_messages(
user_content: str,
assistant_content: str,
) -> tuple[str, str]:
system = (
"你是魂镜的小周天反思器。基于本轮对话(用户消息 + 你的回复),抽取值得长期记住的"
"记忆候选(用户偏好 / 事件 / 事实等),并生成一条简短的本轮反思"
"(做得好 / 不好 / 可改进)。"
"只抽取有长期价值、非琐碎的信息;反思是系统内部复盘,不超过两句。"
'以 JSON 对象返回:{"memories": [{"type": str'
"(user/preference/event/relationship/skill/goal/fact/other), "
'"title": str, "content": str, "importance": 0~1, '
'"sensitivity": "low|medium|high", "confidence": 0~1, "reason": str}], '
'"reflection": str}。'
"无值得记忆的内容时 memories 为空数组;reflection 仍需给出。"
"只返回 JSON 对象,不要解释或 markdown。"
)
user = f"用户消息:\n{user_content}\n\n你的回复:\n{assistant_content}"
return system, user
+2 -2
View File
@@ -4,7 +4,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.character import Character from app.models.character import Character
from app.models.memory import Memory from app.models.xirang import Memory
async def get_active_character(session: AsyncSession, owner_scope: str) -> Character | None: async def get_active_character(session: AsyncSession, owner_scope: str) -> Character | None:
@@ -98,7 +98,7 @@ async def count_memories(session: AsyncSession, character_id: str) -> int:
await session.execute( await session.execute(
select(func.count()) select(func.count())
.select_from(Memory) .select_from(Memory)
.where(Memory.character_id == character_id, Memory.deleted_at.is_(None)) .where(Memory.character_id == character_id, Memory.status != "tombstoned")
) )
).scalar_one() ).scalar_one()
return int(total or 0) return int(total or 0)
-200
View File
@@ -1,200 +0,0 @@
"""聊天数据访问。"""
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.models.chat import ChatMessage, ChatSession
async def get_session(
session: AsyncSession, session_id: str, *, owner_scope: str | None = None
) -> ChatSession | None:
stmt = select(ChatSession).where(ChatSession.id == session_id)
if owner_scope is not None:
stmt = stmt.where(ChatSession.owner_scope == owner_scope)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def list_recent_messages(
session: AsyncSession, session_id: str, *, limit: int = 12
) -> list[ChatMessage]:
result = await session.execute(
select(ChatMessage)
.where(ChatMessage.session_id == session_id, ChatMessage.deleted_at.is_(None))
.order_by(ChatMessage.created_at.desc())
.limit(limit)
)
return list(reversed(result.scalars().all()))
async def list_sessions(
session: AsyncSession,
character_id: str,
*,
status: str | None = None,
owner_scope: str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[ChatSession]:
conditions = [ChatSession.character_id == character_id, ChatSession.status != "deleted"]
if status is not None:
conditions.append(ChatSession.status == status)
if owner_scope is not None:
conditions.append(ChatSession.owner_scope == owner_scope)
result = await session.execute(
select(ChatSession)
.where(*conditions)
.order_by(ChatSession.updated_at.desc())
.offset(offset)
.limit(limit)
)
return list(result.scalars().all())
async def count_sessions(
session: AsyncSession,
character_id: str,
*,
status: str | None = None,
owner_scope: str | None = None,
) -> int:
conditions = [ChatSession.character_id == character_id, ChatSession.status != "deleted"]
if status is not None:
conditions.append(ChatSession.status == status)
if owner_scope is not None:
conditions.append(ChatSession.owner_scope == owner_scope)
result = await session.execute(select(func.count()).select_from(ChatSession).where(*conditions))
return result.scalar_one()
async def list_messages(
session: AsyncSession, session_id: str, *, limit: int = 100, offset: int = 0
) -> list[ChatMessage]:
result = await session.execute(
select(ChatMessage)
.where(ChatMessage.session_id == session_id, ChatMessage.deleted_at.is_(None))
.order_by(ChatMessage.created_at.asc())
.offset(offset)
.limit(limit)
)
return list(result.scalars().all())
async def count_messages(session: AsyncSession, session_id: str) -> int:
result = await session.execute(
select(func.count())
.select_from(ChatMessage)
.where(
ChatMessage.session_id == session_id,
ChatMessage.deleted_at.is_(None),
)
)
return result.scalar_one()
async def count_messages_by_sessions(
session: AsyncSession, session_ids: list[str]
) -> dict[str, int]:
"""批量统计多个会话的未删除消息数(GROUP BY 聚合,避免列表查询 N+1)。"""
if not session_ids:
return {}
result = await session.execute(
select(ChatMessage.session_id, func.count())
.where(
ChatMessage.session_id.in_(session_ids),
ChatMessage.deleted_at.is_(None),
)
.group_by(ChatMessage.session_id)
)
return {str(sid): int(cnt) for sid, cnt in result.all()}
async def last_message_previews_by_sessions(
session: AsyncSession, session_ids: list[str]
) -> dict[str, str]:
"""批量取每个会话最后一条未删除消息的内容(截断由调用方处理)。
用 row_number() 窗口函数取每个 session_id 的最新一条,兼容 SQLite(3.25+)
与 PostgreSQL,避免逐会话查询(N+1)。无消息的会话不在结果中。
"""
if not session_ids:
return {}
latest = (
select(
ChatMessage.session_id,
ChatMessage.content,
func.row_number()
.over(
partition_by=ChatMessage.session_id,
order_by=ChatMessage.created_at.desc(),
)
.label("rn"),
).where(
ChatMessage.session_id.in_(session_ids),
ChatMessage.deleted_at.is_(None),
)
).subquery()
result = await session.execute(
select(latest.c.session_id, latest.c.content).where(latest.c.rn == 1)
)
return {str(sid): (content or "") for sid, content in result.all()}
async def count_messages_by_role(session: AsyncSession, session_id: str, *, role: str) -> int:
"""统计会话内指定角色的未删除消息数(用于判断首轮对话)。"""
result = await session.execute(
select(func.count())
.select_from(ChatMessage)
.where(
ChatMessage.session_id == session_id,
ChatMessage.role == role,
ChatMessage.deleted_at.is_(None),
)
)
return result.scalar_one()
async def update_session_title(session: AsyncSession, session_id: str, title: str) -> None:
"""更新会话标题;仅修改托管对象,提交时机由调用方控制。"""
chat_session = await session.get(ChatSession, session_id)
if chat_session is not None:
chat_session.title = title
async def soft_delete_session_messages(session: AsyncSession, session_id: str) -> None:
"""软删除会话内全部未删除消息;提交时机由调用方控制。"""
from app.core.time import utc_now
await session.execute(
update(ChatMessage)
.where(
ChatMessage.session_id == session_id,
ChatMessage.deleted_at.is_(None),
)
.values(deleted_at=utc_now())
)
async def get_message(session: AsyncSession, message_id: str) -> ChatMessage | None:
"""按 ID 获取单条消息。"""
return await session.get(ChatMessage, message_id)
async def set_message_active_version(
session: AsyncSession,
message_id: str,
version_idx: int,
versions_json: str,
) -> None:
"""更新消息的活跃版本索引和 versions_json。"""
from sqlalchemy import update
result = await session.execute(
update(ChatMessage)
.where(ChatMessage.id == message_id)
.values(active_version_idx=version_idx, versions_json=versions_json)
)
if result.rowcount == 0:
raise AppError("not_found", "消息不存在。")
-73
View File
@@ -1,73 +0,0 @@
"""记忆数据访问。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.memory import Memory
async def get_memory(session: AsyncSession, memory_id: str) -> Memory | None:
return await session.get(Memory, memory_id)
async def list_memories(
session: AsyncSession,
character_id: str,
*,
memory_type: str | None = None,
source: str | None = None,
page: int,
page_size: int,
active_only: bool = True,
) -> tuple[list[Memory], int]:
conditions = [Memory.character_id == character_id]
if active_only:
conditions.append(Memory.status == "active")
conditions.append(Memory.deleted_at.is_(None))
if memory_type is not None:
conditions.append(Memory.type == memory_type)
if source is not None:
conditions.append(Memory.source == source)
total_result = await session.execute(
select(func.count()).select_from(Memory).where(*conditions)
)
total = int(total_result.scalar_one())
result = await session.execute(
select(Memory)
.where(*conditions)
.order_by(Memory.importance.desc(), Memory.updated_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total
async def list_injectable_memories(
session: AsyncSession, character_id: str, *, limit: int
) -> list[Memory]:
result = await session.execute(
select(Memory)
.where(
Memory.character_id == character_id,
Memory.status == "active",
Memory.deleted_at.is_(None),
)
.order_by(Memory.importance.desc(), Memory.updated_at.desc())
.limit(limit)
)
return list(result.scalars().all())
async def get_memories_by_ids(
session: AsyncSession, memory_ids: list[str]
) -> list[Memory]:
"""按 ID 列表批量获取记忆(按重要性降序排序,而非 ID 传入顺序)。"""
result = await session.execute(
select(Memory).where(
Memory.id.in_(memory_ids),
Memory.status == "active",
Memory.deleted_at.is_(None),
)
.order_by(Memory.importance.desc(), Memory.updated_at.desc())
)
return list(result.scalars().all())
@@ -1,31 +0,0 @@
"""灵核情绪状态数据访问(设计 §11.4)。"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.personality_emotion import CharacterEmotionState
async def get_state(session: AsyncSession, character_id: str) -> CharacterEmotionState | None:
"""读取某角色当前情绪状态(单行 per character)。"""
return await session.get(CharacterEmotionState, character_id)
async def upsert_state(
session: AsyncSession, state: CharacterEmotionState
) -> CharacterEmotionState:
"""写入情绪状态(存在则更新字段,不存在则插入)。
跨方言(SQLite + Postgres)ORM 层 upsert,不依赖 ON CONFLICT。
``updated_at`` 由 SQLAlchemy ``onupdate`` 自动刷新。
"""
existing = await session.get(CharacterEmotionState, state.character_id)
if existing is not None:
existing.mood = state.mood
existing.intensity = state.intensity
existing.baseline_mood = state.baseline_mood
existing.baseline_intensity = state.baseline_intensity
existing.last_trigger_event_id = state.last_trigger_event_id
return existing
session.add(state)
await session.flush()
return state
+1 -12
View File
@@ -14,17 +14,14 @@ async def list_runs(
session: AsyncSession, session: AsyncSession,
*, *,
character_id: str | None = None, character_id: str | None = None,
session_id: str | None = None,
status: str | None = None, status: str | None = None,
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
) -> tuple[list[Run], int]: ) -> tuple[list[Run], int]:
"""按创建时间倒序分页返回 Run;可选按人物/会话/状态过滤,返回 (条目, 总数)。""" """按创建时间倒序分页返回 Run;可选按人物/状态过滤,返回 (条目, 总数)。"""
conds = [] conds = []
if character_id is not None: if character_id is not None:
conds.append(Run.character_id == character_id) conds.append(Run.character_id == character_id)
if session_id is not None:
conds.append(Run.session_id == session_id)
if status is not None: if status is not None:
conds.append(Run.status == status) conds.append(Run.status == status)
count_stmt = select(func.count()).select_from(Run) count_stmt = select(func.count()).select_from(Run)
@@ -127,11 +124,3 @@ async def summarize_by_trace(session: AsyncSession):
return [(row[0], int(row[1]), row[2]) for row in result.all()] return [(row[0], int(row[1]), row[2]) for row in result.all()]
async def list_session_ids_by_runs(session: AsyncSession, run_ids: list[str]) -> list[str]:
"""按 run_id 集合查询关联的 chat session_id 去重列表(链路追踪:run → 对话)。"""
if not run_ids:
return []
result = await session.execute(
select(Run.session_id).where(Run.id.in_(run_ids), Run.session_id.is_not(None))
)
return sorted({row[0] for row in result.all() if row[0]})
@@ -1,49 +0,0 @@
"""时态纪念日数据访问(辰正 §5.3)。"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.time_anniversary import TimeAnniversary
async def create_anniversary(
session: AsyncSession, anniversary: TimeAnniversary
) -> TimeAnniversary:
session.add(anniversary)
await session.flush()
await session.refresh(anniversary)
return anniversary
async def get_anniversary(session: AsyncSession, anniversary_id: str) -> TimeAnniversary | None:
return await session.get(TimeAnniversary, anniversary_id)
async def list_anniversaries(session: AsyncSession, character_id: str) -> list[TimeAnniversary]:
result = await session.execute(
select(TimeAnniversary)
.where(TimeAnniversary.character_id == character_id)
.order_by(TimeAnniversary.date.asc())
)
return list(result.scalars().all())
async def update_anniversary(
session: AsyncSession, anniversary_id: str, **fields: object
) -> TimeAnniversary | None:
anniversary = await session.get(TimeAnniversary, anniversary_id)
if anniversary is None:
return None
for key, value in fields.items():
setattr(anniversary, key, value)
await session.flush()
return anniversary
async def delete_anniversary(session: AsyncSession, anniversary_id: str) -> bool:
anniversary = await session.get(TimeAnniversary, anniversary_id)
if anniversary is None:
return False
await session.delete(anniversary)
await session.flush()
return True
+16
View File
@@ -0,0 +1,16 @@
"""灵台插件化运行时(事件总线 / 插件 / Profile)。Kernel 见 ``app.kernel``。"""
from app.runtime.bus import RunContext, Veto, WaterfallBus
from app.runtime.plugin import Plugin, PluginRegistry
from app.runtime.profile import Profile, ProfileLayer, resolve_profile
__all__ = [
"Plugin",
"PluginRegistry",
"Profile",
"ProfileLayer",
"RunContext",
"Veto",
"WaterfallBus",
"resolve_profile",
]
+79
View File
@@ -0,0 +1,79 @@
"""运行时事件总线:瀑布节点 + 可否决监听器。
设计依据 docs/architecture/lingtai-runtime.md §1/§2:注入、审计、安全都在事件层做,
不改循环骨架。监听器按注册序执行;任一监听器抛 ``Veto`` 即中断该节点(否决),
否决事实由调用方记录(safety.ruled 等事件),总线本身不落库。
"""
from __future__ import annotations
import fnmatch
from collections import defaultdict
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
# 瀑布节点名(契约词典 §8.3 的可否决子集)
NODE_RUN_REQUESTED = "run.requested"
NODE_TURN_STARTED = "turn.started"
NODE_LLM_PRE_REQUEST = "llm.pre_request"
NODE_TOOL_PRE_EXECUTE = "tool.pre_execute"
NODE_RESULT_PRE_ACCEPT = "result.pre_accept"
WATERFALL_NODES = frozenset(
{
NODE_RUN_REQUESTED,
NODE_TURN_STARTED,
NODE_LLM_PRE_REQUEST,
NODE_TOOL_PRE_EXECUTE,
NODE_RESULT_PRE_ACCEPT,
}
)
class Veto(Exception):
"""监听器否决:携带裁决摘要(reason / 规则标识)。"""
def __init__(self, reason: str, *, rule: str = "", details: dict[str, Any] | None = None):
super().__init__(reason)
self.reason = reason
self.rule = rule
self.details = details or {}
@dataclass(frozen=True)
class RunContext:
"""节点监听器可见的运行上下文(只读视图,变更经事件日志)。"""
run_id: str
character_id: str
trace_id: str | None = None
payload: dict[str, Any] = field(default_factory=dict)
Listener = Callable[[str, RunContext], Awaitable[None]]
class WaterfallBus:
"""瀑布节点订阅与触发。"""
def __init__(self) -> None:
self._listeners: dict[str, list[Listener]] = defaultdict(list)
def on(self, node: str, listener: Listener) -> None:
"""注册节点监听器;支持 glob 通配(如 ``llm.*``)。"""
if not any(fnmatch.fnmatch(node, pattern) for pattern in WATERFALL_NODES):
raise ValueError(f"未知瀑布节点:{node}")
self._listeners[node].append(listener)
async def fire(self, node: str, context: RunContext) -> None:
"""按注册序触发监听器;任一否决即停止后续。
精确键与通配键(如 ``llm.*``)各自成列表,列表内保持注册序;
两种键之间的相对顺序不保证,需要严格顺序的场景用精确注册。
"""
for key, listeners in self._listeners.items():
if key != node and not fnmatch.fnmatch(node, key):
continue
for listener in list(listeners):
await listener(node, context)
+42
View File
@@ -0,0 +1,42 @@
"""插件契约与注册表。
一切能力插件化(docs/architecture/lingtai-runtime.md §4):插件只通过订阅瀑布节点
与提供能力参与运行,彼此不直接调用。M0 只需注册表与装配骨架,首个真实插件(司命
占位裁决器)随 M5 落地;禁域挂载点在 Kernel,不在插件。
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from app.runtime.bus import WaterfallBus
@runtime_checkable
class Plugin(Protocol):
"""插件契约:稳定标识 + 版本 + 注册入口。"""
@property
def name(self) -> str: ...
@property
def version(self) -> str: ...
def register(self, bus: WaterfallBus) -> None: ...
class PluginRegistry:
"""按注册序持有插件;同名插件后注册覆盖前注册(Profile 层叠语义)。"""
def __init__(self) -> None:
self._plugins: dict[str, Plugin] = {}
def register(self, plugin: Plugin) -> None:
self._plugins[plugin.name] = plugin
def bootstrap(self, bus: WaterfallBus) -> None:
for plugin in list(self._plugins.values()):
plugin.register(bus)
def names(self) -> list[str]:
return list(self._plugins)
+42
View File
@@ -0,0 +1,42 @@
"""Profile:一次 Run 的插件装配清单与参数包。
层叠顺序(产品 PRD §9.2 / lingtai-runtime.md §4.2):
平台硬边界 → 平台目录 → 用户偏好/BYOK → 人物覆写 → 镜树临时选择。
M0 只有平台目录层;后续里程碑逐层接入。Profile 快照写入事件日志,保证可回放。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class ProfileLayer:
"""层叠中的一层:来源标识 + 参数(后者覆盖前者同键)。"""
source: str
params: dict[str, Any] = field(default_factory=dict)
_PLATFORM_LAYER = ProfileLayer(source="platform", params={"slot": "chat"})
@dataclass(frozen=True)
class Profile:
"""解析后的最终装配:插件名列表 + 合并参数。"""
plugins: tuple[str, ...] = ()
params: dict[str, Any] = field(default_factory=dict)
def snapshot(self) -> dict[str, Any]:
"""写入 context.projected 事件的快照视图。"""
return {"plugins": list(self.plugins), "params": dict(self.params)}
def resolve_profile(layers: list[ProfileLayer] | None = None) -> Profile:
"""层叠解析:平台层恒在最底,逐层覆盖参数;M0 无插件装配。"""
merged: dict[str, Any] = {}
for layer in [_PLATFORM_LAYER, *(layers or [])]:
merged.update(layer.params)
return Profile(plugins=(), params=merged)
-254
View File
@@ -1,254 +0,0 @@
"""聊天 API schema。"""
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class CreateSessionRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"title": "大模型联调会话",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
}
]
}
)
title: str | None = Field(default=None, max_length=200, description="会话标题。")
character_id: str | None = Field(default=None, description="人物 ID;为空时使用当前激活人物。")
class UpdateSessionRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{"title": "新的会话标题"},
{"status": "archived"},
]
}
)
title: str | None = Field(default=None, max_length=200, description="新标题;为空时保持不变。")
status: str | None = Field(
default=None,
description="会话状态切换:active / archived。deleted 请走 DELETE 端点。",
)
class ChatSessionResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"title": "大模型联调会话",
"status": "active",
"created_at": "2026-06-19T09:06:47.515000Z",
"updated_at": "2026-06-19T09:10:52.690000Z",
}
]
}
)
session_id: str = Field(description="会话 ID,前缀为 session_。")
character_id: str = Field(description="会话所属人物 ID。")
title: str | None = Field(description="会话标题。")
status: str = Field(description="会话状态:active / archived / deleted。")
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
updated_at: str = Field(description="更新时间,UTC ISO 8601 字符串。")
message_count: int = Field(default=0, description="会话内未删除消息数(用户 + 助手)。")
last_message_preview: str | None = Field(default=None, description="最近一条消息的内容摘要。")
class ChatSessionListResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"items": [
{
"session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"title": "大模型联调会话",
"status": "active",
"created_at": "2026-06-19T09:06:47.515000Z",
"updated_at": "2026-06-19T09:10:52.690000Z",
}
],
"total": 1,
}
]
}
)
items: list[ChatSessionResponse] = Field(description="当前页会话列表。")
total: int = Field(description="符合条件的会话总数。")
class SendMessageRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"content": "你好,请简单介绍一下你自己。",
"modality": "text",
"model_hint": "deepseek-v4-flash",
},
{
"content": "请用三句话总结我们刚才聊到的内容。",
"modality": "text",
"model_hint": "default-chat",
},
]
}
)
content: str = Field(min_length=1, description="用户消息正文。")
modality: str = Field(default="text", description="消息模态;当前阶段仅支持 text。")
model_hint: str | None = Field(
default=None,
description="逻辑模型名或能力槽位,例如 default-chat、deepseek-v4-flash;为空时使用人物默认模型。",
)
thinking: bool = Field(
default=False, description="是否开启思考模式(reasoning);模型不支持时忽略。"
)
reasoning_effort: str | None = Field(
default=None,
description="思考力度:low / medium / high。thinking=false 时忽略,使用模型默认。",
)
prior_versions: list[dict] | None = Field(
default=None,
description="重答时携带的历史版本列表,每项含 content 和 created_at。后端将其合并到新消息的 versions_json 中(前置插入),"
"同时软删除前一条 assistant 消息,避免刷新后出现重复消息。",
)
class SendMessageResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"message_id": "message_462927da6f0e4af780dd28fd58f73314",
"assistant_message_id": "message_2f9b7bf7d3b84231b67b28bb39fbfa7a",
"run_id": "run_a2c8ecc73c0e4cc7bc2fafbaeada74e0",
"session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"status": "completed",
"stream_url": "/api/v1/runs/run_a2c8ecc73c0e4cc7bc2fafbaeada74e0/stream",
}
]
}
)
message_id: str = Field(description="本次写入的用户消息 ID。")
assistant_message_id: str | None = Field(description="模型回复消息 ID;失败时可能为空。")
run_id: str = Field(description="本次对话运行 ID,可用于查询 Run 或 SSE。")
session_id: str = Field(description="会话 ID。")
character_id: str = Field(description="人物 ID。")
status: str = Field(description="运行状态。成功完成时为 completed。")
stream_url: str = Field(description="SSE 事件流 URL(前置于此,前端可直接 new EventSource)。")
class ChatMessageResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"message_id": "message_2f9b7bf7d3b84231b67b28bb39fbfa7a",
"session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"role": "assistant",
"content": "你好,我是魂镜中的默认人物,会结合记忆和当前会话与你对话。",
"modality": "text",
"run_id": "run_a2c8ecc73c0e4cc7bc2fafbaeada74e0",
"metadata": {"model_hint": "deepseek-v4-flash", "provider": "deepseek"},
"sensitivity": "low",
"created_at": "2026-06-19T09:10:52.676462Z",
"deleted_at": None,
}
]
}
)
message_id: str = Field(description="消息 ID,前缀为 message_。")
session_id: str = Field(description="所属会话 ID。")
character_id: str = Field(description="所属人物 ID。")
role: str = Field(description="消息角色:user / assistant / system / tool。")
content: str = Field(description="消息正文。")
modality: str = Field(description="消息模态:text / image / audio / file。")
run_id: str | None = Field(description="关联运行 ID。")
metadata: dict[str, Any] = Field(description="消息元数据,例如模型名、provider 等。")
sensitivity: str = Field(description="敏感等级:none / low / medium / high。")
versions: list[dict] = Field(
default=[],
description="版本历史列表,每项含 content 和 created_at。",
)
active_version_idx: int = Field(
default=0,
description="当前活跃版本在 versions 中的索引。",
)
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
deleted_at: str | None = Field(description="删除时间;未删除时为空。")
class ChatMessageListResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"items": [
{
"message_id": "message_462927da6f0e4af780dd28fd58f73314",
"session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"role": "user",
"content": "你好,请简单介绍一下你自己。",
"modality": "text",
"run_id": "run_a2c8ecc73c0e4cc7bc2fafbaeada74e0",
"metadata": {},
"sensitivity": "low",
"created_at": "2026-06-19T09:10:52.660153Z",
"deleted_at": None,
},
{
"message_id": "message_2f9b7bf7d3b84231b67b28bb39fbfa7a",
"session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"role": "assistant",
"content": "你好,我是魂镜中的默认人物,会结合记忆和当前会话与你对话。",
"modality": "text",
"run_id": "run_a2c8ecc73c0e4cc7bc2fafbaeada74e0",
"metadata": {"model_hint": "deepseek-v4-flash", "provider": "deepseek"},
"sensitivity": "low",
"created_at": "2026-06-19T09:10:52.676462Z",
"deleted_at": None,
},
],
"total": 2,
}
]
}
)
items: list[ChatMessageResponse] = Field(description="按创建时间升序排列的消息列表。")
total: int = Field(description="符合条件的消息总数。")
class SelectVersionRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{"version_idx": 0},
{"version_idx": 2},
]
}
)
version_idx: int = Field(
ge=0,
description="要激活的版本在消息 versions 数组中的索引(0-based)。",
)
-35
View File
@@ -1,35 +0,0 @@
"""演化候选 API schema(契约字典 §12;api-spec 演化候选审核)。"""
from datetime import datetime
from typing import Any
from pydantic import Field
from app.schemas.common import OrmModel
class EvolutionCandidateResponse(OrmModel):
"""演化候选响应(delta/evidence_ids 已从 JSON 文本解析)。"""
id: str = Field(description="演化候选 ID(evolution_ 前缀)")
character_id: str
cycle_id: str = Field(description="触发本次的大周天循环 ID")
type: str = Field(description="候选类型,取值见契约字典 §12.1")
source: str = Field(description="来源,本期 big_cycle")
level: str = Field(description="影响层级 L0-L4")
scope: str = Field(description="作用域,取值见契约字典 §12.3")
title: str
proposal: str
delta: dict[str, Any] = Field(default_factory=dict, description="演化增量")
evidence_ids: list[str] = Field(default_factory=list)
confidence: float
benefit_score: float
risk_score: float
drift_score: float
auto_apply_allowed: bool
status: str
resulting_target_id: str | None = None
error_message: str | None = None
reviewed_at: datetime | None = None
reviewed_by: str
created_at: datetime
-109
View File
@@ -1,109 +0,0 @@
"""记忆 API schema。"""
from pydantic import BaseModel, ConfigDict, Field
class MemoryCreateRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"type": "user",
"title": "回答偏好",
"content": "用户喜欢简洁、直接、带验证结果的中文回答。",
"source": "user_explicit",
"confidence": 1.0,
"importance": 0.8,
"sensitivity": "low",
"evidence_ids": ["message_462927da6f0e4af780dd28fd58f73314"],
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
}
]
}
)
type: str = Field(
default="user", max_length=60, description="记忆类型,取值见契约字典 memory.type。"
)
title: str = Field(max_length=200, description="记忆标题。")
content: str = Field(min_length=1, description="记忆正文。")
source: str = Field(
default="user_explicit", description="记忆来源,取值见契约字典 memory.source。"
)
confidence: float = Field(default=1.0, ge=0.0, le=1.0, description="置信度,范围 0.0–1.0。")
importance: float = Field(default=0.5, ge=0.0, le=1.0, description="重要性,范围 0.0–1.0。")
sensitivity: str = Field(default="low", description="敏感等级:none / low / medium / high。")
evidence_ids: list[str] = Field(default_factory=list, description="证据 ID 列表。")
character_id: str | None = Field(default=None, description="人物 ID;为空时使用当前激活人物。")
class MemoryUpdateRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"title": "回答偏好(已确认)",
"importance": 0.9,
"status": "active",
"evidence_ids": ["message_462927da6f0e4af780dd28fd58f73314"],
}
]
}
)
type: str | None = Field(default=None, max_length=60, description="记忆类型。")
title: str | None = Field(default=None, max_length=200, description="记忆标题。")
content: str | None = Field(default=None, description="记忆正文。")
confidence: float | None = Field(
default=None, ge=0.0, le=1.0, description="置信度,范围 0.0–1.0。"
)
importance: float | None = Field(
default=None, ge=0.0, le=1.0, description="重要性,范围 0.0–1.0。"
)
sensitivity: str | None = Field(default=None, description="敏感等级。")
status: str | None = Field(
default=None, description="记忆状态:active / archived / deleted / superseded。"
)
evidence_ids: list[str] | None = Field(default=None, description="证据 ID 列表。")
class MemoryResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"id": "memory_4d9c6e3b18bb4ef5a6b8ed2e902d1f1a",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"type": "user",
"title": "回答偏好",
"content": "用户喜欢简洁、直接、带验证结果的中文回答。",
"source": "user_explicit",
"confidence": 1.0,
"importance": 0.8,
"sensitivity": "low",
"evidence_ids": ["message_462927da6f0e4af780dd28fd58f73314"],
"status": "active",
"created_at": "2026-06-19T09:12:00.000000Z",
"updated_at": "2026-06-19T09:12:00.000000Z",
"deleted_at": None,
"expires_at": None,
}
]
}
)
id: str = Field(description="记忆 ID,前缀为 memory_。")
character_id: str = Field(description="所属人物 ID。")
type: str = Field(description="记忆类型。")
title: str = Field(description="记忆标题。")
content: str = Field(description="记忆正文。")
source: str = Field(description="记忆来源。")
confidence: float = Field(description="置信度。")
importance: float = Field(description="重要性。")
sensitivity: str = Field(description="敏感等级。")
evidence_ids: list[str] = Field(description="证据 ID 列表。")
status: str = Field(description="记忆状态。")
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
updated_at: str = Field(description="更新时间,UTC ISO 8601 字符串。")
deleted_at: str | None = Field(description="删除时间;未删除时为空。")
expires_at: str | None = Field(description="过期时间;永不过期时为空。")
+7 -2
View File
@@ -66,11 +66,16 @@ class RunResponse(BaseModel):
) )
id: str = Field(description="运行 ID,前缀为 run_。") id: str = Field(description="运行 ID,前缀为 run_。")
character_id: str = Field(description="所属人物 ID。") character_id: str | None = Field(
session_id: str | None = Field(description="关联会话 ID。") default=None, description="所属人物 ID;照心试演(kind=rehearsal)无正式人物。"
)
status: str = Field( status: str = Field(
description="运行状态:queued / running / completed / failed / cancelled / interrupted。" description="运行状态:queued / running / completed / failed / cancelled / interrupted。"
) )
kind: str = Field(
default="foreground",
description="运行种类:foreground / background_task / awakening / rehearsal(契约词典 §8.2)。",
)
goal: str | None = Field(description="运行目标。") goal: str | None = Field(description="运行目标。")
origin: str = Field(description="运行来源,例如 chat。") origin: str = Field(description="运行来源,例如 chat。")
model_hint: str | None = Field(description="本次运行使用的逻辑模型名或能力槽位。") model_hint: str | None = Field(description="本次运行使用的逻辑模型名或能力槽位。")
-84
View File
@@ -1,84 +0,0 @@
"""辰正时态 API schema(设计 §10)。对外字段不带 _json 后缀。"""
from pydantic import BaseModel, ConfigDict, Field
from app.modules.time_sense.modulation import ModulationFactors
from app.modules.time_sense.snapshot import TemporalSnapshot
class SnapshotResponse(BaseModel):
"""GET /time/snapshot 响应:快照 + 因子 + context_text。"""
model_config = ConfigDict(from_attributes=True)
snapshot: TemporalSnapshot | None
factors: ModulationFactors | None
context_text: str
class AnniversaryCreateRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"name": "我们的相遇日",
"calendar": "solar",
"date": "2025-09-19",
"recurs_yearly": True,
"kind": "anniversary",
"subject_type": "relationship",
"source": "manual",
}
]
}
)
name: str = Field(max_length=200, description="纪念日名称。")
calendar: str = Field(description="历法:solar / lunar。")
date: str = Field(description="日期:公历 ISO 或农历编码。")
recurs_yearly: bool = Field(default=True, description="是否每年 recur。")
kind: str = Field(description="类型,取值见契约 §11.4.2。")
subject_type: str = Field(description="对象类型,取值见契约 §11.4.3。")
source: str = Field(default="manual", description="来源,取值见契约 §11.4.4。")
class AnniversaryUpdateRequest(BaseModel):
name: str | None = Field(default=None, max_length=200)
calendar: str | None = None
date: str | None = None
recurs_yearly: bool | None = None
kind: str | None = None
subject_type: str | None = None
source: str | None = None
class AnniversaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
calendar: str
date: str
recurs_yearly: bool
kind: str
subject_type: str
source: str
class AcquaintanceResponse(BaseModel):
total_days: int
natural_language: str
class TimeConfigResponse(BaseModel):
"""GET /time/config:当前时态相关配置(只读,v1 经 env 覆盖)。"""
user_timezone: str
home_timezone: str
default_city: str
offline_active_minutes: float
offline_short_absence_minutes: float
offline_long_absence_minutes: float
offline_reunion_impact_minutes: float
care_enabled: bool
jetlag_threshold_minutes: int
+4 -15
View File
@@ -21,7 +21,7 @@ from app.models.governance import (
WorldEntity, WorldEntity,
) )
from app.models.idempotency import IdempotencyKey from app.models.idempotency import IdempotencyKey
from app.models.memory import Memory from app.models.xirang import Memory
from app.models.runtime import Event, Run from app.models.runtime import Event, Run
from app.models.safety import AuditEvent from app.models.safety import AuditEvent
from app.modules.safety.confirmation import ConfirmationStore, PendingConfirmation from app.modules.safety.confirmation import ConfirmationStore, PendingConfirmation
@@ -77,7 +77,7 @@ async def get_pivots_health(
plans_n = await governance_repo.count(session, Plan) plans_n = await governance_repo.count(session, Plan)
perception_n = await governance_repo.count(session, PerceptionChannel) perception_n = await governance_repo.count(session, PerceptionChannel)
agents_n = await governance_repo.count(session, AgentRosterEntry) agents_n = await governance_repo.count(session, AgentRosterEntry)
memories_n = await governance_repo.count(session, Memory) memories_n = await governance_repo.count(session, Memory, Memory.status != "tombstoned")
character = await character_repo.get_active_character(session, owner_scope) character = await character_repo.get_active_character(session, owner_scope)
has_personality = bool(character and character.current_personality_version_id) has_personality = bool(character and character.current_personality_version_id)
tool_enabled = settings.enable_tool_runtime and tool_runtime is not None tool_enabled = settings.enable_tool_runtime and tool_runtime is not None
@@ -262,14 +262,11 @@ async def get_trace(trace_id: str) -> TraceDetail:
*[audit.character_id for audit in audits if audit.character_id], *[audit.character_id for audit in audits if audit.character_id],
} }
) )
session_ids = (
sorted(await runtime_repo.list_session_ids_by_runs(session, run_ids)) if run_ids else []
)
return TraceDetail( return TraceDetail(
trace_id=trace_id, trace_id=trace_id,
character_ids=character_ids, character_ids=character_ids,
run_ids=run_ids, run_ids=run_ids,
session_ids=session_ids, session_ids=[],
items=items, items=items,
) )
@@ -342,15 +339,7 @@ async def list_traces(*, limit: int = 50) -> TraceListResponse:
).all(): ).all():
_entry(tid)["character_ids"].add(cid) _entry(tid)["character_ids"].add(cid)
# 对话归属(events.run_id → runs.session_id) # 对话归属随线性会话模型退役;镜树归属随 M2c 司命台重设计接入(tree_id 聚合)
for tid, sid in (
await session.execute(
select(Event.trace_id, Run.session_id)
.join(Run, Event.run_id == Run.id)
.where(Event.trace_id.is_not(None), Run.session_id.is_not(None))
)
).all():
_entry(tid)["session_ids"].add(sid)
total = len(merged) total = len(merged)
ordered = sorted(merged.values(), key=lambda e: e["last_seen"], reverse=True) ordered = sorted(merged.values(), key=lambda e: e["last_seen"], reverse=True)
+54 -96
View File
@@ -1,117 +1,75 @@
"""角色卡导入 / 导出服务。""" """角色卡导入 / 导出。
rewrite/1.0 M2b:导入不再直接建人物——ST V1/V2/V3 卡解析后产出**可审阅灵胚**
(产品 PRD §5.2「导入默认生成可审阅灵胚」);``character_book`` 暂存于灵胚道体草稿,
启镜时物化为新人物的世谱书与条目(见 jingsheng.service.activate_embryo)。
导出方向保持:人物 → ST V3 卡静态快照。
"""
from __future__ import annotations from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError from app.core.errors import AppError
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.models.character import Character from app.models.character import Character
from app.models.lorebook import Lorebook, LorebookEntry
from app.modules.imports.card_parser import parse_character_card from app.modules.imports.card_parser import parse_character_card
from app.modules.imports.export_mapping import character_to_card from app.modules.imports.export_mapping import character_to_card
from app.modules.imports.mapping import (
card_to_daoti,
card_to_profile,
character_book_to_entries,
)
from app.repositories import lorebook as lorebook_repo
from app.repositories.json_utils import dumps
from app.schemas.character import ImportCardResponse
async def import_character_card( def card_to_embryo_payload(raw: bytes) -> dict:
session: AsyncSession, raw: bytes, owner_scope: str """解析角色卡字节 → 灵胚创建载荷(name/description/identity_baseline)。
) -> Character:
"""解析角色卡字节 → 建 Character + 世谱(character_book)。返回 Character ORM。
``personality`` 写入道体 ``personality_summary`` 作初始值; identity_baseline 保存卡的全部有效字段与原始 character_book,
``character_book`` 转为该角色的世谱书 + 条目。 启镜前可经照心编辑(PATCH /embryo-drafts/{id})审阅修改。
""" """
card = parse_character_card(raw) card = parse_character_card(raw)
daoti = card_to_daoti(card) description = "\n\n".join(
part
character = Character( for part in (
name=card.name or "未命名角色", card.description.strip(),
display_name=card.nickname or card.name or None, f"【性格】{card.personality.strip()}" if card.personality.strip() else "",
language="zh-CN", f"【场景】{card.scenario.strip()}" if card.scenario.strip() else "",
profile_json=dumps(card_to_profile(card)), )
model_policy_json="{}", if part
personality_summary=daoti["personality_summary"],
privacy_level="normal",
owner_scope=owner_scope,
) )
session.add(character) baseline = {
await session.flush() "import_card": {
"spec_version": card.spec_version,
entries_data = character_book_to_entries(card.character_book) "description": card.description,
if entries_data: "personality": card.personality,
book = card.character_book or {} "scenario": card.scenario,
lorebook = Lorebook( "first_mes": card.first_mes,
character_id=character.id, "mes_example": card.mes_example,
name=book.get("name", "") or card.name, "system_prompt": card.system_prompt,
description=book.get("description", ""), "post_history_instructions": card.post_history_instructions,
scan_depth=book.get("scan_depth"), "alternate_greetings": card.alternate_greetings,
token_budget=book.get("token_budget"), "tags": card.tags,
recursive_scanning=bool(book.get("recursive_scanning", False)), "creator": card.creator,
scope="character", "character_version": card.character_version,
owner_scope=owner_scope, "character_book": card.character_book,
) }
session.add(lorebook) }
await session.flush() name = card.name.strip() or "未命名灵胚"
for entry in entries_data: return {
session.add( "name": name[:120],
LorebookEntry( "description": description or None,
lorebook_id=lorebook.id, "identity_baseline": baseline,
keys_json=dumps(entry["keys"]), "book_entry_count": _count_book_entries(card.character_book),
content=entry["content"], }
secondary_keys_json=dumps(entry["secondary_keys"]),
constant=entry["constant"],
selective=entry["selective"],
enabled=entry["enabled"],
case_sensitive=entry["case_sensitive"],
use_regex=entry["use_regex"],
position=entry["position"],
insertion_order=entry["insertion_order"],
priority=entry["priority"],
comment=entry["comment"],
)
)
await session.commit()
await session.refresh(character)
return character
async def import_character_card_response( def _count_book_entries(book: dict | None) -> int:
raw: bytes, owner_scope: str if not book:
) -> ImportCardResponse: return 0
"""导入角色卡 → ImportCardResponse(供 API 调用,自管 session)。""" entries = book.get("entries")
async with AsyncSessionLocal() as session: if isinstance(entries, (dict, list)):
character = await import_character_card(session, raw, owner_scope) return len(entries)
books = await lorebook_repo.list_books(session, character.id, owner_scope) return 0
book_id = books[0].id if books else None
entries = (
await lorebook_repo.list_book_entries(session, book_id) if book_id else []
)
return ImportCardResponse(
character_id=character.id,
name=character.name,
display_name=character.display_name,
lorebook_id=book_id,
entry_count=len(entries),
)
async def export_character_card(character_id: str, owner_scope: str) -> dict: async def export_character_card(character_id: str, owner_scope: str) -> dict:
"""导出角色为 ST V3 卡静态快照(丢弃演化维度)。""" """导出人物为 ST V3 卡静态快照(丢弃演化维度)。"""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id) character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope: if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。") raise AppError("not_found", "人物不存在。")
books = await lorebook_repo.list_books(session, character_id, owner_scope) return character_to_card(character)
book = books[0] if books else None
entries = (
await lorebook_repo.list_book_entries(session, book.id) if book else []
)
return character_to_card(character, book, entries)
+5 -41
View File
@@ -10,7 +10,6 @@ from app.core.time import utc_now
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.models.character import Character from app.models.character import Character
from app.models.personality import PersonalityVersion from app.models.personality import PersonalityVersion
from app.models.time_anniversary import TimeAnniversary
from app.modules.character import ( from app.modules.character import (
MODEL_POLICY_CREATE_DEFAULTS, MODEL_POLICY_CREATE_DEFAULTS,
SYSTEM_TEMPLATE_OWNER_SCOPE, SYSTEM_TEMPLATE_OWNER_SCOPE,
@@ -19,7 +18,6 @@ from app.modules.character import (
) )
from app.repositories import characters as character_repo from app.repositories import characters as character_repo
from app.repositories import personality as personality_repo from app.repositories import personality as personality_repo
from app.repositories import time_anniversaries as anniversary_repo
from app.repositories.json_utils import dumps, loads_dict from app.repositories.json_utils import dumps, loads_dict
from app.schemas.character import ( from app.schemas.character import (
ActivateCharacterResponse, ActivateCharacterResponse,
@@ -232,7 +230,7 @@ async def create_character_from_template(
session: AsyncSession, template: Character, owner_scope: str session: AsyncSession, template: Character, owner_scope: str
) -> Character: ) -> Character:
# 模板的 birthday 仅在兜底(空/缺失)时不继承;显式设置的有效生日原样保留, # 模板的 birthday 仅在兜底(空/缺失)时不继承;显式设置的有效生日原样保留,
# 由 _ensure_default_birthday 写入子角色的 profile 与 time_anniversaries 表。 # 由 _ensure_default_birthday 写入子角色的 profile。
profile = loads_dict(template.profile_json) profile = loads_dict(template.profile_json)
birthday = profile.get("birthday") birthday = profile.get("birthday")
if not (isinstance(birthday, str) and birthday.strip()): if not (isinstance(birthday, str) and birthday.strip()):
@@ -388,51 +386,17 @@ async def _audit_character(session: AsyncSession, character: Character, action:
async def _ensure_default_birthday(session: AsyncSession, character: Character) -> None: async def _ensure_default_birthday(session: AsyncSession, character: Character) -> None:
"""birthday 缺失时用 created_at 兜底:写 profile.birthday 并建一条生日纪念日。 """birthday 缺失时用 created_at 兜底:仅写 profile.birthday。
- 仅在 profile.birthday 为空/缺失时触发默认兜底;用户或模板已设则不覆盖。 纪念日表(time_anniversaries)随旧时间模型退役删除;辰正(M8 重实现)落地后
- 无论 birthday 是否已设,只要角色尚无 birthday 纪念日,就建一条。 生日提醒改由时态感知按 profile.birthday 计算,不再预写行。
- profile.birthday 与 TimeAnniversary.date 取 birthday 值(显式)或 created_at 的 ISO 日期。
- 须在 character flush 后调用(依赖 id 与 created_at);同一事务内随外层 commit 落库。
""" """
profile = loads_dict(character.profile_json) profile = loads_dict(character.profile_json)
birthday = profile.get("birthday") birthday = profile.get("birthday")
if isinstance(birthday, str) and birthday.strip(): if isinstance(birthday, str) and birthday.strip():
# birthday 已设但可能无对应纪念日(如从模板继承),仅 ISO 格式生日补建
existing = await anniversary_repo.list_anniversaries(session, character.id)
if not any(a.kind == "birthday" for a in existing):
try:
datetime.strptime(birthday, "%Y-%m-%d")
except (ValueError, TypeError):
pass
else:
anniversary = TimeAnniversary(
character_id=character.id,
name=f"{character.display_name or character.name}·生辰",
calendar="solar",
date=birthday,
recurs_yearly=True,
kind="birthday",
subject_type="character_self",
source="auto",
)
await anniversary_repo.create_anniversary(session, anniversary)
return return
birthday_iso = character.created_at.strftime("%Y-%m-%d") profile["birthday"] = character.created_at.strftime("%Y-%m-%d")
profile["birthday"] = birthday_iso
character.profile_json = dumps(profile) character.profile_json = dumps(profile)
anniversary = TimeAnniversary(
character_id=character.id,
name=f"{character.display_name or character.name}·生辰",
calendar="solar",
date=birthday_iso,
recurs_yearly=True,
kind="birthday",
subject_type="character_self",
source="auto",
)
await anniversary_repo.create_anniversary(session, anniversary)
async def create_character( async def create_character(
session: AsyncSession, session: AsyncSession,
-252
View File
@@ -1,252 +0,0 @@
"""聊天服务。"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.modules.safety.confirmation import ConfirmationStore
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.chat import ChatMessage, ChatSession
from app.repositories import chat as chat_repo
from app.repositories.json_utils import dumps, loads_list
from app.schemas.chat import (
ChatMessageListResponse,
ChatMessageResponse,
ChatSessionListResponse,
ChatSessionResponse,
CreateSessionRequest,
SelectVersionRequest,
SendMessageRequest,
SendMessageResponse,
UpdateSessionRequest,
)
from app.services.characters import get_active_character_model
from app.services.serializers import chat_message_to_response, chat_session_to_response
if TYPE_CHECKING:
from app.application.agent.confirmation import ConfirmationRegistry
from app.application.agent.python_skills import PythonSkillCatalog
from app.application.agent.run_registry import AgentRunRegistry
from app.application.tool_runtime.runtime import ToolRuntime
from app.modules.runtime.events import EventStore
async def create_session(
payload: CreateSessionRequest, owner_scope: str = settings.default_owner_scope
) -> ChatSessionResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
character_id = payload.character_id or character.id
if character_id != character.id:
raise AppError("not_found", "人物不存在。")
chat_session = ChatSession(
character_id=character_id,
title=payload.title,
status="active",
owner_scope=owner_scope,
)
session.add(chat_session)
await session.commit()
await session.refresh(chat_session)
return chat_session_to_response(chat_session)
_PREVIEW_LEN = 80
def _truncate_preview(content: str | None) -> str | None:
"""截断消息内容为列表预览长度;空内容返回 None。"""
if not content:
return None
text = content.strip()
if len(text) <= _PREVIEW_LEN:
return text
return text[:_PREVIEW_LEN].rstrip() + "…"
async def list_session_responses(
*,
owner_scope: str = settings.default_owner_scope,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> ChatSessionListResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
sessions = await chat_repo.list_sessions(
session, character.id, status=status, owner_scope=owner_scope, limit=limit, offset=offset
)
total = await chat_repo.count_sessions(
session, character.id, status=status, owner_scope=owner_scope
)
session_ids = [s.id for s in sessions]
counts = await chat_repo.count_messages_by_sessions(session, session_ids)
previews = await chat_repo.last_message_previews_by_sessions(session, session_ids)
return ChatSessionListResponse(
items=[
chat_session_to_response(
s,
message_count=counts.get(s.id, 0),
last_message_preview=_truncate_preview(previews.get(s.id)),
)
for s in sessions
],
total=total,
)
async def get_session_response(
session_id: str, owner_scope: str = settings.default_owner_scope
) -> ChatSessionResponse:
async with AsyncSessionLocal() as session:
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
message_count = await chat_repo.count_messages(session, chat_session.id)
recent = await chat_repo.list_recent_messages(session, chat_session.id, limit=1)
preview = _truncate_preview(recent[0].content if recent else None)
return chat_session_to_response(
chat_session,
message_count=message_count,
last_message_preview=preview,
)
_ALLOWED_SESSION_STATUSES = {"active", "archived"}
async def update_session_response(
session_id: str, payload: UpdateSessionRequest, owner_scope: str = settings.default_owner_scope
) -> ChatSessionResponse:
"""更新会话标题或状态(active/archived);归属校验同 get。
deleted 态由 DELETE 端点承担,此处禁止直接置为 deleted。
"""
async with AsyncSessionLocal() as session:
# owner_scope 校验已由 get_session 内化,无需重复查 character
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
if payload.title is not None:
chat_session.title = payload.title
if payload.status is not None:
if payload.status not in _ALLOWED_SESSION_STATUSES:
raise AppError(
"invalid_request",
"会话状态仅可切换为 active 或 archived;删除请使用 DELETE。",
)
chat_session.status = payload.status
await session.commit()
await session.refresh(chat_session)
message_count = await chat_repo.count_messages(session, chat_session.id)
return chat_session_to_response(chat_session, message_count=message_count)
async def delete_session(session_id: str, owner_scope: str = settings.default_owner_scope) -> None:
"""软删除会话:session.status=deleted 并软删其全部消息。
Run/Event 为审计/日志型数据,保留不删;消息通过 deleted_at 软删,
与现有查询过滤保持一致。
"""
async with AsyncSessionLocal() as session:
# owner_scope 校验已由 get_session 内化,无需重复查 character
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
chat_session.status = "deleted"
await chat_repo.soft_delete_session_messages(session, session_id)
await session.commit()
async def list_message_responses(
session_id: str,
*,
owner_scope: str = settings.default_owner_scope,
limit: int = 100,
offset: int = 0,
) -> ChatMessageListResponse:
async with AsyncSessionLocal() as session:
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
messages = await chat_repo.list_messages(session, session_id, limit=limit, offset=offset)
total = await chat_repo.count_messages(session, session_id)
return ChatMessageListResponse(
items=[chat_message_to_response(message) for message in messages],
total=total,
)
async def recent_chat_messages(
session: AsyncSession, session_id: str, *, limit: int = 12
) -> list[ChatMessage]:
"""加载会话最近消息(供上下文组装)——接收外部 session,供 send_message usecase 复用。"""
return await chat_repo.list_recent_messages(session, session_id, limit=limit)
async def send_message(
session: AsyncSession,
session_id: str,
payload: SendMessageRequest,
*,
owner_scope: str = settings.default_owner_scope,
tool_runtime: "ToolRuntime | None" = None,
confirmation_registry: "ConfirmationRegistry | None" = None,
confirmation_store: "ConfirmationStore | None" = None,
event_store: "EventStore | None" = None,
agent_run_registry: "AgentRunRegistry | None" = None,
python_skill_catalog: "PythonSkillCatalog | None" = None,
) -> SendMessageResponse:
"""发送消息——接收外部 session(由 run_idempotent 注入,保证幂等记录与发送同事务)。"""
from app.application.chat.send_message import SendChatMessageUseCase
return await SendChatMessageUseCase(
session,
owner_scope=owner_scope,
tool_runtime=tool_runtime,
confirmation_registry=confirmation_registry,
confirmation_store=confirmation_store,
event_store=event_store,
agent_run_registry=agent_run_registry,
python_skill_catalog=python_skill_catalog,
).execute(session_id, payload)
async def select_message_version(
session_id: str,
message_id: str,
payload: SelectVersionRequest,
*,
owner_scope: str = settings.default_owner_scope,
) -> ChatMessageResponse:
"""选择/激活消息的某个历史版本。"""
async with AsyncSessionLocal() as session:
# owner_scope 校验已由 get_session 内化
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
message = await chat_repo.get_message(session, message_id)
if message is None or message.session_id != session_id:
raise AppError("not_found", "消息不存在。")
versions = loads_list(message.versions_json)
if payload.version_idx < 0 or payload.version_idx >= len(versions):
raise AppError(
"invalid_request",
f"版本索引 {payload.version_idx} 超出范围 (0-{len(versions) - 1})。",
)
await chat_repo.set_message_active_version(
session,
message_id,
payload.version_idx,
dumps(versions),
)
await session.commit()
await session.refresh(message)
return chat_message_to_response(message)
Loaded 100 of 353 files, more files were not shown because too many files have changed in this diff. Show more