Compare commits
23
Commits
main
..
79db56a87b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79db56a87b | ||
|
|
45d7c19cf1 | ||
|
|
7279904043 | ||
|
|
df5caa69e7 | ||
|
|
e914aec9c4 | ||
|
|
eb0027757d | ||
|
|
9c98c2f85e | ||
|
|
2a640a901b | ||
|
|
30447fc40f | ||
|
|
118445b208 | ||
|
|
ad9c9e942a | ||
|
|
5f36e3785c | ||
|
|
e6a3b7121f | ||
|
|
1cd0069d65 | ||
|
|
c090893e63 | ||
|
|
8ebcc11a8a | ||
|
|
8d8d81e757 | ||
|
|
f3058a64c3 | ||
|
|
7ef3a15061 | ||
|
|
99e55fa0a8 | ||
|
|
e66e50bee4 | ||
|
|
0ca9396f4d | ||
|
|
6a65f3b582 |
No files matched your search
@@ -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
|
||||||
@@ -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,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)"
|
|
||||||
@@ -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)。
|
||||||
|
|
||||||
## 文档入口
|
## 文档入口
|
||||||
|
|
||||||
|
|||||||
@@ -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/
|
||||||
@@ -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` 加载)
|
|
||||||
@@ -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
@@ -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)
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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,超时按拒绝处理;
|
||||||
|
|||||||
@@ -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_")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""六域领域层(rewrite/1.0)。镜生域先行,其余域随里程碑落地。"""
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)} 字符",
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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))
|
|
||||||
@@ -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)
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
||||||
@@ -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)
|
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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.
@@ -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"}
|
|
||||||
]
|
|
||||||
@@ -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)。"""
|
|
||||||
@@ -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))
|
|
||||||
@@ -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)
|
|
||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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()
|
|
||||||
@@ -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}
|
|
||||||
@@ -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 = (
|
|
||||||
"你对用户保持着温和的关注,"
|
|
||||||
"平日里话不多,但每次开口都带着真诚。"
|
|
||||||
"你善于观察,能从细微之处感知对方的情绪。"
|
|
||||||
)
|
|
||||||
@@ -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.
@@ -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)。"""
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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", "消息不存在。")
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)。",
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
@@ -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="过期时间;永不过期时为空。")
|
|
||||||
@@ -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="本次运行使用的逻辑模型名或能力槽位。")
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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="{}",
|
|
||||||
personality_summary=daoti["personality_summary"],
|
|
||||||
privacy_level="normal",
|
|
||||||
owner_scope=owner_scope,
|
|
||||||
)
|
)
|
||||||
session.add(character)
|
if part
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
entries_data = character_book_to_entries(card.character_book)
|
|
||||||
if entries_data:
|
|
||||||
book = card.character_book or {}
|
|
||||||
lorebook = Lorebook(
|
|
||||||
character_id=character.id,
|
|
||||||
name=book.get("name", "") or card.name,
|
|
||||||
description=book.get("description", ""),
|
|
||||||
scan_depth=book.get("scan_depth"),
|
|
||||||
token_budget=book.get("token_budget"),
|
|
||||||
recursive_scanning=bool(book.get("recursive_scanning", False)),
|
|
||||||
scope="character",
|
|
||||||
owner_scope=owner_scope,
|
|
||||||
)
|
)
|
||||||
session.add(lorebook)
|
baseline = {
|
||||||
await session.flush()
|
"import_card": {
|
||||||
for entry in entries_data:
|
"spec_version": card.spec_version,
|
||||||
session.add(
|
"description": card.description,
|
||||||
LorebookEntry(
|
"personality": card.personality,
|
||||||
lorebook_id=lorebook.id,
|
"scenario": card.scenario,
|
||||||
keys_json=dumps(entry["keys"]),
|
"first_mes": card.first_mes,
|
||||||
content=entry["content"],
|
"mes_example": card.mes_example,
|
||||||
secondary_keys_json=dumps(entry["secondary_keys"]),
|
"system_prompt": card.system_prompt,
|
||||||
constant=entry["constant"],
|
"post_history_instructions": card.post_history_instructions,
|
||||||
selective=entry["selective"],
|
"alternate_greetings": card.alternate_greetings,
|
||||||
enabled=entry["enabled"],
|
"tags": card.tags,
|
||||||
case_sensitive=entry["case_sensitive"],
|
"creator": card.creator,
|
||||||
use_regex=entry["use_regex"],
|
"character_version": card.character_version,
|
||||||
position=entry["position"],
|
"character_book": card.character_book,
|
||||||
insertion_order=entry["insertion_order"],
|
}
|
||||||
priority=entry["priority"],
|
}
|
||||||
comment=entry["comment"],
|
name = card.name.strip() or "未命名灵胚"
|
||||||
)
|
return {
|
||||||
)
|
"name": name[:120],
|
||||||
|
"description": description or None,
|
||||||
await session.commit()
|
"identity_baseline": baseline,
|
||||||
await session.refresh(character)
|
"book_entry_count": _count_book_entries(card.character_book),
|
||||||
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)
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user