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
+2002
-74
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user