M2a:镜树域后端核心——树/节点/引文/归真/移栽全量 API 与 Kernel 完成回调(芽体固化/残答封存),8 用例钉死 PRD 场景 1/2/3/5/6/7/8
This commit is contained in:
1 parent
1cd0069d65
commit
e6a3b7121f
16 files changed
+1999
-71
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 ###
|
||||||
@@ -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)
|
||||||
@@ -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_")
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
run_id=None,
|
source="jingsheng",
|
||||||
character_id=character_id,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
@@ -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:
|
|
||||||
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,
|
character_id=character_id,
|
||||||
)
|
payload=payload,
|
||||||
|
visibility=visibility,
|
||||||
|
source="jingsheng",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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
@@ -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()
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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/进程隔离)已有工程与测试;能力注册与四维裁决待重写 |
|
||||||
|
|||||||
@@ -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 记忆闭环(息壤)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user