M2a:镜树域后端核心——树/节点/引文/归真/移栽全量 API 与 Kernel 完成回调(芽体固化/残答封存),8 用例钉死 PRD 场景 1/2/3/5/6/7/8

This commit is contained in:
chenxin committed 2026-08-21 23:00:07 +08:00
1 parent 1cd0069d65
commit e6a3b7121f
16 files changed
+2002 -74

No files matched your search

@@ -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 ###
+3
View File
@@ -21,6 +21,7 @@ from app.api.v1.endpoints import (
jingsheng_characters, jingsheng_characters,
kernel_runs, kernel_runs,
lorebooks, lorebooks,
mirror_trees,
mcp, mcp,
memory, memory,
personality, personality,
@@ -44,6 +45,8 @@ api_router.include_router(runs.router)
api_router.include_router(kernel_runs.router) api_router.include_router(kernel_runs.router)
api_router.include_router(embryo_drafts.router) api_router.include_router(embryo_drafts.router)
api_router.include_router(jingsheng_characters.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(memory.router)
api_router.include_router(lorebooks.router) api_router.include_router(lorebooks.router)
api_router.include_router(personality.router) api_router.include_router(personality.router)
@@ -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)
+20
View File
@@ -131,6 +131,26 @@ def new_identity_record_id() -> str:
return new_id("identity_") 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: def new_role_id() -> str:
return new_id("rl_") return new_id("rl_")
+7 -16
View File
@@ -71,24 +71,15 @@ async def emit_seat_event(
requester: str | None = None, requester: str | None = None,
) -> None: ) -> None:
"""事务外发射命台事件(一炁,visibility=internal)。""" """事务外发射命台事件(一炁,visibility=internal)。"""
from app.modules.runtime.events import Event from app.domains import one_qi
from app.services.event_store import DbEventStore
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} payload: dict = {"holder": holder, "epoch": epoch}
if requester is not None: if requester is not None:
payload["requester"] = requester payload["requester"] = requester
await store.append( await one_qi.emit(
Event( event_type,
type=event_type, character_id=character_id,
source="jingsheng", payload=payload,
payload=payload, visibility="internal",
visibility="internal", source="jingsheng",
run_id=None,
character_id=character_id,
)
) )
+9 -18
View File
@@ -16,14 +16,12 @@ from app.models.character import Character
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat
from app.services.event_store import DbEventStore from app.services.event_store import DbEventStore
# 事件由服务层经一炁落库(DbEventStore 由调用方注入;默认取进程级装配) from app.domains import one_qi
_event_store: DbEventStore | None = None
def bind_event_store(store: DbEventStore) -> None: def bind_event_store(store: DbEventStore) -> None:
"""lifespan 装配时注入事件存储。""" """lifespan 装配时注入事件存储(委托共享绑定)。"""
global _event_store one_qi.bind_event_store(store)
_event_store = store
async def _emit( async def _emit(
@@ -34,19 +32,12 @@ async def _emit(
payload: dict, payload: dict,
visibility: str = "public", visibility: str = "public",
) -> None: ) -> None:
from app.modules.runtime.events import Event await one_qi.emit(
event_type,
if _event_store is None: character_id=character_id,
return payload=payload,
await _event_store.append( visibility=visibility,
Event( source="jingsheng",
type=event_type,
source="jingsheng",
payload=payload,
visibility=visibility, # type: ignore[arg-type]
run_id=None,
character_id=character_id,
)
) )
@@ -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",
]
+815
View File
@@ -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}
+42
View File
@@ -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,
)
)
+76 -11
View File
@@ -10,7 +10,7 @@ import asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.kernel.eventlog import KernelEventLog 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.kernel.seat import SeatManager
from app.models.runtime import Run from app.models.runtime import Run
from app.runtime.bus import WaterfallBus from app.runtime.bus import WaterfallBus
@@ -47,7 +47,14 @@ class Kernel:
self._tasks: set[asyncio.Task[None]] = set() self._tasks: set[asyncio.Task[None]] = set()
async def start_foreground_run( 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:
"""创建 Run 行(queued)并异步启动循环;前台席位在循环内排队获取。""" """创建 Run 行(queued)并异步启动循环;前台席位在循环内排队获取。"""
profile = resolve_profile() profile = resolve_profile()
@@ -56,23 +63,81 @@ class Kernel:
character_id=character_id, character_id=character_id,
session_id=None, session_id=None,
status="queued", status="queued",
kind="foreground",
goal=prompt[:300] if prompt else None, goal=prompt[:300] if prompt else None,
origin="kernel", origin=origin,
model_hint=str(profile.params.get("slot", "chat")), model_hint=str(profile.params.get("slot", "chat")),
) )
session.add(run) session.add(run)
await session.commit() await session.commit()
run_id = run.id run_id = run.id
self._spawn(
task = asyncio.create_task( run_id=run_id,
self.loop.execute( character_id=character_id,
run_id=run_id, character_id=character_id, prompt=prompt, trace_id=trace_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: async with self._sessions() as session:
created = await session.get(Run, run_id) created = await session.get(Run, run_id)
assert created is not None assert created is not None
return created 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)
+89 -25
View File
@@ -1,20 +1,22 @@
"""Kernel Run 循环骨架(M0 占位形态)。 """Kernel Run 循环骨架。
事件序列对应 docs/architecture/lingtai-runtime.md §3.1;瀑布节点可被插件否决。 事件序列对应 docs/architecture/lingtai-runtime.md §3.1;瀑布节点可被插件否决。
M0 的投影为固定占位(系统契约占位 + 用户输入),M2 起接入镜树祖先路径、获准记忆 投影当前为「调用方组装」形态:prompt 由领域层按镜树祖先链组装后传入,
与显式引文三类来源。 三类投影来源(祖先路径/获准记忆/显式引文)的完整接入随 M3 息壤落地。
``RunOutcome`` 携带终态与生成内容,供领域层(镜树节点固化等)在 Run 结束时回调。
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from dataclasses import replace from collections.abc import Awaitable, Callable
from datetime import datetime, UTC from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
from typing import Any from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 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.eventlog import KernelEventLog
from app.kernel.invariants import ( from app.kernel.invariants import (
InvariantViolation, InvariantViolation,
@@ -34,13 +36,29 @@ from app.runtime.bus import (
) )
from app.runtime.profile import Profile, resolve_profile from app.runtime.profile import Profile, resolve_profile
from app.shenshi_client.client import ShenshiClient 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: class KernelRunLoop:
"""单次 Run 的执行骨架:命台席位 → 事件序列 → 神识流 → 接纳断言。""" """单次 Run 的执行骨架:命台席位 → 事件序列 → 神识流 → 接纳断言。
``character_id`` 允许为空(照心试演无正式人物),此时必须提供 ``seat_key``。
"""
def __init__( def __init__(
self, self,
@@ -60,30 +78,62 @@ class KernelRunLoop:
self._timeout = timeout_seconds self._timeout = timeout_seconds
async def execute( async def execute(
self, *, run_id: str, character_id: str, prompt: str, trace_id: str | None = None self,
) -> None: *,
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() trace = trace_id or new_trace_id()
outcome = RunOutcome()
try: try:
async with asyncio.timeout(self._timeout): async with asyncio.timeout(self._timeout):
await self._execute( 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: except TimeoutError:
outcome.status = "interrupted"
outcome.error_code = "run_timeout"
await self._finish_interrupted( await self._finish_interrupted(
run_id, character_id, trace, code="run_timeout", message="运行超时被中断" run_id, character_id, trace, code="run_timeout", message="运行超时被中断"
) )
except asyncio.CancelledError: except asyncio.CancelledError:
outcome.status = "cancelled"
await self._emit(run_id, character_id, trace, "run.cancelled", visibility="public") await self._emit(run_id, character_id, trace, "run.cancelled", visibility="public")
await self._update_run(run_id, status="cancelled") await self._update_run(run_id, status="cancelled")
raise 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( 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: ) -> None:
ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id) ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id)
try: try:
await self._bus.fire(NODE_RUN_REQUESTED, ctx) await self._bus.fire(NODE_RUN_REQUESTED, ctx)
except Veto as veto: 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._record_veto(run_id, character_id, trace_id, veto)
await self._finish_interrupted( await self._finish_interrupted(
run_id, character_id, trace_id, code="HJ-1301", message=veto.reason run_id, character_id, trace_id, code="HJ-1301", message=veto.reason
@@ -91,13 +141,26 @@ class KernelRunLoop:
return return
await self._emit(run_id, character_id, trace_id, "run.queued") 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._update_run(run_id, status="running")
await self._emit(run_id, character_id, trace_id, "run.started") 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( 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: ) -> None:
ctx = RunContext( ctx = RunContext(
run_id=run_id, character_id=character_id, trace_id=trace_id, payload={"prompt": prompt} 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"), model=profile.params.get("model"),
slot=str(profile.params.get("slot", "chat")), slot=str(profile.params.get("slot", "chat")),
messages=[ messages=[
ShenshiMessage(role="system", content=_SYSTEM_CONTRACT_PLACEHOLDER), ShenshiMessage(role="system", content=system_contract),
ShenshiMessage(role="user", content=prompt), ShenshiMessage(role="user", content=prompt),
], ],
run_id=run_id, run_id=run_id,
@@ -124,13 +187,14 @@ class KernelRunLoop:
"context.projected", "context.projected",
payload={ payload={
"profile": profile.snapshot(), "profile": profile.snapshot(),
"sources": {"system_contract": "placeholder", "user_input": True}, "sources": {"system_contract": "assembled", "user_input": True},
}, },
visibility="internal", visibility="internal",
) )
try: try:
await self._bus.fire(NODE_LLM_PRE_REQUEST, ctx) await self._bus.fire(NODE_LLM_PRE_REQUEST, ctx)
except Veto as veto: 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._record_veto(run_id, character_id, trace_id, veto)
await self._finish_interrupted( await self._finish_interrupted(
run_id, character_id, trace_id, code="HJ-1301", message=veto.reason run_id, character_id, trace_id, code="HJ-1301", message=veto.reason
@@ -163,6 +227,7 @@ class KernelRunLoop:
"finish_reason": delta.finish_reason, "finish_reason": delta.finish_reason,
} }
except Exception as exc: # 神识侧失败:如实上报,不伪装完成 except Exception as exc: # 神识侧失败:如实上报,不伪装完成
outcome.status, outcome.error_code = "interrupted", "HJ-2001"
await self._emit( await self._emit(
run_id, run_id,
character_id, character_id,
@@ -187,6 +252,7 @@ class KernelRunLoop:
assert_model_visible_logged(request, events) assert_model_visible_logged(request, events)
assert_stream_reconstruction(events, accepted_text) assert_stream_reconstruction(events, accepted_text)
except InvariantViolation as violation: except InvariantViolation as violation:
outcome.status, outcome.error_code = "interrupted", "HJ-9000"
await self._emit( await self._emit(
run_id, run_id,
character_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, "turn.finished")
await self._emit(run_id, character_id, trace_id, "run.finished") await self._emit(run_id, character_id, trace_id, "run.finished")
await self._update_run(run_id, status="completed") await self._update_run(run_id, status="completed")
outcome.status = "completed"
outcome.content = accepted_text
async def _record_veto( 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: ) -> None:
await self._emit( await self._emit(
run_id, run_id,
@@ -226,7 +294,7 @@ class KernelRunLoop:
async def _finish_interrupted( async def _finish_interrupted(
self, self,
run_id: str, run_id: str,
character_id: str, character_id: str | None,
trace_id: str, trace_id: str,
*, *,
code: str, code: str,
@@ -238,7 +306,7 @@ class KernelRunLoop:
async def _emit( async def _emit(
self, self,
run_id: str, run_id: str,
character_id: str, character_id: str | None,
trace_id: str, trace_id: str,
type: str, # noqa: A002 type: str, # noqa: A002
payload: dict[str, Any] | None = None, payload: dict[str, Any] | None = None,
@@ -273,7 +341,3 @@ class KernelRunLoop:
if status in ("completed", "failed", "cancelled", "interrupted"): if status in ("completed", "failed", "cancelled", "interrupted"):
run.completed_at = datetime.now(UTC) run.completed_at = datetime.now(UTC)
await session.commit() await session.commit()
def new_kernel_run_id() -> str:
return new_run_id()
+7
View File
@@ -19,6 +19,13 @@ from app.models.imports import CharacterImportJob, ImportItem
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat
from app.models.lorebook import Lorebook, LorebookEntry from app.models.lorebook import Lorebook, LorebookEntry
from app.models.mcp_servers import McpServer 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.memory import Memory, MemoryLink
from app.models.personality import PersonalityProposal, PersonalityVersion from app.models.personality import PersonalityProposal, PersonalityVersion
from app.models.personality_emotion import CharacterEmotionState from app.models.personality_emotion import CharacterEmotionState
+132
View File
@@ -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)
+384
View File
@@ -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
+1 -1
View File
@@ -21,7 +21,7 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 灵台 Kernel 与插件运行时 | M0 | 局部实现 | M0 骨架已验证(本地开发档,2026-08-21):`backend/tests/test_kernel.py` 5 用例(事件序列/命台串行/否决/不变量/SSE),Kernel 经 DbEventStore 事件溯源;投影仍为占位,九枢插件与禁域裁决待 M2+/M5 | | 灵台 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+ | | 镜生闭环(灵胚/启镜/魂籍/命台) | 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 模块待退役;新管线未建 | | 三级记忆(微痕/明忆/重忆/实录/心痕) | M3 | 未实现 | 旧 memory/extraction 模块待退役;新管线未建 |
| 神识 Go 网关 | M4 | 局部实现 | M0 骨架已上线(2026-08-21):IR 入站/SSE 出站/stub 适配器/服务令牌/健康检查,`go test` 5 用例,与灵台 HttpShenshiClient 联调通过;云 Provider 与元炁端点、路由冷却计量待 M4 | | 神识 Go 网关 | M4 | 局部实现 | M0 骨架已上线(2026-08-21):IR 入站/SSE 出站/stub 适配器/服务令牌/健康检查,`go test` 5 用例,与灵台 HttpShenshiClient 联调通过;云 Provider 与元炁端点、路由冷却计量待 M4 |
| 百工与司命(授契/行契/请命/禁域/行动账) | M5 | 局部实现 | 乾坤沙箱(HMAC/进程隔离)已有工程与测试;能力注册与四维裁决待重写 | | 百工与司命(授契/行契/请命/禁域/行动账) | M5 | 局部实现 | 乾坤沙箱(HMAC/进程隔离)已有工程与测试;能力注册与四维裁决待重写 |
+5 -3
View File
@@ -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)。 - 证据:`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:随缘灵胚候选生成、角色卡导入对接灵胚、Kernel 前台串行接入 DB 租约(多进程一致)、前端人物页。
### M2 镜树闭环(核心主线) ### M2 镜树闭环(核心主线)— 🔶 M2a 后端核心完成(2026-08-21),M2b 进行中
- 交付:mirror_trees/tree_nodes/citations/canonizations/transplants 全量 API;另答/续思/续生/编辑分叉/采用/归真(三界 scope)/移栽/引文锚;SSE 芽体→固化;前端镜树页(当前路径+局部树图+抽屉);导入改造(角色卡 V2/V3→灵胚+世谱)+ 符文引擎/眉批/火候接入组装管线;**删除旧 chat 模块与 chat 页面**。 - 交付:mirror_trees/tree_nodes/citations/canonizations/transplants 全量 API(迁移 `002`);另答/续思/续生/编辑分叉/采用/归真(三界 scope)/移栽/引文锚;Kernel `RunOutcome` 回调实现芽体→固化/残答封存(run 关联 tree 事件);祖先链投影 prompt 组装。
- 验收:镜树 PRD §14 十个验收场景全部自动化通过(三候答保留、归真不回滚、分叉可达、幻境不入真界记忆、残答续生、引文快照、移栽谱系、归真冲突暂停、移动端可完成、轨迹可读)。 - 验收:镜树 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 记忆闭环(息壤) ### M3 记忆闭环(息壤)