Files
hunjing/backend/app/domains/mirror_tree/service.py
T

902 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""镜树域服务:树/节点生命周期、另答、续思/续生、编辑分叉、采用、归真、引文、移栽。
核心不变量(mirror-tree PRD §4,测试钉死):
1. 重新生成永不覆盖旧候答(新行插入);
2. 后代绑定用户实际采用的候答(问节默认挂工作入口);
3. 采用 ≠ 归真:working_entry 变更不触碰 canonizations;
4. 已归真经历不因切换候答回滚(canonization 不可变);
5. 编辑已发送内容 = 新版本分叉,原文保留;
6. 归真路径与既有主脉分叉时,后到操作 409(HJ-1102),不静默替换。
"""
from __future__ import annotations
import asyncio
import json
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.domains import one_qi
from app.kernel import Kernel
from app.models.character import Character
from app.models.mirror_tree import (
Canonization,
Citation,
MirrorTree,
TreeNode,
TreeTransplant,
)
REALMS = ("reality", "persistent_world", "simulation")
SCOPES = REALMS # canonical_scope 与 realm 同值族(契约词典 §9.4/§12)
# ── 所有权与取数 ──────────────────────────────────────────────
async def _owned_tree(tree_id: str, owner_scope: str) -> MirrorTree:
async with AsyncSessionLocal() as session:
tree = await session.get(MirrorTree, tree_id)
if tree is not None:
character = await session.get(Character, tree.character_id)
if tree is None or character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "镜树不存在。")
return tree
async def _node_in_tree(session: AsyncSession, node_id: str, tree_id: str) -> TreeNode:
node = await session.get(TreeNode, node_id)
if node is None or node.tree_id != tree_id:
raise AppError("not_found", "节点不存在或不属于该树。")
return node
async def _get_node(node_id: str) -> TreeNode:
async with AsyncSessionLocal() as session:
node = await session.get(TreeNode, node_id)
if node is None:
raise AppError("not_found", "节点不存在。")
return node
def node_view(node: TreeNode, citations: list[Citation] | None = None) -> dict:
return {
"id": node.id,
"tree_id": node.tree_id,
"parent_node_id": node.parent_node_id,
"node_type": node.node_type,
"status": node.status,
"author": node.author,
"content": node.content,
"realm": node.realm,
"generation_meta": json.loads(node.generation_meta_json or "{}"),
"supersedes_node_id": node.supersedes_node_id,
"citations": [
{
"id": c.id,
"target_type": c.target_type,
"snapshot_digest": c.snapshot[:64],
"source_id": c.source_id,
"source_version": c.source_version,
}
for c in (citations or [])
],
"created_at": node.created_at.isoformat(),
}
async def _node_citations(session: AsyncSession, node_id: str) -> list[Citation]:
result = await session.execute(
select(Citation).where(Citation.node_id == node_id).order_by(Citation.created_at)
)
return list(result.scalars().all())
# ── 树 ──────────────────────────────────────────────
async def create_tree(*, character_id: str, owner_scope: str, title: str, default_realm: str = "reality") -> MirrorTree:
if default_realm not in REALMS:
raise AppError("invalid_request", "界域取值不合法。")
tree = MirrorTree(character_id=character_id, title=title, default_realm=default_realm)
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
session.add(tree)
await session.commit()
await session.refresh(tree)
await one_qi.emit(
"tree.created",
character_id=character_id,
payload={"tree_id": tree.id, "title": title},
source="mirror_tree",
)
return tree
async def list_trees(owner_scope: str, character_id: str | None = None) -> list[MirrorTree]:
async with AsyncSessionLocal() as session:
character_ids = [
c.id
for c in (
await session.execute(
select(Character.id).where(Character.owner_scope == owner_scope)
)
).scalars()
]
if not character_ids:
return []
stmt = select(MirrorTree).where(
MirrorTree.character_id.in_(character_ids), MirrorTree.status == "active"
)
if character_id is not None:
stmt = stmt.where(MirrorTree.character_id == character_id)
stmt = stmt.order_by(MirrorTree.updated_at.desc())
return list((await session.execute(stmt)).scalars().all())
def tree_view(tree: MirrorTree) -> dict:
return {
"id": tree.id,
"character_id": tree.character_id,
"title": tree.title,
"status": tree.status,
"default_realm": tree.default_realm,
"working_entry_node_id": tree.working_entry_node_id,
"transplanted_from": json.loads(tree.transplanted_from_json or "{}"),
"created_at": tree.created_at.isoformat(),
}
async def get_tree_view(tree_id: str, owner_scope: str) -> dict:
"""树详情视图(含权限校验)。"""
async with AsyncSessionLocal() as session:
tree = await session.get(MirrorTree, tree_id)
if tree is not None:
character = await session.get(Character, tree.character_id)
if tree is None or character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "镜树不存在。")
return tree_view(tree)
async def rename_tree(tree_id: str, owner_scope: str, title: str) -> dict:
await _owned_tree(tree_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(MirrorTree, tree_id)
row.title = title
view = tree_view(row)
await session.commit()
return view
async def archive_tree(tree_id: str, owner_scope: str) -> dict:
await _owned_tree(tree_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(MirrorTree, tree_id)
row.status = "archived"
view = tree_view(row) # 会话内取视图,避免提交后属性过期
await session.commit()
return view
# ── 路径与图 ──────────────────────────────────────────────
async def ancestor_chain(node_id: str) -> list[TreeNode]:
"""root→node 的祖先链(含自身)。环与超深保护:上限 10_000 层。"""
chain: list[TreeNode] = []
current_id: str | None = node_id
async with AsyncSessionLocal() as session:
for _ in range(10_000):
if current_id is None:
break
node = await session.get(TreeNode, current_id)
if node is None:
raise AppError("not_found", f"祖先链断裂:{current_id}")
chain.append(node)
current_id = node.parent_node_id
chain.reverse()
return chain
async def path_view(tree_id: str, owner_scope: str, entry_node_id: str | None) -> dict:
tree = await _owned_tree(tree_id, owner_scope)
entry = entry_node_id or tree.working_entry_node_id
async with AsyncSessionLocal() as session:
if entry is None:
root_questions = (
await session.execute(
select(TreeNode).where(
TreeNode.tree_id == tree_id, TreeNode.parent_node_id.is_(None)
)
)
).scalars().all()
nodes = [
node_view(n, await _node_citations(session, n.id))
for n in sorted(root_questions, key=lambda n: n.created_at)
]
return {"tree_id": tree_id, "entry_node_id": None, "nodes": nodes}
node = await _node_in_tree(session, entry, tree_id)
chain = [node]
cursor = node.parent_node_id
for _ in range(10_000):
if cursor is None:
break
parent = await session.get(TreeNode, cursor)
chain.append(parent)
cursor = parent.parent_node_id
chain.reverse()
return {
"tree_id": tree_id,
"entry_node_id": entry,
"nodes": [node_view(n, await _node_citations(session, n.id)) for n in chain],
}
async def local_graph(tree_id: str, owner_scope: str, node_id: str | None, depth: int = 2) -> dict:
"""局部树图:入口的祖先(全链)+ 兄弟 + 向下 depth 层子枝(服务定位,不替代阅读)。"""
tree = await _owned_tree(tree_id, owner_scope)
entry = node_id or tree.working_entry_node_id
async with AsyncSessionLocal() as session:
nodes_out: dict[str, dict] = {}
if entry is not None:
node = await _node_in_tree(session, entry, tree_id)
chain = [node]
cursor = node.parent_node_id
while cursor is not None:
parent = await session.get(TreeNode, cursor)
chain.append(parent)
cursor = parent.parent_node_id
for n in chain:
nodes_out[n.id] = _graph_node(n)
# 兄弟(同父的其他子节点)
for n in chain:
if n.parent_node_id is not None:
stmt = select(TreeNode).where(
TreeNode.tree_id == tree_id, TreeNode.parent_node_id == n.parent_node_id
)
else:
stmt = select(TreeNode).where(
TreeNode.tree_id == tree_id, TreeNode.parent_node_id.is_(None)
)
siblings = (await session.execute(stmt)).scalars().all()
for s in siblings:
if s.id not in nodes_out:
nodes_out[s.id] = _graph_node(s)
# 向下展开
frontier: list[str] = [entry] if entry is not None else []
for _ in range(max(depth, 0)):
next_frontier: list[str] = []
for fid in frontier:
children = (
await session.execute(
select(TreeNode).where(TreeNode.parent_node_id == fid)
)
).scalars().all()
for child in children:
if child.id not in nodes_out:
nodes_out[child.id] = _graph_node(child)
next_frontier.append(child.id)
frontier = next_frontier
# 无入口(新树):给根问节
if entry is None:
roots = (
await session.execute(
select(TreeNode).where(
TreeNode.tree_id == tree_id, TreeNode.parent_node_id.is_(None)
)
)
).scalars().all()
for r in roots:
nodes_out[r.id] = _graph_node(r)
return {"tree_id": tree_id, "focus_node_id": entry, "nodes": list(nodes_out.values())}
def _graph_node(node: TreeNode) -> dict:
return {
"id": node.id,
"parent_node_id": node.parent_node_id,
"node_type": node.node_type,
"status": node.status,
"realm": node.realm,
"branch_mark": node.node_type == "answer",
"content_preview": node.content[:80],
}
# ── 问节与生成 ──────────────────────────────────────────────
async def create_question(
*,
tree_id: str,
owner_scope: str,
content: str,
realm: str | None = None,
parent_answer_id: str | None = None,
citations: list[dict] | None = None,
) -> TreeNode:
"""新问节:默认挂当前工作入口(不变量 2——绑定用户实际采用的候答)。"""
tree = await _owned_tree(tree_id, owner_scope)
parent_id = parent_answer_id or tree.working_entry_node_id
async with AsyncSessionLocal() as session:
if parent_id is not None:
parent = await _node_in_tree(session, parent_id, tree_id)
if parent.node_type == "question":
raise AppError("invalid_request", "问节必须挂在候答/续思之下(或作为根问节)。")
effective_realm = realm or tree.default_realm
if effective_realm not in REALMS:
raise AppError("invalid_request", "界域取值不合法。")
node = TreeNode(
tree_id=tree_id,
parent_node_id=parent_id,
node_type="question",
status="solidified",
author="user",
content=content,
realm=effective_realm,
)
session.add(node)
await session.flush()
node_id = node.id
if citations:
for c in citations:
session.add(_citation_row(node_id, c))
# 界域切换可见(PRD §8):与父节点(或树默认界域,根问节时)不同即留痕入境;
# 事件在提交后发射,避免事务内跨连接写(SQLite 锁冲突)
baseline_realm = tree.default_realm
if parent_id is not None:
baseline_realm = (await session.get(TreeNode, parent_id)).realm
realm_event = (
{"tree_id": tree_id, "node_id": node_id, "realm": effective_realm}
if effective_realm != baseline_realm
else None
)
await session.commit()
if realm_event is not None:
await one_qi.emit(
"realm.entered",
character_id=tree.character_id,
payload=realm_event,
source="mirror_tree",
)
await one_qi.emit(
"tree.node_created",
character_id=tree.character_id,
payload={"tree_id": tree_id, "node_id": node_id, "node_type": "question"},
source="mirror_tree",
)
return await _get_node(node_id)
def _citation_row(node_id: str, data: dict) -> Citation:
return Citation(
node_id=node_id,
target_type=data.get("target_type", "text_range"),
locator_json=json.dumps(data.get("locator", {}), ensure_ascii=False),
snapshot=data.get("snapshot", ""),
source_id=data.get("source_id", ""),
source_version=int(data.get("source_version", 1)),
)
async def build_generation_prompt(
question: TreeNode,
instructions: str | None,
*,
char_name: str | None = None,
user_name: str | None = None,
) -> str:
"""按祖先链组装投影 prompt 并做符文展开({{char}}/{{user}}/…)。
M2 形态:祖先路径 + 问题 + 临时指引;获准记忆与显式引文随 M3 息壤接入。
"""
from app.modules.macros.builtins import build_default_registry
from app.modules.macros.engine import render
from app.modules.macros.types import MacroContext
ctx = MacroContext(char_name=char_name, user_name=user_name)
registry = build_default_registry()
def expand(text: str) -> str:
return render(text, ctx, registry=registry)
chain = await ancestor_chain(question.id)
lines: list[str] = []
for n in chain:
if n.id == question.id:
continue
speaker = "用户" if n.node_type == "question" else "人物"
lines.append(f"{speaker}:{expand(n.content)}")
transcript = "\n".join(lines)
parts = []
# 获准长期记忆(息壤 M3):召回透明——注入来源可展开,旁枝不入(默认 active 级)
if question.tree_id:
from app.domains.xirang import service as xirang_service
recalled = await xirang_service.recall_for_generation(
await _tree_character_id(question.tree_id),
f"{transcript}\n{question.content}",
realm=question.realm,
)
if recalled:
memory_lines = [f"- {m.content}" for m in recalled]
parts.append(f"【获准记忆(来源可查)】\n" + "\n".join(memory_lines))
if transcript:
parts.append(f"【共同经历(祖先路径)】\n{transcript}")
parts.append(f"【当前问题】\n{expand(question.content)}")
if instructions:
parts.append(f"【本次指引】\n{expand(instructions)}")
return "\n\n".join(parts)
async def _names_for_generation(tree: MirrorTree) -> tuple[str | None, str | None]:
"""人物名与执镜者名(符文上下文)。"""
from app.models.user import User
async with AsyncSessionLocal() as session:
character = await session.get(Character, tree.character_id)
char_name = character.name if character else None
user_name = None
if character is not None:
user = await session.get(User, character.owner_scope)
user_name = user.username if user else None
return char_name, user_name
async def _generation_overrides(owner_scope: str) -> dict:
"""火候(GenerationPreferences):本镜域默认采样参数 → IR 覆写。"""
from sqlalchemy import select
from app.models.governance import GenerationPreferences
async with AsyncSessionLocal() as session:
prefs = await session.get(GenerationPreferences, owner_scope)
if prefs is None:
# 兼容旧默认 owner_scope 行(历史数据)
prefs = (
await session.execute(
select(GenerationPreferences).where(
GenerationPreferences.owner_scope == owner_scope
)
)
).scalar_one_or_none()
if prefs is None:
return {}
return {"temperature": prefs.temperature, "max_tokens": prefs.max_output_tokens}
async def spawn_answer(
*,
question_id: str,
owner_scope: str,
kernel: Kernel,
instructions: str | None = None,
) -> dict:
"""另答:新候答(budding)+ Kernel 前台 Run;完成回调固化节点,永不覆盖旧候答。"""
question = await _get_node(question_id)
if question.node_type != "question":
raise AppError("invalid_request", "只能在问节下生成候答。")
tree = await _owned_tree(question.tree_id, owner_scope)
node = TreeNode(
tree_id=question.tree_id,
parent_node_id=question.id,
node_type="answer",
status="budding",
author="character",
content="",
realm=question.realm,
)
async with AsyncSessionLocal() as session:
session.add(node)
await session.flush()
node_id = node.id
await session.commit()
await one_qi.emit(
"tree.node_created",
character_id=tree.character_id,
payload={"tree_id": tree.id, "node_id": node_id, "node_type": "answer"},
source="mirror_tree",
)
char_name, user_name = await _names_for_generation(tree)
prompt = await build_generation_prompt(
question, instructions, char_name=char_name, user_name=user_name
)
overrides = await _generation_overrides(owner_scope)
run = await kernel.start_foreground_run(
character_id=tree.character_id,
prompt=prompt,
system_contract=None,
origin="tree_answer",
on_run_final=_make_finalize(tree.id, tree.character_id, node_id),
**overrides,
)
async with AsyncSessionLocal() as session:
node = await session.get(TreeNode, node_id)
node.generation_meta_json = json.dumps({"run_id": run.id}, ensure_ascii=False)
await session.commit()
return {"node_id": node_id, "run_id": run.id}
def _make_finalize(tree_id: str, character_id: str, node_id: str):
async def finalize(content: str, status: str) -> None:
async with AsyncSessionLocal() as session:
node = await session.get(TreeNode, node_id)
if node is None:
return
run_id = json.loads(node.generation_meta_json or "{}").get("run_id")
if status == "completed":
node.content = content
node.status = "solidified"
event = "tree.node_solidified"
payload = {"tree_id": tree_id, "node_id": node_id}
else:
node.content = content # 残答保留已生成部分,只读封存
node.status = "sealed_residual"
event = "tree.node_sealed"
payload = {"tree_id": tree_id, "node_id": node_id}
await session.commit()
await one_qi.emit(
event,
character_id=character_id,
payload=payload,
run_id=run_id,
source="mirror_tree",
)
return finalize
async def continue_node(
*, node_id: str, owner_scope: str, kernel: Kernel, instructions: str | None = None
) -> dict:
"""续思:基于已固化候答/续思生成独立延展节点,不修改原节点。"""
base = await _get_node(node_id)
if base.node_type == "question":
raise AppError("invalid_request", "续思基于候答或续思,不能基于问节。")
if base.status != "solidified":
raise AppError("HJ-1103", "只能续思已固化的内容。", status_code=409)
return await _spawn_continuation(base, owner_scope, kernel, kind="continue", instructions=instructions)
async def resume_node(
*, node_id: str, owner_scope: str, kernel: Kernel, instructions: str | None = None
) -> dict:
"""续生:针对中断残答,从其已完成内容继续生成新的延展节点;不改写残答。"""
base = await _get_node(node_id)
if base.status != "sealed_residual":
raise AppError("HJ-1103", "只能续生残答。", status_code=409)
return await _spawn_continuation(base, owner_scope, kernel, kind="resume", instructions=instructions)
async def _spawn_continuation(
base: TreeNode, owner_scope: str, kernel: Kernel, *, kind: str, instructions: str | None
) -> dict:
tree = await _owned_tree(base.tree_id, owner_scope)
node = TreeNode(
tree_id=base.tree_id,
parent_node_id=base.id,
node_type="continuation",
status="budding",
author="character",
content="",
realm=base.realm,
)
async with AsyncSessionLocal() as session:
session.add(node)
await session.flush()
node_id = node.id
await session.commit()
await one_qi.emit(
"tree.node_created",
character_id=tree.character_id,
payload={"tree_id": tree.id, "node_id": node_id, "node_type": "continuation"},
source="mirror_tree",
)
if kind == "continue":
prompt = f"【继续深入】\n请针对以下内容继续深入分析:\n\n{base.content}"
if instructions:
prompt += f"\n\n【本次指引】\n{instructions}"
else:
prompt = f"【续生】\n以下内容被中断,请从其结尾继续补全(不要重复已有内容):\n\n{base.content}"
if instructions:
prompt += f"\n\n【本次指引】\n{instructions}"
run = await kernel.start_foreground_run(
character_id=tree.character_id,
prompt=prompt,
origin=f"tree_{kind}",
on_run_final=_make_finalize(tree.id, tree.character_id, node_id),
)
async with AsyncSessionLocal() as session:
row = await session.get(TreeNode, node_id)
row.generation_meta_json = json.dumps({"run_id": run.id}, ensure_ascii=False)
await session.commit()
return {"node_id": node_id, "run_id": run.id}
async def edit_fork(*, node_id: str, owner_scope: str, new_content: str) -> dict:
"""编辑已发送内容 = 新版本分叉:新节点 supersedes 旧节点,原文与后代保留。"""
old = await _get_node(node_id)
tree = await _owned_tree(old.tree_id, owner_scope)
if old.status == "budding":
raise AppError("HJ-1103", "芽体生成中,不能编辑分叉。", status_code=409)
async with AsyncSessionLocal() as session:
fork = TreeNode(
tree_id=old.tree_id,
parent_node_id=old.parent_node_id,
node_type=old.node_type,
status="solidified",
author=old.author,
content=new_content,
realm=old.realm,
supersedes_node_id=old.id,
)
session.add(fork)
await session.flush()
fork_id = fork.id
await session.commit()
await one_qi.emit(
"tree.node_created",
character_id=tree.character_id,
payload={
"tree_id": tree.id,
"node_id": fork_id,
"node_type": old.node_type,
"supersedes": old.id,
},
source="mirror_tree",
)
return {"node_id": fork_id, "supersedes_node_id": old.id}
# ── 采用与归真 ──────────────────────────────────────────────
async def adopt(*, tree_id: str, owner_scope: str, node_id: str) -> dict:
"""采用:仅改变工作入口;不触碰 canonizations(不变量 3)。"""
tree = await _owned_tree(tree_id, owner_scope)
async with AsyncSessionLocal() as session:
node = await _node_in_tree(session, node_id, tree_id)
if node.node_type == "question":
raise AppError("invalid_request", "工作入口必须是候答或续思节点。")
if node.status != "solidified":
raise AppError("HJ-1103", "只能采用已固化的候答(芽体/残答不可采用)。", status_code=409)
row = await session.get(MirrorTree, tree_id)
row.working_entry_node_id = node_id
await session.commit()
await one_qi.emit(
"tree.adopted",
character_id=tree.character_id,
payload={"tree_id": tree_id, "node_id": node_id},
source="mirror_tree",
)
return {"tree_id": tree_id, "working_entry_node_id": node_id}
async def canonize(
*,
tree_id: str,
owner_scope: str,
endpoint_node_id: str,
scope: str,
world_id: str | None = None,
) -> dict:
"""归真:路径进入明确的 canonical_scope;与既有主脉分叉时后到操作 409(HJ-1102)。"""
tree = await _owned_tree(tree_id, owner_scope)
if scope not in SCOPES:
raise AppError("invalid_request", "归真作用域取值不合法。")
if scope == "persistent_world" and not world_id:
raise AppError("invalid_request", "境界归真必须指定 world_id。")
async with AsyncSessionLocal() as session:
endpoint = await _node_in_tree(session, endpoint_node_id, tree_id)
if endpoint.node_type == "question":
raise AppError("invalid_request", "归真端点必须是候答或续思节点。")
if endpoint.status != "solidified":
raise AppError(
"HJ-1103", "芽体/残答不能归真(未完成固化的内容不进入正式经历)。", status_code=409
)
chain: list[TreeNode] = [endpoint]
cursor = endpoint.parent_node_id
for _ in range(10_000):
if cursor is None:
break
parent = await session.get(TreeNode, cursor)
chain.append(parent)
cursor = parent.parent_node_id
path_ids = [n.id for n in reversed(chain)]
# 主脉分叉检查:同树同作用域已有归真时,新路径必须是主脉的延长(含旧端点)
last = await session.scalar(
select(Canonization)
.where(Canonization.tree_id == tree_id, Canonization.scope == scope)
.order_by(Canonization.created_at.desc())
.limit(1)
)
if last is not None and last.endpoint_node_id not in path_ids:
raise AppError(
"HJ-1102",
"归真路径与当前主脉分叉;已归真经历不会被静默替换,请先明确处理分歧。",
status_code=409,
details={"current_trunk_head": last.endpoint_node_id, "scope": scope},
)
record = Canonization(
tree_id=tree_id,
character_id=tree.character_id,
scope=scope,
world_id=world_id,
endpoint_node_id=endpoint_node_id,
path_nodes_json=json.dumps(path_ids),
confirmed_by=owner_scope,
)
session.add(record)
await session.flush()
record_id = record.id
await session.commit()
# 息壤:归真产生记忆候选证据(M3)——后台任务,不阻塞归真返回
from app.domains.xirang import service as xirang_service
await xirang_service.on_canonized(record_id)
await one_qi.emit(
"tree.canonized",
character_id=tree.character_id,
payload={
"tree_id": tree_id,
"canonization_id": record_id,
"scope": scope,
"world_id": world_id,
"endpoint_node_id": endpoint_node_id,
"path_length": len(path_ids),
},
source="mirror_tree",
)
return {
"canonization_id": record_id,
"scope": scope,
"endpoint_node_id": endpoint_node_id,
"path_length": len(path_ids),
}
# ── 引文锚 ──────────────────────────────────────────────
async def create_citation(
*, node_id: str, owner_scope: str, data: dict
) -> dict:
node = await _get_node(node_id)
await _owned_tree(node.tree_id, owner_scope)
async with AsyncSessionLocal() as session:
row = _citation_row(node_id, data)
session.add(row)
await session.flush()
citation_id = row.id
await session.commit()
return {"citation_id": citation_id}
async def list_citations(node_id: str, owner_scope: str) -> list[dict]:
node = await _get_node(node_id)
await _owned_tree(node.tree_id, owner_scope)
async with AsyncSessionLocal() as session:
own = await _node_citations(session, node_id)
# 正影提示:同 source 存在更高版本时标记,不静默替换(PRD §7.2)
versions: dict[str, int] = {}
all_rows = (
await session.execute(select(Citation).where(Citation.source_id != ""))
).scalars().all()
for c in all_rows:
versions[c.source_id] = max(versions.get(c.source_id, 0), c.source_version)
return [
{
"id": c.id,
"node_id": c.node_id,
"target_type": c.target_type,
"locator": json.loads(c.locator_json or "{}"),
"snapshot": c.snapshot,
"source_id": c.source_id,
"source_version": c.source_version,
"newer_version_available": versions.get(c.source_id, 0) > c.source_version,
}
for c in own
]
# ── 移栽 ──────────────────────────────────────────────
async def transplant(
*, tree_id: str, owner_scope: str, node_id: str, new_title: str
) -> dict:
"""移栽:把分支的祖先路径复制为新树(全新节点 ID),保留来源谱系;两树只引用不合流。"""
tree = await _owned_tree(tree_id, owner_scope)
branch = await _get_node(node_id)
if branch.tree_id != tree_id:
raise AppError("not_found", "节点不属于该树。")
if branch.node_type == "question":
raise AppError("invalid_request", "移栽端点应为候答或续思分支。")
chain = await ancestor_chain(node_id) # root→branch
async with AsyncSessionLocal() as session:
new_tree = MirrorTree(
character_id=tree.character_id,
title=new_title,
default_realm=tree.default_realm,
transplanted_from_json=json.dumps(
{"source_tree_id": tree_id, "source_node_id": node_id}
),
)
session.add(new_tree)
await session.flush()
id_map: dict[str, str] = {}
for old_node in chain:
new_node = TreeNode(
tree_id=new_tree.id,
parent_node_id=id_map.get(old_node.parent_node_id or ""),
node_type=old_node.node_type,
status="solidified",
author=old_node.author,
content=old_node.content,
realm=old_node.realm,
generation_meta_json=json.dumps(
{"transplanted_from_node": old_node.id}, ensure_ascii=False
),
)
session.add(new_node)
await session.flush()
id_map[old_node.id] = new_node.id
# 引文随移栽复制(快照保留)
for c in await _node_citations(session, old_node.id):
session.add(
Citation(
node_id=new_node.id,
target_type=c.target_type,
locator_json=c.locator_json,
snapshot=c.snapshot,
source_id=c.source_id,
source_version=c.source_version,
)
)
new_tree.working_entry_node_id = id_map[node_id]
session.add(
TreeTransplant(
source_tree_id=tree_id, source_node_id=node_id, new_tree_id=new_tree.id
)
)
await session.flush()
new_tree_id, new_entry = new_tree.id, new_tree.working_entry_node_id
await session.commit()
await one_qi.emit(
"tree.transplanted",
character_id=tree.character_id,
payload={
"source_tree_id": tree_id,
"source_node_id": node_id,
"new_tree_id": new_tree_id,
},
source="mirror_tree",
)
return {"new_tree_id": new_tree_id, "working_entry_node_id": new_entry}
async def _tree_character_id(tree_id: str) -> str:
from app.models.mirror_tree import MirrorTree
async with AsyncSessionLocal() as session:
tree = await session.get(MirrorTree, tree_id)
return tree.character_id if tree else ""