- 天工阁域 domains/tiangong:backstage_tasks(委托包+验收标准+结果证据);有限分解(parent 链)与受控并行(各自后台 Run);子任务全终态级联父任务进入待验收;按证据 accept/reject - kernel.start_backstage_run(kind=background_task):loop 后台分支不占前台席位、与前台对话并行;断言放宽容纳无人物功能任务;结果只经回调回呈任务——无身份写入路径 - 叩心域 domains/kouxin:knocking_candidates(event/time_anchor/manual 触发 + 草稿 + 裁决快照);四约束裁决(总开关→静默窗口【本地时区判定】→24h 频率→预算),dispatch 未过 429 且快照留痕;候选默认不自动发送;expire_stale 过期推进不触碰缘契 - 辰正 domains/zhengchen:temporal_context() 复用 time_sense 计算核(快照/农历/季节),产出【时态感知】投影段接入镜树生成 prompt(客观时间语境,不附情绪臆测——守一纪律) - 退役:modules/goal_orchestrator|planner|perception|big_cycle、application/planning、services/governance|goal_persist、candidates 表链(imports 候选抽取 API 面)、goals/plans/agent_roster/perception_channels/big_cycles/旧候选表(迁移 009);前端 admin goals/plans/agents/perception/world 页与六组件;governance 只留 GenerationPreferences;admin 治理面收敛为司命+百工两枢;pivots 健康以域表计数重建 - 验收(test_m8_tiangong 五用例):后台任务无身份写入(记忆/魂籍/缘契零新增);后台与前台并行;分解级联;叩心四约束逐项;不回应不降低关系(缘契维度/状态/版本零变化) - 四套件:后端 345、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 009 净库干净
1011 lines
39 KiB
Python
1011 lines
39 KiB
Python
"""镜树域服务:树/节点生命周期、另答、续思/续生、编辑分叉、采用、归真、引文、移栽。
|
||
|
||
核心不变量(mirror-tree PRD §4,测试钉死):
|
||
1. 重新生成永不覆盖旧候答(新行插入);
|
||
2. 后代绑定用户实际采用的候答(问节默认挂工作入口);
|
||
3. 采用 ≠ 归真:working_entry 变更不触碰 canonizations;
|
||
4. 已归真经历不因切换候答回滚(canonization 不可变);
|
||
5. 编辑已发送内容 = 新版本分叉,原文保留;
|
||
6. 归真路径与既有主脉分叉时,后到操作 409(HJ-1102),不静默替换。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.errors import AppError
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.domains import one_qi
|
||
from app.kernel import Kernel
|
||
from app.models.character import Character
|
||
from app.models.mirror_tree import (
|
||
Canonization,
|
||
Citation,
|
||
MirrorTree,
|
||
TreeNode,
|
||
TreeTransplant,
|
||
)
|
||
|
||
REALMS = ("reality", "persistent_world", "simulation")
|
||
SCOPES = REALMS # canonical_scope 与 realm 同值族(契约词典 §9.4/§12)
|
||
|
||
|
||
# ── 所有权与取数 ──────────────────────────────────────────────
|
||
|
||
|
||
async def _owned_tree(tree_id: str, owner_scope: str) -> MirrorTree:
|
||
async with AsyncSessionLocal() as session:
|
||
tree = await session.get(MirrorTree, tree_id)
|
||
if tree is not None:
|
||
character = await session.get(Character, tree.character_id)
|
||
if tree is None or character is None or character.owner_scope != owner_scope:
|
||
raise AppError("not_found", "镜树不存在。")
|
||
return tree
|
||
|
||
|
||
async def _node_in_tree(session: AsyncSession, node_id: str, tree_id: str) -> TreeNode:
|
||
node = await session.get(TreeNode, node_id)
|
||
if node is None or node.tree_id != tree_id:
|
||
raise AppError("not_found", "节点不存在或不属于该树。")
|
||
return node
|
||
|
||
|
||
async def _get_node(node_id: str) -> TreeNode:
|
||
async with AsyncSessionLocal() as session:
|
||
node = await session.get(TreeNode, node_id)
|
||
if node is None:
|
||
raise AppError("not_found", "节点不存在。")
|
||
return node
|
||
|
||
|
||
def node_view(node: TreeNode, citations: list[Citation] | None = None) -> dict:
|
||
return {
|
||
"id": node.id,
|
||
"tree_id": node.tree_id,
|
||
"parent_node_id": node.parent_node_id,
|
||
"node_type": node.node_type,
|
||
"status": node.status,
|
||
"author": node.author,
|
||
"content": node.content,
|
||
"realm": node.realm,
|
||
"generation_meta": json.loads(node.generation_meta_json or "{}"),
|
||
"supersedes_node_id": node.supersedes_node_id,
|
||
"citations": [
|
||
{
|
||
"id": c.id,
|
||
"target_type": c.target_type,
|
||
"snapshot_digest": c.snapshot[:64],
|
||
"source_id": c.source_id,
|
||
"source_version": c.source_version,
|
||
}
|
||
for c in (citations or [])
|
||
],
|
||
"created_at": node.created_at.isoformat(),
|
||
}
|
||
|
||
|
||
async def _node_citations(session: AsyncSession, node_id: str) -> list[Citation]:
|
||
result = await session.execute(
|
||
select(Citation).where(Citation.node_id == node_id).order_by(Citation.created_at)
|
||
)
|
||
return list(result.scalars().all())
|
||
|
||
|
||
# ── 树 ──────────────────────────────────────────────
|
||
|
||
|
||
async def create_tree(*, character_id: str, owner_scope: str, title: str, default_realm: str = "reality") -> MirrorTree:
|
||
if default_realm not in REALMS:
|
||
raise AppError("invalid_request", "界域取值不合法。")
|
||
tree = MirrorTree(character_id=character_id, title=title, default_realm=default_realm)
|
||
async with AsyncSessionLocal() as session:
|
||
character = await session.get(Character, character_id)
|
||
if character is None or character.owner_scope != owner_scope:
|
||
raise AppError("not_found", "人物不存在。")
|
||
session.add(tree)
|
||
await session.commit()
|
||
await session.refresh(tree)
|
||
await one_qi.emit(
|
||
"tree.created",
|
||
character_id=character_id,
|
||
payload={"tree_id": tree.id, "title": title},
|
||
source="mirror_tree",
|
||
)
|
||
return tree
|
||
|
||
|
||
async def list_trees(owner_scope: str, character_id: str | None = None) -> list[MirrorTree]:
|
||
async with AsyncSessionLocal() as session:
|
||
character_ids = [
|
||
c.id
|
||
for c in (
|
||
await session.execute(
|
||
select(Character.id).where(Character.owner_scope == owner_scope)
|
||
)
|
||
).scalars()
|
||
]
|
||
if not character_ids:
|
||
return []
|
||
stmt = select(MirrorTree).where(
|
||
MirrorTree.character_id.in_(character_ids), MirrorTree.status == "active"
|
||
)
|
||
if character_id is not None:
|
||
stmt = stmt.where(MirrorTree.character_id == character_id)
|
||
stmt = stmt.order_by(MirrorTree.updated_at.desc())
|
||
return list((await session.execute(stmt)).scalars().all())
|
||
|
||
|
||
def tree_view(tree: MirrorTree) -> dict:
|
||
return {
|
||
"id": tree.id,
|
||
"character_id": tree.character_id,
|
||
"title": tree.title,
|
||
"status": tree.status,
|
||
"default_realm": tree.default_realm,
|
||
"working_entry_node_id": tree.working_entry_node_id,
|
||
"transplanted_from": json.loads(tree.transplanted_from_json or "{}"),
|
||
"created_at": tree.created_at.isoformat(),
|
||
}
|
||
|
||
|
||
async def get_tree_view(tree_id: str, owner_scope: str) -> dict:
|
||
"""树详情视图(含权限校验)。"""
|
||
async with AsyncSessionLocal() as session:
|
||
tree = await session.get(MirrorTree, tree_id)
|
||
if tree is not None:
|
||
character = await session.get(Character, tree.character_id)
|
||
if tree is None or character is None or character.owner_scope != owner_scope:
|
||
raise AppError("not_found", "镜树不存在。")
|
||
return tree_view(tree)
|
||
|
||
|
||
async def rename_tree(tree_id: str, owner_scope: str, title: str) -> dict:
|
||
await _owned_tree(tree_id, owner_scope)
|
||
async with AsyncSessionLocal() as session:
|
||
row = await session.get(MirrorTree, tree_id)
|
||
row.title = title
|
||
view = tree_view(row)
|
||
await session.commit()
|
||
return view
|
||
|
||
|
||
async def archive_tree(tree_id: str, owner_scope: str) -> dict:
|
||
await _owned_tree(tree_id, owner_scope)
|
||
async with AsyncSessionLocal() as session:
|
||
row = await session.get(MirrorTree, tree_id)
|
||
row.status = "archived"
|
||
view = tree_view(row) # 会话内取视图,避免提交后属性过期
|
||
await session.commit()
|
||
return view
|
||
|
||
|
||
# ── 路径与图 ──────────────────────────────────────────────
|
||
|
||
|
||
async def ancestor_chain(node_id: str) -> list[TreeNode]:
|
||
"""root→node 的祖先链(含自身)。环与超深保护:上限 10_000 层。"""
|
||
chain: list[TreeNode] = []
|
||
current_id: str | None = node_id
|
||
async with AsyncSessionLocal() as session:
|
||
for _ in range(10_000):
|
||
if current_id is None:
|
||
break
|
||
node = await session.get(TreeNode, current_id)
|
||
if node is None:
|
||
raise AppError("not_found", f"祖先链断裂:{current_id}")
|
||
chain.append(node)
|
||
current_id = node.parent_node_id
|
||
chain.reverse()
|
||
return chain
|
||
|
||
|
||
async def path_view(tree_id: str, owner_scope: str, entry_node_id: str | None) -> dict:
|
||
tree = await _owned_tree(tree_id, owner_scope)
|
||
entry = entry_node_id or tree.working_entry_node_id
|
||
async with AsyncSessionLocal() as session:
|
||
if entry is None:
|
||
root_questions = (
|
||
await session.execute(
|
||
select(TreeNode).where(
|
||
TreeNode.tree_id == tree_id, TreeNode.parent_node_id.is_(None)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
nodes = [
|
||
node_view(n, await _node_citations(session, n.id))
|
||
for n in sorted(root_questions, key=lambda n: n.created_at)
|
||
]
|
||
return {"tree_id": tree_id, "entry_node_id": None, "nodes": nodes}
|
||
node = await _node_in_tree(session, entry, tree_id)
|
||
chain = [node]
|
||
cursor = node.parent_node_id
|
||
for _ in range(10_000):
|
||
if cursor is None:
|
||
break
|
||
parent = await session.get(TreeNode, cursor)
|
||
chain.append(parent)
|
||
cursor = parent.parent_node_id
|
||
chain.reverse()
|
||
return {
|
||
"tree_id": tree_id,
|
||
"entry_node_id": entry,
|
||
"nodes": [node_view(n, await _node_citations(session, n.id)) for n in chain],
|
||
}
|
||
|
||
|
||
async def local_graph(tree_id: str, owner_scope: str, node_id: str | None, depth: int = 2) -> dict:
|
||
"""局部树图:入口的祖先(全链)+ 兄弟 + 向下 depth 层子枝(服务定位,不替代阅读)。"""
|
||
tree = await _owned_tree(tree_id, owner_scope)
|
||
entry = node_id or tree.working_entry_node_id
|
||
async with AsyncSessionLocal() as session:
|
||
nodes_out: dict[str, dict] = {}
|
||
if entry is not None:
|
||
node = await _node_in_tree(session, entry, tree_id)
|
||
chain = [node]
|
||
cursor = node.parent_node_id
|
||
while cursor is not None:
|
||
parent = await session.get(TreeNode, cursor)
|
||
chain.append(parent)
|
||
cursor = parent.parent_node_id
|
||
for n in chain:
|
||
nodes_out[n.id] = _graph_node(n)
|
||
# 兄弟(同父的其他子节点)
|
||
for n in chain:
|
||
if n.parent_node_id is not None:
|
||
stmt = select(TreeNode).where(
|
||
TreeNode.tree_id == tree_id, TreeNode.parent_node_id == n.parent_node_id
|
||
)
|
||
else:
|
||
stmt = select(TreeNode).where(
|
||
TreeNode.tree_id == tree_id, TreeNode.parent_node_id.is_(None)
|
||
)
|
||
siblings = (await session.execute(stmt)).scalars().all()
|
||
for s in siblings:
|
||
if s.id not in nodes_out:
|
||
nodes_out[s.id] = _graph_node(s)
|
||
# 向下展开
|
||
frontier: list[str] = [entry] if entry is not None else []
|
||
for _ in range(max(depth, 0)):
|
||
next_frontier: list[str] = []
|
||
for fid in frontier:
|
||
children = (
|
||
await session.execute(
|
||
select(TreeNode).where(TreeNode.parent_node_id == fid)
|
||
)
|
||
).scalars().all()
|
||
for child in children:
|
||
if child.id not in nodes_out:
|
||
nodes_out[child.id] = _graph_node(child)
|
||
next_frontier.append(child.id)
|
||
frontier = next_frontier
|
||
# 无入口(新树):给根问节
|
||
if entry is None:
|
||
roots = (
|
||
await session.execute(
|
||
select(TreeNode).where(
|
||
TreeNode.tree_id == tree_id, TreeNode.parent_node_id.is_(None)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
for r in roots:
|
||
nodes_out[r.id] = _graph_node(r)
|
||
return {"tree_id": tree_id, "focus_node_id": entry, "nodes": list(nodes_out.values())}
|
||
|
||
|
||
def _graph_node(node: TreeNode) -> dict:
|
||
return {
|
||
"id": node.id,
|
||
"parent_node_id": node.parent_node_id,
|
||
"node_type": node.node_type,
|
||
"status": node.status,
|
||
"realm": node.realm,
|
||
"branch_mark": node.node_type == "answer",
|
||
"content_preview": node.content[:80],
|
||
}
|
||
|
||
|
||
# ── 问节与生成 ──────────────────────────────────────────────
|
||
|
||
|
||
async def create_question(
|
||
*,
|
||
tree_id: str,
|
||
owner_scope: str,
|
||
content: str,
|
||
realm: str | None = None,
|
||
parent_answer_id: str | None = None,
|
||
citations: list[dict] | None = None,
|
||
) -> TreeNode:
|
||
"""新问节:默认挂当前工作入口(不变量 2——绑定用户实际采用的候答)。"""
|
||
tree = await _owned_tree(tree_id, owner_scope)
|
||
parent_id = parent_answer_id or tree.working_entry_node_id
|
||
async with AsyncSessionLocal() as session:
|
||
if parent_id is not None:
|
||
parent = await _node_in_tree(session, parent_id, tree_id)
|
||
if parent.node_type == "question":
|
||
raise AppError("invalid_request", "问节必须挂在候答/续思之下(或作为根问节)。")
|
||
effective_realm = realm or tree.default_realm
|
||
if effective_realm not in REALMS:
|
||
raise AppError("invalid_request", "界域取值不合法。")
|
||
node = TreeNode(
|
||
tree_id=tree_id,
|
||
parent_node_id=parent_id,
|
||
node_type="question",
|
||
status="solidified",
|
||
author="user",
|
||
content=content,
|
||
realm=effective_realm,
|
||
)
|
||
session.add(node)
|
||
await session.flush()
|
||
node_id = node.id
|
||
if citations:
|
||
for c in citations:
|
||
session.add(_citation_row(node_id, c))
|
||
# 界域切换可见(PRD §8):与父节点(或树默认界域,根问节时)不同即留痕入境;
|
||
# 事件在提交后发射,避免事务内跨连接写(SQLite 锁冲突)
|
||
baseline_realm = tree.default_realm
|
||
if parent_id is not None:
|
||
baseline_realm = (await session.get(TreeNode, parent_id)).realm
|
||
realm_event = (
|
||
{"tree_id": tree_id, "node_id": node_id, "realm": effective_realm}
|
||
if effective_realm != baseline_realm
|
||
else None
|
||
)
|
||
await session.commit()
|
||
if realm_event is not None:
|
||
await one_qi.emit(
|
||
"realm.entered",
|
||
character_id=tree.character_id,
|
||
payload=realm_event,
|
||
source="mirror_tree",
|
||
)
|
||
await one_qi.emit(
|
||
"tree.node_created",
|
||
character_id=tree.character_id,
|
||
payload={"tree_id": tree_id, "node_id": node_id, "node_type": "question"},
|
||
source="mirror_tree",
|
||
)
|
||
return await _get_node(node_id)
|
||
|
||
|
||
def _citation_row(node_id: str, data: dict) -> Citation:
|
||
return Citation(
|
||
node_id=node_id,
|
||
target_type=data.get("target_type", "text_range"),
|
||
locator_json=json.dumps(data.get("locator", {}), ensure_ascii=False),
|
||
snapshot=data.get("snapshot", ""),
|
||
source_id=data.get("source_id", ""),
|
||
source_version=int(data.get("source_version", 1)),
|
||
)
|
||
|
||
|
||
async def build_generation_prompt(
|
||
question: TreeNode,
|
||
instructions: str | None,
|
||
*,
|
||
char_name: str | None = None,
|
||
user_name: str | None = None,
|
||
) -> str:
|
||
"""按祖先链组装投影 prompt 并做符文展开({{char}}/{{user}}/…)。
|
||
|
||
M2 形态:祖先路径 + 问题 + 临时指引;获准记忆与显式引文随 M3 息壤接入。
|
||
"""
|
||
from app.modules.macros.builtins import build_default_registry
|
||
from app.modules.macros.engine import render
|
||
from app.modules.macros.types import MacroContext
|
||
|
||
ctx = MacroContext(char_name=char_name, user_name=user_name)
|
||
registry = build_default_registry()
|
||
|
||
def expand(text: str) -> str:
|
||
return render(text, ctx, registry=registry)
|
||
|
||
chain = await ancestor_chain(question.id)
|
||
lines: list[str] = []
|
||
for n in chain:
|
||
if n.id == question.id:
|
||
continue
|
||
speaker = "用户" if n.node_type == "question" else "人物"
|
||
lines.append(f"{speaker}:{expand(n.content)}")
|
||
transcript = "\n".join(lines)
|
||
parts = []
|
||
# 获准长期记忆(息壤 M3):召回透明——注入来源可展开,旁枝不入(默认 active 级)
|
||
if question.tree_id:
|
||
from app.domains.xirang import service as xirang_service
|
||
|
||
recalled = await xirang_service.recall_for_generation(
|
||
await _tree_character_id(question.tree_id),
|
||
f"{transcript}\n{question.content}",
|
||
realm=question.realm,
|
||
)
|
||
if recalled:
|
||
memory_lines = [f"- {m.content}" for m in recalled]
|
||
parts.append(f"【获准记忆(来源可查)】\n" + "\n".join(memory_lines))
|
||
# 世谱激活(河图):关键词命中扫描近期文本,常驻条目恒注入;预算内按 insertion_order
|
||
tree_character_id = await _tree_character_id(question.tree_id)
|
||
activated = await _activate_lorebook(
|
||
tree_character_id,
|
||
scan_text=f"{transcript[-600:]}\n{question.content}",
|
||
expand=(lambda t: render(t, ctx, registry=registry)),
|
||
)
|
||
if activated:
|
||
parts.append("【世界认知(世谱命中)】\n" + "\n".join(f"- {line}" for line in activated))
|
||
# 辰正时态感知(M8):客观时间语境注入投影(不附情绪臆测)
|
||
from app.domains.zhengchen import temporal_context
|
||
|
||
parts.append(temporal_context())
|
||
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,
|
||
owner_id=owner_scope,
|
||
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,
|
||
owner_id=owner_scope,
|
||
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()
|
||
|
||
# 归真后置按三界分流(core-domains.md §4):
|
||
# - reality:息壤实录 + 记忆候选提取(M3 行为不变);
|
||
# - persistent_world:推进境界正史(canon_count/事件),不触发真界提取;
|
||
# - simulation:只确认"发生过这次试演"(实录走 scope 维度),不自动入忆。
|
||
if scope == "reality":
|
||
from app.domains.xirang import service as xirang_service
|
||
|
||
await xirang_service.on_canonized(record_id)
|
||
elif scope == "persistent_world":
|
||
from app.domains.hetu import service as hetu_service
|
||
from app.domains.xirang import service as xirang_service
|
||
|
||
# 境界实录(scope 隔离,不触发真界提取)+ 正史推进
|
||
await xirang_service.record_factual_scoped(record_id)
|
||
await hetu_service.on_world_canonized(world_id or "")
|
||
else:
|
||
from app.domains.xirang import service as xirang_service
|
||
|
||
await xirang_service.record_factual_scoped(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)
|
||
# 正影提示(引文→记忆):target_type=memory 且被引记忆已 superseded 时给出后继版本
|
||
from app.models.xirang import Memory
|
||
|
||
memory_ids = {
|
||
c.source_id for c in own if c.target_type == "memory" and c.source_id
|
||
}
|
||
successors: dict[str, str] = {}
|
||
if memory_ids:
|
||
rows = (
|
||
await session.execute(
|
||
select(Memory).where(Memory.id.in_(memory_ids))
|
||
)
|
||
).scalars().all()
|
||
superseded = {m.id for m in rows if m.status == "superseded"}
|
||
if superseded:
|
||
newer = (
|
||
await session.execute(
|
||
select(Memory).where(Memory.prev_version_id.in_(superseded))
|
||
)
|
||
).scalars().all()
|
||
successors = {m.prev_version_id: m.id for m in newer}
|
||
out = []
|
||
for c in own:
|
||
entry = {
|
||
"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,
|
||
}
|
||
successor = successors.get(c.source_id)
|
||
if successor:
|
||
entry["memory_superseded"] = True
|
||
entry["successor_memory_id"] = successor
|
||
out.append(entry)
|
||
return out
|
||
|
||
|
||
# ── 移栽 ──────────────────────────────────────────────
|
||
|
||
|
||
async def transplant(
|
||
*, tree_id: str, owner_scope: str, node_id: str, new_title: str
|
||
) -> dict:
|
||
"""移栽:把分支的祖先路径复制为新树(全新节点 ID),保留来源谱系;两树只引用不合流。"""
|
||
tree = await _owned_tree(tree_id, owner_scope)
|
||
branch = await _get_node(node_id)
|
||
if branch.tree_id != tree_id:
|
||
raise AppError("not_found", "节点不属于该树。")
|
||
if branch.node_type == "question":
|
||
raise AppError("invalid_request", "移栽端点应为候答或续思分支。")
|
||
|
||
chain = await ancestor_chain(node_id) # root→branch
|
||
async with AsyncSessionLocal() as session:
|
||
new_tree = MirrorTree(
|
||
character_id=tree.character_id,
|
||
title=new_title,
|
||
default_realm=tree.default_realm,
|
||
transplanted_from_json=json.dumps(
|
||
{"source_tree_id": tree_id, "source_node_id": node_id}
|
||
),
|
||
)
|
||
session.add(new_tree)
|
||
await session.flush()
|
||
|
||
id_map: dict[str, str] = {}
|
||
for old_node in chain:
|
||
new_node = TreeNode(
|
||
tree_id=new_tree.id,
|
||
parent_node_id=id_map.get(old_node.parent_node_id or ""),
|
||
node_type=old_node.node_type,
|
||
status="solidified",
|
||
author=old_node.author,
|
||
content=old_node.content,
|
||
realm=old_node.realm,
|
||
generation_meta_json=json.dumps(
|
||
{"transplanted_from_node": old_node.id}, ensure_ascii=False
|
||
),
|
||
)
|
||
session.add(new_node)
|
||
await session.flush()
|
||
id_map[old_node.id] = new_node.id
|
||
# 引文随移栽复制(快照保留)
|
||
for c in await _node_citations(session, old_node.id):
|
||
session.add(
|
||
Citation(
|
||
node_id=new_node.id,
|
||
target_type=c.target_type,
|
||
locator_json=c.locator_json,
|
||
snapshot=c.snapshot,
|
||
source_id=c.source_id,
|
||
source_version=c.source_version,
|
||
)
|
||
)
|
||
|
||
new_tree.working_entry_node_id = id_map[node_id]
|
||
session.add(
|
||
TreeTransplant(
|
||
source_tree_id=tree_id, source_node_id=node_id, new_tree_id=new_tree.id
|
||
)
|
||
)
|
||
await session.flush()
|
||
new_tree_id, new_entry = new_tree.id, new_tree.working_entry_node_id
|
||
await session.commit()
|
||
|
||
await one_qi.emit(
|
||
"tree.transplanted",
|
||
character_id=tree.character_id,
|
||
payload={
|
||
"source_tree_id": tree_id,
|
||
"source_node_id": node_id,
|
||
"new_tree_id": new_tree_id,
|
||
},
|
||
source="mirror_tree",
|
||
)
|
||
return {"new_tree_id": new_tree_id, "working_entry_node_id": new_entry}
|
||
|
||
|
||
async def _tree_character_id(tree_id: str) -> str:
|
||
from app.models.mirror_tree import MirrorTree
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
tree = await session.get(MirrorTree, tree_id)
|
||
return tree.character_id if tree else ""
|
||
|
||
|
||
async def _activate_lorebook(
|
||
character_id: str,
|
||
*,
|
||
scan_text: str,
|
||
expand=None, # noqa: ANN001 — 符文展开函数(char/user 名),无上下文时不展开
|
||
char_budget: int = 600,
|
||
) -> list[str]:
|
||
"""世谱激活管线(mirror-tree PRD 借 ST 范式):关键词命中 + 常驻,预算内按插入序。
|
||
|
||
扫描范围为近期文本(祖先链尾部 + 当前问题);条目内容经符文展开后注入。
|
||
次级键逻辑、递归扫描与时序效果(sticky/cooldown)随 M5 循环轮次状态接入。
|
||
"""
|
||
from sqlalchemy import select
|
||
|
||
from app.models.lorebook import Lorebook, LorebookEntry
|
||
|
||
text = "".join(scan_text.lower().split())
|
||
async with AsyncSessionLocal() as session:
|
||
books = (
|
||
await session.execute(
|
||
select(Lorebook.id).where(
|
||
Lorebook.character_id == character_id, Lorebook.status == "active"
|
||
)
|
||
)
|
||
).scalars().all()
|
||
if not books:
|
||
return []
|
||
entries = (
|
||
(
|
||
await session.execute(
|
||
select(LorebookEntry)
|
||
.where(LorebookEntry.lorebook_id.in_(tuple(books)), LorebookEntry.enabled.is_(True))
|
||
.order_by(LorebookEntry.insertion_order)
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
picked: list[str] = []
|
||
used = 0
|
||
for entry in entries:
|
||
keys = [k.lower() for k in json.loads(entry.keys_json or "[]") if k]
|
||
hit = entry.constant or any("".join(k.split()) in text for k in keys)
|
||
if not hit:
|
||
continue
|
||
content = entry.content if expand is None else expand(entry.content)
|
||
if used + len(content) > char_budget:
|
||
continue
|
||
picked.append(content)
|
||
used += len(content)
|
||
return picked
|