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

+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,
)
)