M2a:镜树域后端核心——树/节点/引文/归真/移栽全量 API 与 Kernel 完成回调(芽体固化/残答封存),8 用例钉死 PRD 场景 1/2/3/5/6/7/8

This commit is contained in:
chenxin committed 2026-08-21 23:00:07 +08:00
1 parent 1cd0069d65
commit e6a3b7121f
16 files changed
+2002 -74

No files matched your search

+3
View File
@@ -21,6 +21,7 @@ from app.api.v1.endpoints import (
jingsheng_characters,
kernel_runs,
lorebooks,
mirror_trees,
mcp,
memory,
personality,
@@ -44,6 +45,8 @@ api_router.include_router(runs.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(mirror_trees.node_router)
api_router.include_router(memory.router)
api_router.include_router(lorebooks.router)
api_router.include_router(personality.router)
@@ -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)
+20
View File
@@ -131,6 +131,26 @@ 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_role_id() -> str:
return new_id("rl_")
+7 -16
View File
@@ -71,24 +71,15 @@ async def emit_seat_event(
requester: str | None = None,
) -> None:
"""事务外发射命台事件(一炁,visibility=internal)。"""
from app.modules.runtime.events import Event
from app.services.event_store import DbEventStore
from app.domains import one_qi
from app.domains.jingsheng import service as jingsheng_service
store: DbEventStore | None = jingsheng_service._event_store # noqa: SLF001
if store is None:
return
payload: dict = {"holder": holder, "epoch": epoch}
if requester is not None:
payload["requester"] = requester
await store.append(
Event(
type=event_type,
source="jingsheng",
payload=payload,
visibility="internal",
run_id=None,
character_id=character_id,
)
await one_qi.emit(
event_type,
character_id=character_id,
payload=payload,
visibility="internal",
source="jingsheng",
)
+9 -18
View File
@@ -16,14 +16,12 @@ from app.models.character import Character
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat
from app.services.event_store import DbEventStore
# 事件由服务层经一炁落库(DbEventStore 由调用方注入;默认取进程级装配)
_event_store: DbEventStore | None = None
from app.domains import one_qi
def bind_event_store(store: DbEventStore) -> None:
"""lifespan 装配时注入事件存储。"""
global _event_store
_event_store = store
"""lifespan 装配时注入事件存储(委托共享绑定)。"""
one_qi.bind_event_store(store)
async def _emit(
@@ -34,19 +32,12 @@ async def _emit(
payload: dict,
visibility: str = "public",
) -> None:
from app.modules.runtime.events import Event
if _event_store is None:
return
await _event_store.append(
Event(
type=event_type,
source="jingsheng",
payload=payload,
visibility=visibility, # type: ignore[arg-type]
run_id=None,
character_id=character_id,
)
await one_qi.emit(
event_type,
character_id=character_id,
payload=payload,
visibility=visibility,
source="jingsheng",
)
@@ -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",
]
+815
View File
@@ -0,0 +1,815 @@
"""镜树域服务:树/节点生命周期、另答、续思/续生、编辑分叉、采用、归真、引文、移栽。
核心不变量(mirror-tree PRD §4,测试钉死):
1. 重新生成永不覆盖旧候答(新行插入);
2. 后代绑定用户实际采用的候答(问节默认挂工作入口);
3. 采用 ≠ 归真:working_entry 变更不触碰 canonizations;
4. 已归真经历不因切换候答回滚(canonization 不可变);
5. 编辑已发送内容 = 新版本分叉,原文保留;
6. 归真路径与既有主脉分叉时,后到操作 409(HJ-1102),不静默替换。
"""
from __future__ import annotations
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) -> str:
"""按祖先链组装投影 prompt(M2 形态:祖先路径 + 问题 + 临时指引;记忆/引文随 M3)。"""
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}:{n.content}")
transcript = "\n".join(lines)
parts = []
if transcript:
parts.append(f"【共同经历(祖先路径)】\n{transcript}")
parts.append(f"【当前问题】\n{question.content}")
if instructions:
parts.append(f"【本次指引】\n{instructions}")
return "\n\n".join(parts)
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",
)
prompt = await build_generation_prompt(question, instructions)
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),
)
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()
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}
+42
View File
@@ -0,0 +1,42 @@
"""领域层一炁事件发射(共享绑定)。
领域服务不持有 Run 上下文时(灵胚创建、采用、归真等)经此写入事件日志;
lifespan 装配时 ``bind_event_store`` 注入进程级 EventStore。事件类型取值以
契约词典 §8.3 为准,新增先登记后使用。
"""
from __future__ import annotations
from app.services.event_store import DbEventStore
_store: DbEventStore | None = None
def bind_event_store(store: DbEventStore) -> None:
global _store
_store = store
async def emit(
event_type: str,
*,
character_id: str | None,
payload: dict,
visibility: str = "public",
run_id: str | None = None,
source: str,
) -> None:
from app.modules.runtime.events import Event
if _store is None:
return
await _store.append(
Event(
type=event_type,
source=source,
payload=payload,
visibility=visibility, # type: ignore[arg-type]
run_id=run_id,
character_id=character_id,
)
)
+76 -11
View File
@@ -10,7 +10,7 @@ import asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.kernel.eventlog import KernelEventLog
from app.kernel.loop import KernelRunLoop
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
@@ -47,7 +47,14 @@ class Kernel:
self._tasks: set[asyncio.Task[None]] = set()
async def start_foreground_run(
self, *, character_id: str, prompt: str, trace_id: str | None = None
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,
) -> Run:
"""创建 Run 行(queued)并异步启动循环;前台席位在循环内排队获取。"""
profile = resolve_profile()
@@ -56,23 +63,81 @@ class Kernel:
character_id=character_id,
session_id=None,
status="queued",
kind="foreground",
goal=prompt[:300] if prompt else None,
origin="kernel",
origin=origin,
model_hint=str(profile.params.get("slot", "chat")),
)
session.add(run)
await session.commit()
run_id = run.id
task = asyncio.create_task(
self.loop.execute(
run_id=run_id, character_id=character_id, prompt=prompt, trace_id=trace_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,
)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
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,
session_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
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,
) -> 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,
)
)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
+89 -25
View File
@@ -1,20 +1,22 @@
"""Kernel Run 循环骨架(M0 占位形态)。
"""Kernel Run 循环骨架。
事件序列对应 docs/architecture/lingtai-runtime.md §3.1;瀑布节点可被插件否决。
M0 的投影为固定占位(系统契约占位 + 用户输入),M2 起接入镜树祖先路径、获准记忆
与显式引文三类来源。
投影当前为「调用方组装」形态:prompt 由领域层按镜树祖先链组装后传入,
三类投影来源(祖先路径/获准记忆/显式引文)的完整接入随 M3 息壤落地。
``RunOutcome`` 携带终态与生成内容,供领域层(镜树节点固化等)在 Run 结束时回调。
"""
from __future__ import annotations
import asyncio
from dataclasses import replace
from datetime import datetime, UTC
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_run_id, new_trace_id
from app.core.ids import new_trace_id
from app.kernel.eventlog import KernelEventLog
from app.kernel.invariants import (
InvariantViolation,
@@ -34,13 +36,29 @@ from app.runtime.bus import (
)
from app.runtime.profile import Profile, resolve_profile
from app.shenshi_client.client import ShenshiClient
from app.shenshi_client.types import ShenshiMessage, ShenshiChatRequest
from app.shenshi_client.types import ShenshiChatRequest, ShenshiMessage
_SYSTEM_CONTRACT_PLACEHOLDER = "你是魂镜人物运行时(M0 骨架占位契约)。"
_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 的执行骨架:命台席位 → 事件序列 → 神识流 → 接纳断言。"""
"""单次 Run 的执行骨架:命台席位 → 事件序列 → 神识流 → 接纳断言。
``character_id`` 允许为空(照心试演无正式人物),此时必须提供 ``seat_key``。
"""
def __init__(
self,
@@ -60,30 +78,62 @@ class KernelRunLoop:
self._timeout = timeout_seconds
async def execute(
self, *, run_id: str, character_id: str, prompt: str, trace_id: str | None = None
) -> None:
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,
) -> 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
run_id=run_id,
character_id=character_id,
prompt=prompt,
trace_id=trace,
seat_key=seat_key,
system_contract=system_contract,
outcome=outcome,
)
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, prompt: str, trace_id: str
self,
*,
run_id: str,
character_id: str | None,
prompt: str,
trace_id: str,
seat_key: str | None,
system_contract: str | None,
outcome: RunOutcome,
) -> 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
@@ -91,13 +141,26 @@ class KernelRunLoop:
return
await self._emit(run_id, character_id, trace_id, "run.queued")
async with self._seats.foreground(character_id):
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)
await self._turn(
run_id,
character_id,
trace_id,
prompt,
system_contract or _SYSTEM_CONTRACT_PLACEHOLDER,
outcome,
)
async def _turn(
self, run_id: str, character_id: str, trace_id: str, prompt: str
self,
run_id: str,
character_id: str | None,
trace_id: str,
prompt: str,
system_contract: str,
outcome: RunOutcome,
) -> None:
ctx = RunContext(
run_id=run_id, character_id=character_id, trace_id=trace_id, payload={"prompt": prompt}
@@ -110,7 +173,7 @@ class KernelRunLoop:
model=profile.params.get("model"),
slot=str(profile.params.get("slot", "chat")),
messages=[
ShenshiMessage(role="system", content=_SYSTEM_CONTRACT_PLACEHOLDER),
ShenshiMessage(role="system", content=system_contract),
ShenshiMessage(role="user", content=prompt),
],
run_id=run_id,
@@ -124,13 +187,14 @@ class KernelRunLoop:
"context.projected",
payload={
"profile": profile.snapshot(),
"sources": {"system_contract": "placeholder", "user_input": True},
"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
@@ -163,6 +227,7 @@ class KernelRunLoop:
"finish_reason": delta.finish_reason,
}
except Exception as exc: # 神识侧失败:如实上报,不伪装完成
outcome.status, outcome.error_code = "interrupted", "HJ-2001"
await self._emit(
run_id,
character_id,
@@ -187,6 +252,7 @@ class KernelRunLoop:
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,
@@ -210,9 +276,11 @@ class KernelRunLoop:
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, trace_id: str, veto: Veto
self, run_id: str, character_id: str | None, trace_id: str, veto: Veto
) -> None:
await self._emit(
run_id,
@@ -226,7 +294,7 @@ class KernelRunLoop:
async def _finish_interrupted(
self,
run_id: str,
character_id: str,
character_id: str | None,
trace_id: str,
*,
code: str,
@@ -238,7 +306,7 @@ class KernelRunLoop:
async def _emit(
self,
run_id: str,
character_id: str,
character_id: str | None,
trace_id: str,
type: str, # noqa: A002
payload: dict[str, Any] | None = None,
@@ -273,7 +341,3 @@ class KernelRunLoop:
if status in ("completed", "failed", "cancelled", "interrupted"):
run.completed_at = datetime.now(UTC)
await session.commit()
def new_kernel_run_id() -> str:
return new_run_id()
+7
View File
@@ -19,6 +19,13 @@ 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.mcp_servers import McpServer
from app.models.mirror_tree import (
Canonization,
Citation,
MirrorTree,
TreeNode,
TreeTransplant,
)
from app.models.memory import Memory, MemoryLink
from app.models.personality import PersonalityProposal, PersonalityVersion
from app.models.personality_emotion import CharacterEmotionState
+132
View File
@@ -0,0 +1,132 @@
"""镜树域模型(data-model.md §2):主题镜树、节点、引文锚、归真记录、移栽谱系。
不变量(应用层强制 + 测试钉死):
- 候答重生成=插入新行,旧节点永不 UPDATE 内容或状态回退;
- 后代节点绑定用户实际采用的候答(问节默认挂工作入口);
- working_entry 变更不触碰 canonizations(采用与归真解耦);
- canonizations 不可变,正影=新版本节点 + 新归真记录。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import (
new_canonization_id,
new_citation_id,
new_mirror_tree_id,
new_tree_node_id,
new_transplant_id,
)
from app.core.time import utc_now
from app.db.base import Base
class MirrorTree(Base):
"""主题镜树:一条归真主脉 + 多条推演枝 + 一个工作入口。"""
__tablename__ = "mirror_trees"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_mirror_tree_id)
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(300), nullable=False)
# active / archived
status: Mapped[str] = mapped_column(String(40), nullable=False, default="active", index=True)
# 默认界域(reality / persistent_world / simulation),问节未显式指定时继承
default_realm: Mapped[str] = mapped_column(String(40), nullable=False, default="reality")
# 采用:当前阅读与继续生长的工作入口(answer/continuation 节点)
working_entry_node_id: Mapped[str | None] = mapped_column(String(80))
# 移栽来源谱系:{source_tree_id, source_node_id};原生树为空
transplanted_from_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class TreeNode(Base):
"""统一节点表:问节 / 候答 / 续思。
拓扑:answer.parent=question;question.parent=所依据的候答(默认工作入口);
continuation.parent=被延展的候答或续思。编辑已发送内容=新建分叉节点(supersedes 指旧节点)。
"""
__tablename__ = "tree_nodes"
__table_args__ = (
UniqueConstraint("tree_id", "supersedes_node_id", name="uq_tree_node_supersedes"),
)
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_tree_node_id)
tree_id: Mapped[str] = mapped_column(String(80), ForeignKey("mirror_trees.id"), index=True)
parent_node_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("tree_nodes.id"), index=True
)
# question / answer / continuation(契约词典 §9.1)
node_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
# budding / sealed_residual / solidified(契约词典 §9.2)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="budding", index=True)
# user / character(问节作者为 user;候答为 character)
author: Mapped[str] = mapped_column(String(40), nullable=False, default="user")
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 节点界域(契约词典 §12);默认继承树默认值
realm: Mapped[str] = mapped_column(String(40), nullable=False, default="reality")
# 生成元数据:{model, run_id, trace_id, instructions}
generation_meta_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 正影/编辑分叉:新节点指向被替代的旧节点(旧节点原样保留)
supersedes_node_id: Mapped[str | None] = mapped_column(String(80))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class Citation(Base):
"""引文锚:稳定引用 + 原文快照。来源正影时旧锚不静默替换,仅提示后继版本。"""
__tablename__ = "citations"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_citation_id)
node_id: Mapped[str] = mapped_column(String(80), ForeignKey("tree_nodes.id"), index=True)
# text_range / image_region / pdf_region / attachment / av_segment(契约词典 §9.5)
target_type: Mapped[str] = mapped_column(String(40), nullable=False)
# 定位信息:{文本区间 / 页码区域 / 时间段等}
locator_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 引用时的原文/媒体快照(来源变化不被覆盖)
snapshot: Mapped[str] = mapped_column(Text, nullable=False, default="")
source_id: Mapped[str] = mapped_column(String(200), nullable=False)
source_version: Mapped[int] = mapped_column(nullable=False, default=1)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class Canonization(Base):
"""归真记录(不可变):路径进入明确的 canonical_scope;主脉=同作用域记录的路径并集。"""
__tablename__ = "canonizations"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_canonization_id)
tree_id: Mapped[str] = mapped_column(String(80), ForeignKey("mirror_trees.id"), index=True)
character_id: Mapped[str] = mapped_column(String(80), index=True)
# reality / persistent_world / simulation(契约词典 §9.4)
scope: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
world_id: Mapped[str | None] = mapped_column(String(80))
# 路径端点(answer/continuation 节点)
endpoint_node_id: Mapped[str] = mapped_column(String(80), index=True)
# 端点祖先链快照(root→endpoint 的节点 ID 序列)
path_nodes_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
confirmed_by: Mapped[str] = mapped_column(String(80), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class TreeTransplant(Base):
"""移栽谱系:分支复制为新树,保留来源引用;树间只引用不物理合并。"""
__tablename__ = "tree_transplants"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_transplant_id)
source_tree_id: Mapped[str] = mapped_column(String(80), index=True)
source_node_id: Mapped[str] = mapped_column(String(80))
new_tree_id: Mapped[str] = mapped_column(String(80), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)