"""镜树域服务:树/节点生命周期、另答、续思/续生、编辑分叉、采用、归真、引文、移栽。 核心不变量(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