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

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

No files matched your search

+384
View File
@@ -0,0 +1,384 @@
"""镜树域 M2 测试。
对应 mirror-tree PRD §14 验收场景(后端可自动化部分):
1. 同问节三候答并存保留,后续问节绑定实际采用的候答;
2. 采用切换不动归真主脉;归真后切回旧候答正式经历不回滚;分叉归真 409(HJ-1102);
3. 编辑分叉:旧问题/旧候答/后代仍可访问;
5. 残答封存,续生创建独立节点且不改写残答;
6. 引文快照不被来源更新替换,仅提示后继版本;
7. 移栽为新树,可追溯来源,两树引用不合流;
另覆盖三界:境界归真须 world_id、幻境归真只记录试演、入境事件留痕。
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncGenerator
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.session import AsyncSessionLocal
from app.domains.jingsheng import bind_event_store
from app.kernel import Kernel, KernelEventLog
from app.services.event_store import DbEventStore
from app.runtime.bus import Veto
from app.shenshi_client import EchoShenshi
@pytest.fixture
async def tree_client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
from app.main import app
event_store = DbEventStore(AsyncSessionLocal)
bind_event_store(event_store)
app.state.kernel = Kernel(
shenshi=EchoShenshi(),
session_factory=AsyncSessionLocal,
event_log=KernelEventLog(event_store),
timeout_seconds=10.0,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
yield c
app.state.kernel = None
async def _activated_character(client: AsyncClient, name: str) -> str:
embryo = (
await client.post("/api/v1/embryo-drafts", json={"source": "original", "name": name})
).json()
return (
(await client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()["character_id"]
)
async def _wait_run(client: AsyncClient, run_id: str) -> str:
for _ in range(400):
status = (await client.get(f"/api/v1/runs/{run_id}")).json()["status"]
if status in ("completed", "failed", "interrupted", "cancelled"):
return status
await asyncio.sleep(0.02)
raise AssertionError("Run 未到达终态")
async def _new_tree_with_question(client: AsyncClient, content: str, realm: str | None = None):
character_id = await _activated_character(client, f"树主{asyncio.get_event_loop().time()}")
tree = (
await client.post(
"/api/v1/trees", json={"character_id": character_id, "title": "主题树"}
)
).json()
q = (
await client.post(
f"/api/v1/trees/{tree['id']}/nodes",
json={"content": content, **({"realm": realm} if realm else {})},
)
).json()
return tree["id"], q["id"], character_id
async def _answer(client: AsyncClient, question_id: str, instructions: str | None = None):
spawned = (
await client.post(f"/api/v1/nodes/{question_id}/answers", json={"instructions": instructions})
).json()
status = await _wait_run(client, spawned["run_id"])
return spawned["node_id"], status
async def _node_via_path(client: AsyncClient, tree_id: str, node_id: str) -> dict:
path = (await client.get(f"/api/v1/trees/{tree_id}/path", params={"entry": node_id})).json()
return next(n for n in path["nodes"] if n["id"] == node_id)
# ── 场景 1:多候答并存 + 绑定采用 ──────────────────────────────────────────────
async def test_three_candidates_and_binding(tree_client: AsyncClient) -> None:
tree_id, q1, _ = await _new_tree_with_question(tree_client, "第一个问题")
a1, s1 = await _answer(tree_client, q1, "答法一")
a2, s2 = await _answer(tree_client, q1, "答法二")
a3, s3 = await _answer(tree_client, q1, "答法三")
assert (s1, s2, s3) == ("completed", "completed", "completed")
# 三候答均保留且已固化
for node_id in (a1, a2, a3):
node = await _node_via_path(tree_client, tree_id, node_id)
assert node["status"] == "solidified"
# 采用 B 后,新问节默认挂 B(不变量 2)
await tree_client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": a2})
q2 = (
await tree_client.post(f"/api/v1/trees/{tree_id}/nodes", json={"content": "追问"})
).json()
node_q2 = await _node_via_path(tree_client, tree_id, q2["id"])
assert node_q2["parent_node_id"] == a2
# ── 场景 2:采用/归真分离 + 分叉归真 409 ──────────────────────────────────────────────
async def test_adopt_vs_canonize_and_divergence(tree_client: AsyncClient) -> None:
tree_id, q1, _ = await _new_tree_with_question(tree_client, "主脉问题")
a, _ = await _answer(tree_client, q1, "答案 A")
b, _ = await _answer(tree_client, q1, "答案 B")
# 采用 B → 归真 B(真界)→ 切回 A:主脉不变
await tree_client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": b})
canonized = await tree_client.post(
f"/api/v1/trees/{tree_id}/canonize",
json={"endpoint_node_id": b, "canonical_scope": "reality"},
)
assert canonized.status_code == 200
canon_id = canonized.json()["canonization_id"]
await tree_client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": a})
# 归真主脉不受采用切换影响(canonization 不可变)
async with AsyncSessionLocal() as session:
from sqlalchemy import select
from app.models.mirror_tree import Canonization
rows = (await session.execute(select(Canonization))).scalars().all()
assert len(rows) == 1 and rows[0].id == canon_id
assert rows[0].endpoint_node_id == b
# 场景 8:归真与主脉分叉的另一候答 → 409 HJ-1102,附带当前主脉端点
divergent = await tree_client.post(
f"/api/v1/trees/{tree_id}/canonize",
json={"endpoint_node_id": a, "canonical_scope": "reality"},
)
assert divergent.status_code == 409
body = divergent.json()["error"]
assert body["code"] == "HJ-1102"
assert body.get("details", {}).get("current_trunk_head") == b
# 主脉延长(B 之后继续生长再归真)允许
await tree_client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": b})
q2 = (
await client_q(tree_client, tree_id, "延伸问题")
).json()
a_next, _ = await _answer(tree_client, q2["id"])
extension = await tree_client.post(
f"/api/v1/trees/{tree_id}/canonize",
json={"endpoint_node_id": a_next, "canonical_scope": "reality"},
)
assert extension.status_code == 200
async def client_q(client: AsyncClient, tree_id: str, content: str):
return await client.post(f"/api/v1/trees/{tree_id}/nodes", json={"content": content})
# ── 场景 3:编辑分叉 ──────────────────────────────────────────────
async def test_edit_fork_preserves_original(tree_client: AsyncClient) -> None:
tree_id, q1, _ = await _new_tree_with_question(tree_client, "原始问题")
a1, _ = await _answer(tree_client, q1)
fork = (
await tree_client.post(
f"/api/v1/nodes/{q1}/edit-fork", json={"new_content": "改写后的问题"}
)
).json()
assert fork["supersedes_node_id"] == q1
original = await _node_via_path(tree_client, tree_id, q1)
forked = await _node_via_path(tree_client, tree_id, fork["node_id"])
assert original["content"] == "原始问题" # 原文保留
assert forked["content"] == "改写后的问题"
assert forked["parent_node_id"] is None # 与原问节同级(根)
# 旧候答仍可访问
old_answer = await _node_via_path(tree_client, tree_id, a1)
assert old_answer["status"] == "solidified"
# ── 场景 5:残答封存与续生 ──────────────────────────────────────────────
async def test_residual_and_resume(tree_client: AsyncClient) -> None:
tree_id, q1, _ = await _new_tree_with_question(tree_client, "会被中断的问题")
# 用否决监听器制造中断:llm.pre_request 否决 → run.interrupted → 节点封存
from app.main import app
kernel: Kernel = app.state.kernel
async def veto_once(node: str, context) -> None: # noqa: ANN001
raise Veto("中断试演", rule="test_interrupt")
kernel.bus.on("llm.pre_request", veto_once)
try:
spawned = (
await tree_client.post(f"/api/v1/nodes/{q1}/answers", json={})
).json()
status = await _wait_run(tree_client, spawned["run_id"])
assert status == "interrupted"
finally:
kernel.bus._listeners.pop("llm.pre_request", None) # noqa: SLF001
residual_id = spawned["node_id"]
residual = await _node_via_path(tree_client, tree_id, residual_id)
assert residual["status"] == "sealed_residual"
# 续生:新独立节点,不改写残答
resumed = (
await tree_client.post(f"/api/v1/nodes/{residual_id}/resume", json={})
).json()
assert await _wait_run(tree_client, resumed["run_id"]) == "completed"
cont = await _node_via_path(tree_client, tree_id, resumed["node_id"])
assert cont["node_type"] == "continuation"
assert cont["parent_node_id"] == residual_id
residual_after = await _node_via_path(tree_client, tree_id, residual_id)
assert residual_after["status"] == "sealed_residual" # 残答未被改写
assert residual_after["id"] == residual_id
# ── 场景 6:引文快照与后继提示 ──────────────────────────────────────────────
async def test_citation_snapshot_and_newer_version(tree_client: AsyncClient) -> None:
tree_id, q1, _ = await _new_tree_with_question(
tree_client, "带引文的问题",
)
created = await tree_client.post(
f"/api/v1/nodes/{q1}/citations",
json={
"target_type": "pdf_region",
"locator": {"page": 3, "rect": [0.1, 0.2, 0.5, 0.6]},
"snapshot": "第 3 页原文 V1",
"source_id": "doc-001",
"source_version": 1,
},
)
assert created.status_code == 201
# 来源正影为 V2(挂在别处):旧引文不静默替换
q2 = (await client_q(tree_client, tree_id, "另一个问题")).json()
await tree_client.post(
f"/api/v1/nodes/{q2['id']}/citations",
json={
"target_type": "pdf_region",
"locator": {"page": 3},
"snapshot": "第 3 页原文 V2",
"source_id": "doc-001",
"source_version": 2,
},
)
citations = (
await tree_client.get(f"/api/v1/nodes/{q1}/citations")
).json()
assert len(citations) == 1
assert citations[0]["snapshot"] == "第 3 页原文 V1" # 快照保持
assert citations[0]["newer_version_available"] is True # 提示后继版本
# ── 场景 7:移栽 ──────────────────────────────────────────────
async def test_transplant_creates_referenced_tree(tree_client: AsyncClient) -> None:
tree_id, q1, _ = await _new_tree_with_question(tree_client, "被移栽的问题")
a1, _ = await _answer(tree_client, q1, "答案")
await tree_client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": a1})
q2 = (await client_q(tree_client, tree_id, "分支问题")).json()
a2, _ = await _answer(tree_client, q2["id"], "分支答案")
transplanted = (
await tree_client.post(
f"/api/v1/trees/{tree_id}/transplant",
json={"node_id": a2, "new_title": "移栽新树"},
)
).json()
new_tree_id = transplanted["new_tree_id"]
# 新树保留来源谱系引用;节点是全新 ID(引用关联而非物理合并)
new_tree = (await tree_client.get(f"/api/v1/trees/{new_tree_id}")).json()
assert new_tree["transplanted_from"] == {"source_tree_id": tree_id, "source_node_id": a2}
new_path = (
await tree_client.get(
f"/api/v1/trees/{new_tree_id}/path",
params={"entry": transplanted["working_entry_node_id"]},
)
).json()
new_ids = {n["id"] for n in new_path["nodes"]}
assert new_ids.isdisjoint({q1, a1, q2["id"], a2})
assert new_path["nodes"][-1]["content"] == (await _node_via_path(tree_client, tree_id, a2))["content"]
# 原树原样保留
old_path = (
await tree_client.get(f"/api/v1/trees/{tree_id}/path", params={"entry": a2})
).json()
assert old_path["nodes"][-1]["id"] == a2
# ── 三界作用域 ──────────────────────────────────────────────
async def test_realms_and_scopes(tree_client: AsyncClient) -> None:
# 境界归真缺 world_id 被拒
tree_id, q1, _ = await _new_tree_with_question(tree_client, "境界问题", realm="persistent_world")
a1, _ = await _answer(tree_client, q1)
missing_world = await tree_client.post(
f"/api/v1/trees/{tree_id}/canonize",
json={"endpoint_node_id": a1, "canonical_scope": "persistent_world"},
)
assert missing_world.status_code == 400
ok = await tree_client.post(
f"/api/v1/trees/{tree_id}/canonize",
json={
"endpoint_node_id": a1,
"canonical_scope": "persistent_world",
"world_id": "world_demo",
},
)
assert ok.status_code == 200
# 幻境试演:只记录"发生过这次试演",与真界归真互不干扰
tree2, q2, _ = await _new_tree_with_question(tree_client, "幻境问题", realm="simulation")
sim_answer, _ = await _answer(tree_client, q2)
sim = await tree_client.post(
f"/api/v1/trees/{tree2}/canonize",
json={"endpoint_node_id": sim_answer, "canonical_scope": "simulation"},
)
assert sim.status_code == 200
# 入境事件留痕(界域切换可见)
async with AsyncSessionLocal() as session:
from sqlalchemy import select
from app.models.runtime import Event
rows = (
await session.execute(select(Event).where(Event.type == "realm.entered"))
).scalars().all()
assert any(json.loads(r.payload_json).get("tree_id") in (tree_id, tree2) for r in rows)
# ── 芽体保护 ──────────────────────────────────────────────
async def test_budding_protections(tree_client: AsyncClient) -> None:
tree_id, q1, _ = await _new_tree_with_question(tree_client, "保护问题")
# 芽体不可采用/归真(另答后立即操作,echo 极快完成——直接测问节与错误类型路径)
question_adopt = await tree_client.post(
f"/api/v1/trees/{tree_id}/adopt", json={"node_id": q1}
)
assert question_adopt.status_code == 400 # 问节不能作工作入口
question_canonize = await tree_client.post(
f"/api/v1/trees/{tree_id}/canonize",
json={"endpoint_node_id": q1, "canonical_scope": "reality"},
)
assert question_canonize.status_code == 400 # 问节不能作归真端点
# 续思不能基于问节
bad_continue = await tree_client.post(f"/api/v1/nodes/{q1}/continue", json={})
assert bad_continue.status_code == 400