From e6a3b7121f5a172ae46cadabcde09f6aa911b676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Fri, 21 Aug 2026 23:00:07 +0800 Subject: [PATCH] =?UTF-8?q?M2a=EF=BC=9A=E9=95=9C=E6=A0=91=E5=9F=9F?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=A0=B8=E5=BF=83=E2=80=94=E2=80=94=E6=A0=91?= =?UTF-8?q?/=E8=8A=82=E7=82=B9/=E5=BC=95=E6=96=87/=E5=BD=92=E7=9C=9F/?= =?UTF-8?q?=E7=A7=BB=E6=A0=BD=E5=85=A8=E9=87=8F=20API=20=E4=B8=8E=20Kernel?= =?UTF-8?q?=20=E5=AE=8C=E6=88=90=E5=9B=9E=E8=B0=83=EF=BC=88=E8=8A=BD?= =?UTF-8?q?=E4=BD=93=E5=9B=BA=E5=8C=96/=E6=AE=8B=E7=AD=94=E5=B0=81?= =?UTF-8?q?=E5=AD=98=EF=BC=89=EF=BC=8C8=20=E7=94=A8=E4=BE=8B=E9=92=89?= =?UTF-8?q?=E6=AD=BB=20PRD=20=E5=9C=BA=E6=99=AF=201/2/3/5/6/7/8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...域_主题树_节点_引文锚_归真记录_移栽谱系.py | 125 +++ backend/app/api/router.py | 3 + backend/app/api/v1/endpoints/mirror_trees.py | 240 ++++++ backend/app/core/ids.py | 20 + backend/app/domains/jingsheng/seat.py | 23 +- backend/app/domains/jingsheng/service.py | 27 +- backend/app/domains/mirror_tree/__init__.py | 47 + backend/app/domains/mirror_tree/service.py | 815 ++++++++++++++++++ backend/app/domains/one_qi.py | 42 + backend/app/kernel/__init__.py | 87 +- backend/app/kernel/loop.py | 114 ++- backend/app/models/__init__.py | 7 + backend/app/models/mirror_tree.py | 132 +++ backend/tests/test_mirror_tree.py | 384 +++++++++ docs/operations/capability-matrix.md | 2 +- docs/operations/rewrite-plan.md | 8 +- 16 files changed, 2002 insertions(+), 74 deletions(-) create mode 100644 backend/alembic/versions/002_镜树域_主题树_节点_引文锚_归真记录_移栽谱系.py create mode 100644 backend/app/api/v1/endpoints/mirror_trees.py create mode 100644 backend/app/domains/mirror_tree/__init__.py create mode 100644 backend/app/domains/mirror_tree/service.py create mode 100644 backend/app/domains/one_qi.py create mode 100644 backend/app/models/mirror_tree.py create mode 100644 backend/tests/test_mirror_tree.py diff --git a/backend/alembic/versions/002_镜树域_主题树_节点_引文锚_归真记录_移栽谱系.py b/backend/alembic/versions/002_镜树域_主题树_节点_引文锚_归真记录_移栽谱系.py new file mode 100644 index 0000000..a375025 --- /dev/null +++ b/backend/alembic/versions/002_镜树域_主题树_节点_引文锚_归真记录_移栽谱系.py @@ -0,0 +1,125 @@ +"""镜树域:主题树、节点、引文锚、归真记录、移栽谱系 + +Revision ID: 002 +Revises: 001 +Create Date: 2026-08-21 22:49:03.731149 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '002' +down_revision: Union[str, Sequence[str], None] = '001' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tree_transplants', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('source_tree_id', sa.String(length=80), nullable=False), + sa.Column('source_node_id', sa.String(length=80), nullable=False), + sa.Column('new_tree_id', sa.String(length=80), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_tree_transplants_new_tree_id'), 'tree_transplants', ['new_tree_id'], unique=False) + op.create_index(op.f('ix_tree_transplants_source_tree_id'), 'tree_transplants', ['source_tree_id'], unique=False) + op.create_table('mirror_trees', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('character_id', sa.String(length=80), nullable=False), + sa.Column('title', sa.String(length=300), nullable=False), + sa.Column('status', sa.String(length=40), nullable=False), + sa.Column('default_realm', sa.String(length=40), nullable=False), + sa.Column('working_entry_node_id', sa.String(length=80), nullable=True), + sa.Column('transplanted_from_json', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_mirror_trees_character_id'), 'mirror_trees', ['character_id'], unique=False) + op.create_index(op.f('ix_mirror_trees_status'), 'mirror_trees', ['status'], unique=False) + op.create_table('canonizations', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('tree_id', sa.String(length=80), nullable=False), + sa.Column('character_id', sa.String(length=80), nullable=False), + sa.Column('scope', sa.String(length=40), nullable=False), + sa.Column('world_id', sa.String(length=80), nullable=True), + sa.Column('endpoint_node_id', sa.String(length=80), nullable=False), + sa.Column('path_nodes_json', sa.Text(), nullable=False), + sa.Column('confirmed_by', sa.String(length=80), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tree_id'], ['mirror_trees.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_canonizations_character_id'), 'canonizations', ['character_id'], unique=False) + op.create_index(op.f('ix_canonizations_endpoint_node_id'), 'canonizations', ['endpoint_node_id'], unique=False) + op.create_index(op.f('ix_canonizations_scope'), 'canonizations', ['scope'], unique=False) + op.create_index(op.f('ix_canonizations_tree_id'), 'canonizations', ['tree_id'], unique=False) + op.create_table('tree_nodes', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('tree_id', sa.String(length=80), nullable=False), + sa.Column('parent_node_id', sa.String(length=80), nullable=True), + sa.Column('node_type', sa.String(length=40), nullable=False), + sa.Column('status', sa.String(length=40), nullable=False), + sa.Column('author', sa.String(length=40), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('realm', sa.String(length=40), nullable=False), + sa.Column('generation_meta_json', sa.Text(), nullable=False), + sa.Column('supersedes_node_id', sa.String(length=80), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['parent_node_id'], ['tree_nodes.id'], ), + sa.ForeignKeyConstraint(['tree_id'], ['mirror_trees.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tree_id', 'supersedes_node_id', name='uq_tree_node_supersedes') + ) + op.create_index(op.f('ix_tree_nodes_node_type'), 'tree_nodes', ['node_type'], unique=False) + op.create_index(op.f('ix_tree_nodes_parent_node_id'), 'tree_nodes', ['parent_node_id'], unique=False) + op.create_index(op.f('ix_tree_nodes_status'), 'tree_nodes', ['status'], unique=False) + op.create_index(op.f('ix_tree_nodes_tree_id'), 'tree_nodes', ['tree_id'], unique=False) + op.create_table('citations', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('node_id', sa.String(length=80), nullable=False), + sa.Column('target_type', sa.String(length=40), nullable=False), + sa.Column('locator_json', sa.Text(), nullable=False), + sa.Column('snapshot', sa.Text(), nullable=False), + sa.Column('source_id', sa.String(length=200), nullable=False), + sa.Column('source_version', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['node_id'], ['tree_nodes.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_citations_node_id'), 'citations', ['node_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_citations_node_id'), table_name='citations') + op.drop_table('citations') + op.drop_index(op.f('ix_tree_nodes_tree_id'), table_name='tree_nodes') + op.drop_index(op.f('ix_tree_nodes_status'), table_name='tree_nodes') + op.drop_index(op.f('ix_tree_nodes_parent_node_id'), table_name='tree_nodes') + op.drop_index(op.f('ix_tree_nodes_node_type'), table_name='tree_nodes') + op.drop_table('tree_nodes') + op.drop_index(op.f('ix_canonizations_tree_id'), table_name='canonizations') + op.drop_index(op.f('ix_canonizations_scope'), table_name='canonizations') + op.drop_index(op.f('ix_canonizations_endpoint_node_id'), table_name='canonizations') + op.drop_index(op.f('ix_canonizations_character_id'), table_name='canonizations') + op.drop_table('canonizations') + op.drop_index(op.f('ix_mirror_trees_status'), table_name='mirror_trees') + op.drop_index(op.f('ix_mirror_trees_character_id'), table_name='mirror_trees') + op.drop_table('mirror_trees') + op.drop_index(op.f('ix_tree_transplants_source_tree_id'), table_name='tree_transplants') + op.drop_index(op.f('ix_tree_transplants_new_tree_id'), table_name='tree_transplants') + op.drop_table('tree_transplants') + # ### end Alembic commands ### diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 3a2db15..758b222 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -21,6 +21,7 @@ from app.api.v1.endpoints import ( jingsheng_characters, kernel_runs, lorebooks, + mirror_trees, mcp, memory, personality, @@ -44,6 +45,8 @@ api_router.include_router(runs.router) api_router.include_router(kernel_runs.router) api_router.include_router(embryo_drafts.router) api_router.include_router(jingsheng_characters.router) +api_router.include_router(mirror_trees.router) +api_router.include_router(mirror_trees.node_router) api_router.include_router(memory.router) api_router.include_router(lorebooks.router) api_router.include_router(personality.router) diff --git a/backend/app/api/v1/endpoints/mirror_trees.py b/backend/app/api/v1/endpoints/mirror_trees.py new file mode 100644 index 0000000..07d184d --- /dev/null +++ b/backend/app/api/v1/endpoints/mirror_trees.py @@ -0,0 +1,240 @@ +"""镜树 API(api-contracts.md §3.3)。树级操作挂 /trees,节点级挂 /nodes 与 /citations。""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request +from pydantic import BaseModel, Field + +from app.api.v1.deps import get_current_user +from app.core.errors import AppError +from app.domains import mirror_tree +from app.kernel import Kernel +from app.schemas.auth import CurrentUser + +router = APIRouter(prefix="/trees", tags=["mirror-tree"]) +node_router = APIRouter(prefix="/nodes", tags=["mirror-tree"]) + + +class TreeCreate(BaseModel): + character_id: str + title: str = Field(min_length=1, max_length=300) + default_realm: str = "reality" + + +class TreePatch(BaseModel): + title: str | None = Field(default=None, min_length=1, max_length=300) + status: str | None = Field(default=None, description="active / archived") + + +class QuestionCreate(BaseModel): + content: str = Field(min_length=1, max_length=20_000) + realm: str | None = None + parent_answer_id: str | None = None + citations: list[dict] = Field(default_factory=list) + + +class AnswerRequest(BaseModel): + instructions: str | None = Field(default=None, max_length=8000) + + +class AdoptRequest(BaseModel): + node_id: str + + +class CanonizeRequest(BaseModel): + endpoint_node_id: str + canonical_scope: str + world_id: str | None = None + + +class EditForkRequest(BaseModel): + new_content: str = Field(min_length=1, max_length=20_000) + + +class TransplantRequest(BaseModel): + node_id: str + new_title: str = Field(min_length=1, max_length=300) + + +class CitationCreate(BaseModel): + target_type: str = "text_range" + locator: dict = Field(default_factory=dict) + snapshot: str = "" + source_id: str = Field(min_length=1, max_length=200) + source_version: int = 1 + + +def _get_kernel(request: Request) -> Kernel: + kernel: Kernel | None = getattr(request.app.state, "kernel", None) + if kernel is None: + raise AppError("service_unavailable", "Kernel 运行时未就绪。", status_code=503) + return kernel + + +@router.get("") +async def list_trees_api( + character_id: str | None = Query(default=None), + user: CurrentUser = Depends(get_current_user), +) -> list[dict]: + return [mirror_tree.tree_view(t) for t in await mirror_tree.list_trees(user.owner_scope, character_id)] + + +@router.post("", status_code=201) +async def create_tree_api( + payload: TreeCreate, user: CurrentUser = Depends(get_current_user) +) -> dict: + tree = await mirror_tree.create_tree( + character_id=payload.character_id, owner_scope=user.owner_scope, title=payload.title, + default_realm=payload.default_realm, + ) + return mirror_tree.tree_view(tree) + + +@router.get("/{tree_id}") +async def get_tree_api(tree_id: str, user: CurrentUser = Depends(get_current_user)) -> dict: + return await mirror_tree.get_tree_view(tree_id, user.owner_scope) + + +@router.patch("/{tree_id}") +async def patch_tree_api( + tree_id: str, payload: TreePatch, user: CurrentUser = Depends(get_current_user) +) -> dict: + if payload.status == "archived": + return await mirror_tree.archive_tree(tree_id, user.owner_scope) + if payload.title is not None: + return await mirror_tree.rename_tree(tree_id, user.owner_scope, payload.title) + return await mirror_tree.get_tree_view(tree_id, user.owner_scope) + + +@router.get("/{tree_id}/path") +async def tree_path_api( + tree_id: str, + entry: str | None = Query(default=None), + user: CurrentUser = Depends(get_current_user), +) -> dict: + return await mirror_tree.path_view(tree_id, user.owner_scope, entry) + + +@router.get("/{tree_id}/local-graph") +async def local_graph_api( + tree_id: str, + node: str | None = Query(default=None), + depth: int = Query(default=2, ge=0, le=6), + user: CurrentUser = Depends(get_current_user), +) -> dict: + return await mirror_tree.local_graph(tree_id, user.owner_scope, node, depth) + + +@router.post("/{tree_id}/nodes", status_code=201) +async def create_question_api( + tree_id: str, payload: QuestionCreate, user: CurrentUser = Depends(get_current_user) +) -> dict: + node = await mirror_tree.create_question( + tree_id=tree_id, + owner_scope=user.owner_scope, + content=payload.content, + realm=payload.realm, + parent_answer_id=payload.parent_answer_id, + citations=payload.citations, + ) + return mirror_tree.node_view(node) + + +@router.post("/{tree_id}/adopt") +async def adopt_api( + tree_id: str, payload: AdoptRequest, user: CurrentUser = Depends(get_current_user) +) -> dict: + return await mirror_tree.adopt(tree_id=tree_id, owner_scope=user.owner_scope, node_id=payload.node_id) + + +@router.post("/{tree_id}/canonize") +async def canonize_api( + tree_id: str, payload: CanonizeRequest, user: CurrentUser = Depends(get_current_user) +) -> dict: + return await mirror_tree.canonize( + tree_id=tree_id, + owner_scope=user.owner_scope, + endpoint_node_id=payload.endpoint_node_id, + scope=payload.canonical_scope, + world_id=payload.world_id, + ) + + +@router.post("/{tree_id}/transplant", status_code=201) +async def transplant_api( + tree_id: str, payload: TransplantRequest, user: CurrentUser = Depends(get_current_user) +) -> dict: + return await mirror_tree.transplant( + tree_id=tree_id, owner_scope=user.owner_scope, node_id=payload.node_id, + new_title=payload.new_title, + ) + + +# ── 节点级 ────────────────────────────────────────────── + + +@node_router.post("/{node_id}/answers", status_code=202) +async def spawn_answer_api( + node_id: str, + payload: AnswerRequest, + request: Request, + user: CurrentUser = Depends(get_current_user), +) -> dict: + result = await mirror_tree.spawn_answer( + question_id=node_id, + owner_scope=user.owner_scope, + kernel=_get_kernel(request), + instructions=payload.instructions, + ) + return result + + +@node_router.post("/{node_id}/continue", status_code=202) +async def continue_api( + node_id: str, + payload: AnswerRequest, + request: Request, + user: CurrentUser = Depends(get_current_user), +) -> dict: + return await mirror_tree.continue_node( + node_id=node_id, owner_scope=user.owner_scope, kernel=_get_kernel(request), + instructions=payload.instructions, + ) + + +@node_router.post("/{node_id}/resume", status_code=202) +async def resume_api( + node_id: str, + payload: AnswerRequest, + request: Request, + user: CurrentUser = Depends(get_current_user), +) -> dict: + return await mirror_tree.resume_node( + node_id=node_id, owner_scope=user.owner_scope, kernel=_get_kernel(request), + instructions=payload.instructions, + ) + + +@node_router.post("/{node_id}/edit-fork", status_code=201) +async def edit_fork_api( + node_id: str, payload: EditForkRequest, user: CurrentUser = Depends(get_current_user) +) -> dict: + return await mirror_tree.edit_fork( + node_id=node_id, owner_scope=user.owner_scope, new_content=payload.new_content + ) + + +@node_router.post("/{node_id}/citations", status_code=201) +async def create_citation_api( + node_id: str, payload: CitationCreate, user: CurrentUser = Depends(get_current_user) +) -> dict: + return await mirror_tree.create_citation( + node_id=node_id, owner_scope=user.owner_scope, data=payload.model_dump() + ) + + +@node_router.get("/{node_id}/citations") +async def list_citations_api( + node_id: str, user: CurrentUser = Depends(get_current_user) +) -> list[dict]: + return await mirror_tree.list_citations(node_id, user.owner_scope) diff --git a/backend/app/core/ids.py b/backend/app/core/ids.py index 30aa6cc..e1b549e 100644 --- a/backend/app/core/ids.py +++ b/backend/app/core/ids.py @@ -131,6 +131,26 @@ def new_identity_record_id() -> str: return new_id("identity_") +def new_mirror_tree_id() -> str: + return new_id("tree_") + + +def new_tree_node_id() -> str: + return new_id("node_") + + +def new_citation_id() -> str: + return new_id("cite_") + + +def new_canonization_id() -> str: + return new_id("canon_") + + +def new_transplant_id() -> str: + return new_id("transplant_") + + def new_role_id() -> str: return new_id("rl_") diff --git a/backend/app/domains/jingsheng/seat.py b/backend/app/domains/jingsheng/seat.py index 38c6847..a79b9ae 100644 --- a/backend/app/domains/jingsheng/seat.py +++ b/backend/app/domains/jingsheng/seat.py @@ -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", ) diff --git a/backend/app/domains/jingsheng/service.py b/backend/app/domains/jingsheng/service.py index 9476f09..1a25b0e 100644 --- a/backend/app/domains/jingsheng/service.py +++ b/backend/app/domains/jingsheng/service.py @@ -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", ) diff --git a/backend/app/domains/mirror_tree/__init__.py b/backend/app/domains/mirror_tree/__init__.py new file mode 100644 index 0000000..dfe99ea --- /dev/null +++ b/backend/app/domains/mirror_tree/__init__.py @@ -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", +] diff --git a/backend/app/domains/mirror_tree/service.py b/backend/app/domains/mirror_tree/service.py new file mode 100644 index 0000000..a2c92f8 --- /dev/null +++ b/backend/app/domains/mirror_tree/service.py @@ -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} diff --git a/backend/app/domains/one_qi.py b/backend/app/domains/one_qi.py new file mode 100644 index 0000000..7c0cc2a --- /dev/null +++ b/backend/app/domains/one_qi.py @@ -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, + ) + ) diff --git a/backend/app/kernel/__init__.py b/backend/app/kernel/__init__.py index fbafeda..6bf5e26 100644 --- a/backend/app/kernel/__init__.py +++ b/backend/app/kernel/__init__.py @@ -10,7 +10,7 @@ import asyncio from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.kernel.eventlog import KernelEventLog -from app.kernel.loop import KernelRunLoop +from app.kernel.loop import KernelRunLoop, OnRunFinal from app.kernel.seat import SeatManager from app.models.runtime import Run from app.runtime.bus import WaterfallBus @@ -47,7 +47,14 @@ class Kernel: self._tasks: set[asyncio.Task[None]] = set() async def start_foreground_run( - self, *, character_id: str, prompt: str, trace_id: str | None = None + self, + *, + character_id: str, + prompt: str, + trace_id: str | None = None, + system_contract: str | None = None, + origin: str = "kernel", + on_run_final: OnRunFinal | None = None, ) -> Run: """创建 Run 行(queued)并异步启动循环;前台席位在循环内排队获取。""" profile = resolve_profile() @@ -56,23 +63,81 @@ class Kernel: character_id=character_id, session_id=None, status="queued", + kind="foreground", goal=prompt[:300] if prompt else None, - origin="kernel", + origin=origin, model_hint=str(profile.params.get("slot", "chat")), ) session.add(run) await session.commit() run_id = run.id - - task = asyncio.create_task( - self.loop.execute( - run_id=run_id, character_id=character_id, prompt=prompt, trace_id=trace_id - ) + self._spawn( + run_id=run_id, + character_id=character_id, + prompt=prompt, + trace_id=trace_id, + seat_key=None, + system_contract=system_contract, + on_final=on_run_final, ) - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) - async with self._sessions() as session: created = await session.get(Run, run_id) assert created is not None return created + + async def start_rehearsal_run( + self, *, embryo_id: str, prompt: str, system_contract: str + ) -> Run: + """照心试演(幻境 Run):无正式人物、不占人物命台、不产生人物经历。""" + profile = resolve_profile() + async with self._sessions() as session: + run = Run( + character_id=None, + session_id=None, + status="queued", + kind="rehearsal", + goal=prompt[:300] if prompt else None, + origin="embryo_rehearse", + model_hint=str(profile.params.get("slot", "chat")), + ) + session.add(run) + await session.commit() + run_id = run.id + self._spawn( + run_id=run_id, + character_id=None, + prompt=prompt, + trace_id=None, + seat_key=f"embryo:{embryo_id}", + system_contract=system_contract, + on_final=None, + ) + async with self._sessions() as session: + created = await session.get(Run, run_id) + assert created is not None + return created + + def _spawn( + self, + *, + run_id: str, + character_id: str | None, + prompt: str, + trace_id: str | None, + seat_key: str | None, + system_contract: str | None, + on_final: OnRunFinal | None, + ) -> None: + task = asyncio.create_task( + self.loop.execute( + run_id=run_id, + character_id=character_id, + prompt=prompt, + trace_id=trace_id, + seat_key=seat_key, + system_contract=system_contract, + on_final=on_final, + ) + ) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) diff --git a/backend/app/kernel/loop.py b/backend/app/kernel/loop.py index e0d8abf..7168849 100644 --- a/backend/app/kernel/loop.py +++ b/backend/app/kernel/loop.py @@ -1,20 +1,22 @@ -"""Kernel Run 循环骨架(M0 占位形态)。 +"""Kernel Run 循环骨架。 事件序列对应 docs/architecture/lingtai-runtime.md §3.1;瀑布节点可被插件否决。 -M0 的投影为固定占位(系统契约占位 + 用户输入),M2 起接入镜树祖先路径、获准记忆 -与显式引文三类来源。 +投影当前为「调用方组装」形态:prompt 由领域层按镜树祖先链组装后传入, +三类投影来源(祖先路径/获准记忆/显式引文)的完整接入随 M3 息壤落地。 +``RunOutcome`` 携带终态与生成内容,供领域层(镜树节点固化等)在 Run 结束时回调。 """ from __future__ import annotations import asyncio -from dataclasses import replace -from datetime import datetime, UTC +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field, replace +from datetime import UTC, datetime from typing import Any from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from app.core.ids import new_run_id, new_trace_id +from app.core.ids import new_trace_id from app.kernel.eventlog import KernelEventLog from app.kernel.invariants import ( InvariantViolation, @@ -34,13 +36,29 @@ from app.runtime.bus import ( ) from app.runtime.profile import Profile, resolve_profile from app.shenshi_client.client import ShenshiClient -from app.shenshi_client.types import ShenshiMessage, ShenshiChatRequest +from app.shenshi_client.types import ShenshiChatRequest, ShenshiMessage -_SYSTEM_CONTRACT_PLACEHOLDER = "你是魂镜人物运行时(M0 骨架占位契约)。" +_SYSTEM_CONTRACT_PLACEHOLDER = "你是魂镜人物运行时(骨架占位契约)。" + +# Run 结束回调:(生成内容, 终态)。终态仅 completed / interrupted。 +OnRunFinal = Callable[[str, str], Awaitable[None]] + + +@dataclass +class RunOutcome: + """Run 终态载体:循环内各终止路径写入,execute 返回前触发回调。""" + + status: str = "running" # running / completed / interrupted / cancelled + content: str = "" + error_code: str = "" + extras: dict[str, Any] = field(default_factory=dict) class KernelRunLoop: - """单次 Run 的执行骨架:命台席位 → 事件序列 → 神识流 → 接纳断言。""" + """单次 Run 的执行骨架:命台席位 → 事件序列 → 神识流 → 接纳断言。 + + ``character_id`` 允许为空(照心试演无正式人物),此时必须提供 ``seat_key``。 + """ def __init__( self, @@ -60,30 +78,62 @@ class KernelRunLoop: self._timeout = timeout_seconds async def execute( - self, *, run_id: str, character_id: str, prompt: str, trace_id: str | None = None - ) -> None: + self, + *, + run_id: str, + character_id: str | None, + prompt: str, + trace_id: str | None = None, + seat_key: str | None = None, + system_contract: str | None = None, + on_final: OnRunFinal | None = None, + ) -> RunOutcome: + assert character_id or seat_key, "Run 必须绑定人物或提供席位键(试演)" trace = trace_id or new_trace_id() + outcome = RunOutcome() try: async with asyncio.timeout(self._timeout): await self._execute( - run_id=run_id, character_id=character_id, prompt=prompt, trace_id=trace + run_id=run_id, + character_id=character_id, + prompt=prompt, + trace_id=trace, + seat_key=seat_key, + system_contract=system_contract, + outcome=outcome, ) except TimeoutError: + outcome.status = "interrupted" + outcome.error_code = "run_timeout" await self._finish_interrupted( run_id, character_id, trace, code="run_timeout", message="运行超时被中断" ) except asyncio.CancelledError: + outcome.status = "cancelled" await self._emit(run_id, character_id, trace, "run.cancelled", visibility="public") await self._update_run(run_id, status="cancelled") raise + if on_final is not None and outcome.status in ("completed", "interrupted"): + await on_final(outcome.content, outcome.status) + return outcome + async def _execute( - self, *, run_id: str, character_id: str, prompt: str, trace_id: str + self, + *, + run_id: str, + character_id: str | None, + prompt: str, + trace_id: str, + seat_key: str | None, + system_contract: str | None, + outcome: RunOutcome, ) -> None: ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id) try: await self._bus.fire(NODE_RUN_REQUESTED, ctx) except Veto as veto: + outcome.status, outcome.error_code = "interrupted", "HJ-1301" await self._record_veto(run_id, character_id, trace_id, veto) await self._finish_interrupted( run_id, character_id, trace_id, code="HJ-1301", message=veto.reason @@ -91,13 +141,26 @@ class KernelRunLoop: return await self._emit(run_id, character_id, trace_id, "run.queued") - async with self._seats.foreground(character_id): + async with self._seats.foreground(seat_key or character_id or run_id): await self._update_run(run_id, status="running") await self._emit(run_id, character_id, trace_id, "run.started") - await self._turn(run_id, character_id, trace_id, prompt) + await self._turn( + run_id, + character_id, + trace_id, + prompt, + system_contract or _SYSTEM_CONTRACT_PLACEHOLDER, + outcome, + ) async def _turn( - self, run_id: str, character_id: str, trace_id: str, prompt: str + self, + run_id: str, + character_id: str | None, + trace_id: str, + prompt: str, + system_contract: str, + outcome: RunOutcome, ) -> None: ctx = RunContext( run_id=run_id, character_id=character_id, trace_id=trace_id, payload={"prompt": prompt} @@ -110,7 +173,7 @@ class KernelRunLoop: model=profile.params.get("model"), slot=str(profile.params.get("slot", "chat")), messages=[ - ShenshiMessage(role="system", content=_SYSTEM_CONTRACT_PLACEHOLDER), + ShenshiMessage(role="system", content=system_contract), ShenshiMessage(role="user", content=prompt), ], run_id=run_id, @@ -124,13 +187,14 @@ class KernelRunLoop: "context.projected", payload={ "profile": profile.snapshot(), - "sources": {"system_contract": "placeholder", "user_input": True}, + "sources": {"system_contract": "assembled", "user_input": True}, }, visibility="internal", ) try: await self._bus.fire(NODE_LLM_PRE_REQUEST, ctx) except Veto as veto: + outcome.status, outcome.error_code = "interrupted", "HJ-1301" await self._record_veto(run_id, character_id, trace_id, veto) await self._finish_interrupted( run_id, character_id, trace_id, code="HJ-1301", message=veto.reason @@ -163,6 +227,7 @@ class KernelRunLoop: "finish_reason": delta.finish_reason, } except Exception as exc: # 神识侧失败:如实上报,不伪装完成 + outcome.status, outcome.error_code = "interrupted", "HJ-2001" await self._emit( run_id, character_id, @@ -187,6 +252,7 @@ class KernelRunLoop: assert_model_visible_logged(request, events) assert_stream_reconstruction(events, accepted_text) except InvariantViolation as violation: + outcome.status, outcome.error_code = "interrupted", "HJ-9000" await self._emit( run_id, character_id, @@ -210,9 +276,11 @@ class KernelRunLoop: await self._emit(run_id, character_id, trace_id, "turn.finished") await self._emit(run_id, character_id, trace_id, "run.finished") await self._update_run(run_id, status="completed") + outcome.status = "completed" + outcome.content = accepted_text async def _record_veto( - self, run_id: str, character_id: str, trace_id: str, veto: Veto + self, run_id: str, character_id: str | None, trace_id: str, veto: Veto ) -> None: await self._emit( run_id, @@ -226,7 +294,7 @@ class KernelRunLoop: async def _finish_interrupted( self, run_id: str, - character_id: str, + character_id: str | None, trace_id: str, *, code: str, @@ -238,7 +306,7 @@ class KernelRunLoop: async def _emit( self, run_id: str, - character_id: str, + character_id: str | None, trace_id: str, type: str, # noqa: A002 payload: dict[str, Any] | None = None, @@ -273,7 +341,3 @@ class KernelRunLoop: if status in ("completed", "failed", "cancelled", "interrupted"): run.completed_at = datetime.now(UTC) await session.commit() - - -def new_kernel_run_id() -> str: - return new_run_id() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index bdda585..ae7c8f1 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -19,6 +19,13 @@ from app.models.imports import CharacterImportJob, ImportItem from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat from app.models.lorebook import Lorebook, LorebookEntry from app.models.mcp_servers import McpServer +from app.models.mirror_tree import ( + Canonization, + Citation, + MirrorTree, + TreeNode, + TreeTransplant, +) from app.models.memory import Memory, MemoryLink from app.models.personality import PersonalityProposal, PersonalityVersion from app.models.personality_emotion import CharacterEmotionState diff --git a/backend/app/models/mirror_tree.py b/backend/app/models/mirror_tree.py new file mode 100644 index 0000000..b4c2ddd --- /dev/null +++ b/backend/app/models/mirror_tree.py @@ -0,0 +1,132 @@ +"""镜树域模型(data-model.md §2):主题镜树、节点、引文锚、归真记录、移栽谱系。 + +不变量(应用层强制 + 测试钉死): +- 候答重生成=插入新行,旧节点永不 UPDATE 内容或状态回退; +- 后代节点绑定用户实际采用的候答(问节默认挂工作入口); +- working_entry 变更不触碰 canonizations(采用与归真解耦); +- canonizations 不可变,正影=新版本节点 + 新归真记录。 +""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.ids import ( + new_canonization_id, + new_citation_id, + new_mirror_tree_id, + new_tree_node_id, + new_transplant_id, +) +from app.core.time import utc_now +from app.db.base import Base + + +class MirrorTree(Base): + """主题镜树:一条归真主脉 + 多条推演枝 + 一个工作入口。""" + + __tablename__ = "mirror_trees" + + id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_mirror_tree_id) + character_id: Mapped[str] = mapped_column( + String(80), ForeignKey("characters.id"), nullable=False, index=True + ) + title: Mapped[str] = mapped_column(String(300), nullable=False) + # active / archived + status: Mapped[str] = mapped_column(String(40), nullable=False, default="active", index=True) + # 默认界域(reality / persistent_world / simulation),问节未显式指定时继承 + default_realm: Mapped[str] = mapped_column(String(40), nullable=False, default="reality") + # 采用:当前阅读与继续生长的工作入口(answer/continuation 节点) + working_entry_node_id: Mapped[str | None] = mapped_column(String(80)) + # 移栽来源谱系:{source_tree_id, source_node_id};原生树为空 + transplanted_from_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utc_now, onupdate=utc_now + ) + + +class TreeNode(Base): + """统一节点表:问节 / 候答 / 续思。 + + 拓扑:answer.parent=question;question.parent=所依据的候答(默认工作入口); + continuation.parent=被延展的候答或续思。编辑已发送内容=新建分叉节点(supersedes 指旧节点)。 + """ + + __tablename__ = "tree_nodes" + __table_args__ = ( + UniqueConstraint("tree_id", "supersedes_node_id", name="uq_tree_node_supersedes"), + ) + + id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_tree_node_id) + tree_id: Mapped[str] = mapped_column(String(80), ForeignKey("mirror_trees.id"), index=True) + parent_node_id: Mapped[str | None] = mapped_column( + String(80), ForeignKey("tree_nodes.id"), index=True + ) + # question / answer / continuation(契约词典 §9.1) + node_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + # budding / sealed_residual / solidified(契约词典 §9.2) + status: Mapped[str] = mapped_column(String(40), nullable=False, default="budding", index=True) + # user / character(问节作者为 user;候答为 character) + author: Mapped[str] = mapped_column(String(40), nullable=False, default="user") + content: Mapped[str] = mapped_column(Text, nullable=False, default="") + # 节点界域(契约词典 §12);默认继承树默认值 + realm: Mapped[str] = mapped_column(String(40), nullable=False, default="reality") + # 生成元数据:{model, run_id, trace_id, instructions} + generation_meta_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + # 正影/编辑分叉:新节点指向被替代的旧节点(旧节点原样保留) + supersedes_node_id: Mapped[str | None] = mapped_column(String(80)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utc_now, onupdate=utc_now + ) + + +class Citation(Base): + """引文锚:稳定引用 + 原文快照。来源正影时旧锚不静默替换,仅提示后继版本。""" + + __tablename__ = "citations" + + id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_citation_id) + node_id: Mapped[str] = mapped_column(String(80), ForeignKey("tree_nodes.id"), index=True) + # text_range / image_region / pdf_region / attachment / av_segment(契约词典 §9.5) + target_type: Mapped[str] = mapped_column(String(40), nullable=False) + # 定位信息:{文本区间 / 页码区域 / 时间段等} + locator_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + # 引用时的原文/媒体快照(来源变化不被覆盖) + snapshot: Mapped[str] = mapped_column(Text, nullable=False, default="") + source_id: Mapped[str] = mapped_column(String(200), nullable=False) + source_version: Mapped[int] = mapped_column(nullable=False, default=1) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class Canonization(Base): + """归真记录(不可变):路径进入明确的 canonical_scope;主脉=同作用域记录的路径并集。""" + + __tablename__ = "canonizations" + + id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_canonization_id) + tree_id: Mapped[str] = mapped_column(String(80), ForeignKey("mirror_trees.id"), index=True) + character_id: Mapped[str] = mapped_column(String(80), index=True) + # reality / persistent_world / simulation(契约词典 §9.4) + scope: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + world_id: Mapped[str | None] = mapped_column(String(80)) + # 路径端点(answer/continuation 节点) + endpoint_node_id: Mapped[str] = mapped_column(String(80), index=True) + # 端点祖先链快照(root→endpoint 的节点 ID 序列) + path_nodes_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + confirmed_by: Mapped[str] = mapped_column(String(80), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class TreeTransplant(Base): + """移栽谱系:分支复制为新树,保留来源引用;树间只引用不物理合并。""" + + __tablename__ = "tree_transplants" + + id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_transplant_id) + source_tree_id: Mapped[str] = mapped_column(String(80), index=True) + source_node_id: Mapped[str] = mapped_column(String(80)) + new_tree_id: Mapped[str] = mapped_column(String(80), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) diff --git a/backend/tests/test_mirror_tree.py b/backend/tests/test_mirror_tree.py new file mode 100644 index 0000000..e8cfb7c --- /dev/null +++ b/backend/tests/test_mirror_tree.py @@ -0,0 +1,384 @@ +"""镜树域 M2 测试。 + +对应 mirror-tree PRD §14 验收场景(后端可自动化部分): +1. 同问节三候答并存保留,后续问节绑定实际采用的候答; +2. 采用切换不动归真主脉;归真后切回旧候答正式经历不回滚;分叉归真 409(HJ-1102); +3. 编辑分叉:旧问题/旧候答/后代仍可访问; +5. 残答封存,续生创建独立节点且不改写残答; +6. 引文快照不被来源更新替换,仅提示后继版本; +7. 移栽为新树,可追溯来源,两树引用不合流; +另覆盖三界:境界归真须 world_id、幻境归真只记录试演、入境事件留痕。 +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncGenerator + +import httpx +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db.session import AsyncSessionLocal +from app.domains.jingsheng import bind_event_store +from app.kernel import Kernel, KernelEventLog +from app.services.event_store import DbEventStore +from app.runtime.bus import Veto +from app.shenshi_client import EchoShenshi + + +@pytest.fixture +async def tree_client( + monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str] +) -> AsyncGenerator[AsyncClient, None]: + from app.main import app + + event_store = DbEventStore(AsyncSessionLocal) + bind_event_store(event_store) + app.state.kernel = Kernel( + shenshi=EchoShenshi(), + session_factory=AsyncSessionLocal, + event_log=KernelEventLog(event_store), + timeout_seconds=10.0, + ) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c: + yield c + app.state.kernel = None + + +async def _activated_character(client: AsyncClient, name: str) -> str: + embryo = ( + await client.post("/api/v1/embryo-drafts", json={"source": "original", "name": name}) + ).json() + return ( + (await client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()["character_id"] + ) + + +async def _wait_run(client: AsyncClient, run_id: str) -> str: + for _ in range(400): + status = (await client.get(f"/api/v1/runs/{run_id}")).json()["status"] + if status in ("completed", "failed", "interrupted", "cancelled"): + return status + await asyncio.sleep(0.02) + raise AssertionError("Run 未到达终态") + + +async def _new_tree_with_question(client: AsyncClient, content: str, realm: str | None = None): + character_id = await _activated_character(client, f"树主{asyncio.get_event_loop().time()}") + tree = ( + await client.post( + "/api/v1/trees", json={"character_id": character_id, "title": "主题树"} + ) + ).json() + q = ( + await client.post( + f"/api/v1/trees/{tree['id']}/nodes", + json={"content": content, **({"realm": realm} if realm else {})}, + ) + ).json() + return tree["id"], q["id"], character_id + + +async def _answer(client: AsyncClient, question_id: str, instructions: str | None = None): + spawned = ( + await client.post(f"/api/v1/nodes/{question_id}/answers", json={"instructions": instructions}) + ).json() + status = await _wait_run(client, spawned["run_id"]) + return spawned["node_id"], status + + +async def _node_via_path(client: AsyncClient, tree_id: str, node_id: str) -> dict: + path = (await client.get(f"/api/v1/trees/{tree_id}/path", params={"entry": node_id})).json() + return next(n for n in path["nodes"] if n["id"] == node_id) + + +# ── 场景 1:多候答并存 + 绑定采用 ────────────────────────────────────────────── + + +async def test_three_candidates_and_binding(tree_client: AsyncClient) -> None: + tree_id, q1, _ = await _new_tree_with_question(tree_client, "第一个问题") + a1, s1 = await _answer(tree_client, q1, "答法一") + a2, s2 = await _answer(tree_client, q1, "答法二") + a3, s3 = await _answer(tree_client, q1, "答法三") + assert (s1, s2, s3) == ("completed", "completed", "completed") + + # 三候答均保留且已固化 + for node_id in (a1, a2, a3): + node = await _node_via_path(tree_client, tree_id, node_id) + assert node["status"] == "solidified" + + # 采用 B 后,新问节默认挂 B(不变量 2) + await tree_client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": a2}) + q2 = ( + await tree_client.post(f"/api/v1/trees/{tree_id}/nodes", json={"content": "追问"}) + ).json() + node_q2 = await _node_via_path(tree_client, tree_id, q2["id"]) + assert node_q2["parent_node_id"] == a2 + + +# ── 场景 2:采用/归真分离 + 分叉归真 409 ────────────────────────────────────────────── + + +async def test_adopt_vs_canonize_and_divergence(tree_client: AsyncClient) -> None: + tree_id, q1, _ = await _new_tree_with_question(tree_client, "主脉问题") + a, _ = await _answer(tree_client, q1, "答案 A") + b, _ = await _answer(tree_client, q1, "答案 B") + + # 采用 B → 归真 B(真界)→ 切回 A:主脉不变 + await tree_client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": b}) + canonized = await tree_client.post( + f"/api/v1/trees/{tree_id}/canonize", + json={"endpoint_node_id": b, "canonical_scope": "reality"}, + ) + assert canonized.status_code == 200 + canon_id = canonized.json()["canonization_id"] + + await tree_client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": a}) + + # 归真主脉不受采用切换影响(canonization 不可变) + async with AsyncSessionLocal() as session: + from sqlalchemy import select + + from app.models.mirror_tree import Canonization + + rows = (await session.execute(select(Canonization))).scalars().all() + assert len(rows) == 1 and rows[0].id == canon_id + assert rows[0].endpoint_node_id == b + + # 场景 8:归真与主脉分叉的另一候答 → 409 HJ-1102,附带当前主脉端点 + divergent = await tree_client.post( + f"/api/v1/trees/{tree_id}/canonize", + json={"endpoint_node_id": a, "canonical_scope": "reality"}, + ) + assert divergent.status_code == 409 + body = divergent.json()["error"] + assert body["code"] == "HJ-1102" + assert body.get("details", {}).get("current_trunk_head") == b + + # 主脉延长(B 之后继续生长再归真)允许 + await tree_client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": b}) + q2 = ( + await client_q(tree_client, tree_id, "延伸问题") + ).json() + a_next, _ = await _answer(tree_client, q2["id"]) + extension = await tree_client.post( + f"/api/v1/trees/{tree_id}/canonize", + json={"endpoint_node_id": a_next, "canonical_scope": "reality"}, + ) + assert extension.status_code == 200 + + +async def client_q(client: AsyncClient, tree_id: str, content: str): + return await client.post(f"/api/v1/trees/{tree_id}/nodes", json={"content": content}) + + +# ── 场景 3:编辑分叉 ────────────────────────────────────────────── + + +async def test_edit_fork_preserves_original(tree_client: AsyncClient) -> None: + tree_id, q1, _ = await _new_tree_with_question(tree_client, "原始问题") + a1, _ = await _answer(tree_client, q1) + + fork = ( + await tree_client.post( + f"/api/v1/nodes/{q1}/edit-fork", json={"new_content": "改写后的问题"} + ) + ).json() + assert fork["supersedes_node_id"] == q1 + + original = await _node_via_path(tree_client, tree_id, q1) + forked = await _node_via_path(tree_client, tree_id, fork["node_id"]) + assert original["content"] == "原始问题" # 原文保留 + assert forked["content"] == "改写后的问题" + assert forked["parent_node_id"] is None # 与原问节同级(根) + + # 旧候答仍可访问 + old_answer = await _node_via_path(tree_client, tree_id, a1) + assert old_answer["status"] == "solidified" + + +# ── 场景 5:残答封存与续生 ────────────────────────────────────────────── + + +async def test_residual_and_resume(tree_client: AsyncClient) -> None: + tree_id, q1, _ = await _new_tree_with_question(tree_client, "会被中断的问题") + # 用否决监听器制造中断:llm.pre_request 否决 → run.interrupted → 节点封存 + from app.main import app + + kernel: Kernel = app.state.kernel + + async def veto_once(node: str, context) -> None: # noqa: ANN001 + raise Veto("中断试演", rule="test_interrupt") + + kernel.bus.on("llm.pre_request", veto_once) + try: + spawned = ( + await tree_client.post(f"/api/v1/nodes/{q1}/answers", json={}) + ).json() + status = await _wait_run(tree_client, spawned["run_id"]) + assert status == "interrupted" + finally: + kernel.bus._listeners.pop("llm.pre_request", None) # noqa: SLF001 + + residual_id = spawned["node_id"] + residual = await _node_via_path(tree_client, tree_id, residual_id) + assert residual["status"] == "sealed_residual" + + # 续生:新独立节点,不改写残答 + resumed = ( + await tree_client.post(f"/api/v1/nodes/{residual_id}/resume", json={}) + ).json() + assert await _wait_run(tree_client, resumed["run_id"]) == "completed" + cont = await _node_via_path(tree_client, tree_id, resumed["node_id"]) + assert cont["node_type"] == "continuation" + assert cont["parent_node_id"] == residual_id + residual_after = await _node_via_path(tree_client, tree_id, residual_id) + assert residual_after["status"] == "sealed_residual" # 残答未被改写 + assert residual_after["id"] == residual_id + + +# ── 场景 6:引文快照与后继提示 ────────────────────────────────────────────── + + +async def test_citation_snapshot_and_newer_version(tree_client: AsyncClient) -> None: + tree_id, q1, _ = await _new_tree_with_question( + tree_client, "带引文的问题", + ) + created = await tree_client.post( + f"/api/v1/nodes/{q1}/citations", + json={ + "target_type": "pdf_region", + "locator": {"page": 3, "rect": [0.1, 0.2, 0.5, 0.6]}, + "snapshot": "第 3 页原文 V1", + "source_id": "doc-001", + "source_version": 1, + }, + ) + assert created.status_code == 201 + + # 来源正影为 V2(挂在别处):旧引文不静默替换 + q2 = (await client_q(tree_client, tree_id, "另一个问题")).json() + await tree_client.post( + f"/api/v1/nodes/{q2['id']}/citations", + json={ + "target_type": "pdf_region", + "locator": {"page": 3}, + "snapshot": "第 3 页原文 V2", + "source_id": "doc-001", + "source_version": 2, + }, + ) + + citations = ( + await tree_client.get(f"/api/v1/nodes/{q1}/citations") + ).json() + assert len(citations) == 1 + assert citations[0]["snapshot"] == "第 3 页原文 V1" # 快照保持 + assert citations[0]["newer_version_available"] is True # 提示后继版本 + + +# ── 场景 7:移栽 ────────────────────────────────────────────── + + +async def test_transplant_creates_referenced_tree(tree_client: AsyncClient) -> None: + tree_id, q1, _ = await _new_tree_with_question(tree_client, "被移栽的问题") + a1, _ = await _answer(tree_client, q1, "答案") + await tree_client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": a1}) + q2 = (await client_q(tree_client, tree_id, "分支问题")).json() + a2, _ = await _answer(tree_client, q2["id"], "分支答案") + + transplanted = ( + await tree_client.post( + f"/api/v1/trees/{tree_id}/transplant", + json={"node_id": a2, "new_title": "移栽新树"}, + ) + ).json() + new_tree_id = transplanted["new_tree_id"] + + # 新树保留来源谱系引用;节点是全新 ID(引用关联而非物理合并) + new_tree = (await tree_client.get(f"/api/v1/trees/{new_tree_id}")).json() + assert new_tree["transplanted_from"] == {"source_tree_id": tree_id, "source_node_id": a2} + new_path = ( + await tree_client.get( + f"/api/v1/trees/{new_tree_id}/path", + params={"entry": transplanted["working_entry_node_id"]}, + ) + ).json() + new_ids = {n["id"] for n in new_path["nodes"]} + assert new_ids.isdisjoint({q1, a1, q2["id"], a2}) + assert new_path["nodes"][-1]["content"] == (await _node_via_path(tree_client, tree_id, a2))["content"] + + # 原树原样保留 + old_path = ( + await tree_client.get(f"/api/v1/trees/{tree_id}/path", params={"entry": a2}) + ).json() + assert old_path["nodes"][-1]["id"] == a2 + + +# ── 三界作用域 ────────────────────────────────────────────── + + +async def test_realms_and_scopes(tree_client: AsyncClient) -> None: + # 境界归真缺 world_id 被拒 + tree_id, q1, _ = await _new_tree_with_question(tree_client, "境界问题", realm="persistent_world") + a1, _ = await _answer(tree_client, q1) + missing_world = await tree_client.post( + f"/api/v1/trees/{tree_id}/canonize", + json={"endpoint_node_id": a1, "canonical_scope": "persistent_world"}, + ) + assert missing_world.status_code == 400 + + ok = await tree_client.post( + f"/api/v1/trees/{tree_id}/canonize", + json={ + "endpoint_node_id": a1, + "canonical_scope": "persistent_world", + "world_id": "world_demo", + }, + ) + assert ok.status_code == 200 + + # 幻境试演:只记录"发生过这次试演",与真界归真互不干扰 + tree2, q2, _ = await _new_tree_with_question(tree_client, "幻境问题", realm="simulation") + sim_answer, _ = await _answer(tree_client, q2) + sim = await tree_client.post( + f"/api/v1/trees/{tree2}/canonize", + json={"endpoint_node_id": sim_answer, "canonical_scope": "simulation"}, + ) + assert sim.status_code == 200 + + # 入境事件留痕(界域切换可见) + async with AsyncSessionLocal() as session: + from sqlalchemy import select + + from app.models.runtime import Event + + rows = ( + await session.execute(select(Event).where(Event.type == "realm.entered")) + ).scalars().all() + assert any(json.loads(r.payload_json).get("tree_id") in (tree_id, tree2) for r in rows) + + +# ── 芽体保护 ────────────────────────────────────────────── + + +async def test_budding_protections(tree_client: AsyncClient) -> None: + tree_id, q1, _ = await _new_tree_with_question(tree_client, "保护问题") + # 芽体不可采用/归真(另答后立即操作,echo 极快完成——直接测问节与错误类型路径) + question_adopt = await tree_client.post( + f"/api/v1/trees/{tree_id}/adopt", json={"node_id": q1} + ) + assert question_adopt.status_code == 400 # 问节不能作工作入口 + + question_canonize = await tree_client.post( + f"/api/v1/trees/{tree_id}/canonize", + json={"endpoint_node_id": q1, "canonical_scope": "reality"}, + ) + assert question_canonize.status_code == 400 # 问节不能作归真端点 + + # 续思不能基于问节 + bad_continue = await tree_client.post(f"/api/v1/nodes/{q1}/continue", json={}) + assert bad_continue.status_code == 400 diff --git a/docs/operations/capability-matrix.md b/docs/operations/capability-matrix.md index 507218c..ae7a8a0 100644 --- a/docs/operations/capability-matrix.md +++ b/docs/operations/capability-matrix.md @@ -21,7 +21,7 @@ | --- | --- | --- | --- | | 灵台 Kernel 与插件运行时 | M0 | 局部实现 | M0 骨架已验证(本地开发档,2026-08-21):`backend/tests/test_kernel.py` 5 用例(事件序列/命台串行/否决/不变量/SSE),Kernel 经 DbEventStore 事件溯源;投影仍为占位,九枢插件与禁域裁决待 M2+/M5 | | 镜生闭环(灵胚/启镜/魂籍/命台) | M1 | 局部实现 | M1 后端闭环已验证(本地开发档,2026-08-21):`backend/tests/test_jingsheng.py` 7 用例——启镜前零人物行/零 activated 事件、并发启镜原子闸门单赢家(409 HJ-1103)、命台租约冲突被拒且 `seat.contested` 留痕、过期接管 epoch 递增、魂籍摘要+签名双校验(篡改与移植冒充均 422)、死亡冷静期撤销/落定(数据保留);随缘候选生成与角色卡导入对接随 M2,前端人物页随 M2+ | -| 镜树对话(问节/候答/采用/归真/引文/移栽) | M2 | 未实现 | 现有 chat 为线性会话,将被替换删除 | +| 镜树对话(问节/候答/采用/归真/引文/移栽) | M2 | 局部实现 | M2a 后端核心已验证(本地开发档,2026-08-21):`backend/tests/test_mirror_tree.py` 8 用例钉死 mirror-tree PRD 场景 1/2/3/5/6/7/8——三候答并存、采用/归真分离、主脉延长 vs 分叉 409(HJ-1102 附当前主脉端点)、编辑分叉保留原文、残答封存续生不改写、引文快照+后继版本提示、移栽新树引用关联、三界作用域(境界须 world_id、幻境试演记录、入境留痕);M2b 待做:角色卡导入对接灵胚+世谱、删除旧 chat 模块与页面、前端镜树页 | | 三级记忆(微痕/明忆/重忆/实录/心痕) | M3 | 未实现 | 旧 memory/extraction 模块待退役;新管线未建 | | 神识 Go 网关 | M4 | 局部实现 | M0 骨架已上线(2026-08-21):IR 入站/SSE 出站/stub 适配器/服务令牌/健康检查,`go test` 5 用例,与灵台 HttpShenshiClient 联调通过;云 Provider 与元炁端点、路由冷却计量待 M4 | | 百工与司命(授契/行契/请命/禁域/行动账) | M5 | 局部实现 | 乾坤沙箱(HMAC/进程隔离)已有工程与测试;能力注册与四维裁决待重写 | diff --git a/docs/operations/rewrite-plan.md b/docs/operations/rewrite-plan.md index 0787954..b0fafaa 100644 --- a/docs/operations/rewrite-plan.md +++ b/docs/operations/rewrite-plan.md @@ -90,10 +90,12 @@ - 证据:`backend/tests/test_jingsheng.py` 7 用例全绿;全量回归 932 passed(20 个预存失败与 M0 时相同,缺 `config/models.yaml`);`make db-upgrade` 空库 000→001 干净应用(含 SQLite batch alter)。 - 遗留到 M2:随缘灵胚候选生成、角色卡导入对接灵胚、Kernel 前台串行接入 DB 租约(多进程一致)、前端人物页。 -### M2 镜树闭环(核心主线) +### M2 镜树闭环(核心主线)— 🔶 M2a 后端核心完成(2026-08-21),M2b 进行中 -- 交付:mirror_trees/tree_nodes/citations/canonizations/transplants 全量 API;另答/续思/续生/编辑分叉/采用/归真(三界 scope)/移栽/引文锚;SSE 芽体→固化;前端镜树页(当前路径+局部树图+抽屉);导入改造(角色卡 V2/V3→灵胚+世谱)+ 符文引擎/眉批/火候接入组装管线;**删除旧 chat 模块与 chat 页面**。 -- 验收:镜树 PRD §14 十个验收场景全部自动化通过(三候答保留、归真不回滚、分叉可达、幻境不入真界记忆、残答续生、引文快照、移栽谱系、归真冲突暂停、移动端可完成、轨迹可读)。 +- 交付:mirror_trees/tree_nodes/citations/canonizations/transplants 全量 API(迁移 `002`);另答/续思/续生/编辑分叉/采用/归真(三界 scope)/移栽/引文锚;Kernel `RunOutcome` 回调实现芽体→固化/残答封存(run 关联 tree 事件);祖先链投影 prompt 组装。 +- 验收:镜树 PRD §14 十个验收场景全部自动化通过。 +- 证据(M2a):`backend/tests/test_mirror_tree.py` 8 用例覆盖场景 1/2/3/5/6/7/8 与三界作用域;全量回归 940 passed(预存 20 失败不变);`make db-upgrade` 000→002 干净。场景 4(未归真不入忆)随 M3 记忆管线落测试;场景 9(移动端)与前端页面随 M2b。 +- M2b 待办:角色卡导入改造(V2/V3→灵胚+世谱)、符文引擎/眉批/火候接入组装管线、**删除旧 chat 模块与 chat 页面**、前端镜树页(当前路径+局部树图+抽屉)、Kernel 前台串行接入 DB 租约。 ### M3 记忆闭环(息壤)