M2a:镜树域后端核心——树/节点/引文/归真/移栽全量 API 与 Kernel 完成回调(芽体固化/残答封存),8 用例钉死 PRD 场景 1/2/3/5/6/7/8
This commit is contained in:
1 parent
1cd0069d65
commit
e6a3b7121f
16 files changed
+2002
-74
No files matched your search
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
Reference in new issue
Block a user