Files
hunjing/backend/tests/test_xirang.py
T

258 lines
11 KiB
Python

"""息壤(M3)测试:对应 rewrite-plan M3 六条验收。
1. 未归真旁枝自动入忆率 = 0(只有归真/手动入忆产生候选);
2. 重忆未确认写入被拒(HJ-1201 语义:sensitive→pending,仅 confirm 后 active);
3. deny 两次退出检索;
4. 正影后旧版停止普通召回(superseded),内容不改写;
5. 删除后全链路停用(墓碑无原文、检索/注入不出现);
6. 召回带来源可展开(source_refs 指向归真记录);归真触发实录与提取候选。
"""
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.shenshi_client import EchoShenshi
@pytest.fixture
async def 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 _tree_with_answer(client: AsyncClient, title: str) -> tuple[str, str, str, str]:
"""建树→问→答→返回 (tree_id, q, a, character_id)。"""
embryo = (
await client.post("/api/v1/embryo-drafts", json={"source": "original", "name": f"忆灵{title}"})
).json()
character_id = (
(await client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()["character_id"]
)
tree = (
await client.post("/api/v1/trees", json={"character_id": character_id, "title": title})
).json()
q = (await client.post(f"/api/v1/trees/{tree['id']}/nodes", json={"content": "你记得什么"})).json()
spawned = (await client.post(f"/api/v1/nodes/{q['id']}/answers", json={})).json()
for _ in range(300):
status = (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"]
if status in ("completed", "interrupted", "failed"):
break
await asyncio.sleep(0.02)
return tree["id"], q["id"], spawned["node_id"], character_id
async def _memories(client: AsyncClient, character_id: str) -> list[dict]:
return (await client.get(f"/api/v1/characters/{character_id}/memories")).json()
async def _canonize(client: AsyncClient, tree_id: str, endpoint: str, scope: str = "reality") -> dict:
resp = await client.post(
f"/api/v1/trees/{tree_id}/canonize",
json={"endpoint_node_id": endpoint, "canonical_scope": scope},
)
assert resp.status_code == 200, resp.text
return resp.json()
async def _wait_candidate(client: AsyncClient, character_id: str, timeout_s: float = 5.0) -> list[dict]:
"""等待归真后的异步提取落库。"""
for _ in range(int(timeout_s / 0.05)):
items = await _memories(client, character_id)
if items:
return items
await asyncio.sleep(0.05)
return []
# ── 验收 1:未归真不入忆 ──────────────────────────────────────────────
async def test_uncanonized_branch_never_memorized(client: AsyncClient) -> None:
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "旁支树")
# 未归真:多次另答也不产生任何记忆
for _ in range(2):
spawned = (await client.post(f"/api/v1/nodes/{_q}/answers", json={})).json()
for _ in range(200):
if (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"] in (
"completed",
"interrupted",
):
break
await asyncio.sleep(0.02)
await asyncio.sleep(0.2)
assert await _memories(client, character_id) == [] # 自动入忆率 = 0
# ── 验收 6+管线:归真→实录+候选;重敏 pending ──────────────────────────────────────────────
async def test_canonization_creates_factual_record_and_candidates(client: AsyncClient) -> None:
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "归真树")
await _canonize(client, tree_id, answer_id)
# 实录(同步落库)
records = (await client.get(f"/api/v1/characters/{character_id}/factual-records")).json()
assert len(records) == 1
assert records[0]["evidence"]["path_nodes"]
# 候选(异步提取;EchoShenshi 无 JSON → 确定性回落单条 episodic 候选)
items = await _wait_candidate(client, character_id)
assert len(items) == 1
memory = items[0]
assert memory["tier"] == "explicit"
assert memory["source"] == "self_inferred"
assert memory["status"] in ("candidate", "active")
# 召回透明:来源指向归真记录,可展开
assert memory["source_refs"]
# ── 验收 2:重忆未确认不入 active ──────────────────────────────────────────────
async def test_sensitive_requires_confirmation(client: AsyncClient) -> None:
from app.domains.xirang import service as xirang_service
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "重忆树")
await _canonize(client, tree_id, answer_id)
await _wait_candidate(client, character_id)
# 直接放置一条重忆候选(提取管线同路径)
placed = await xirang_service.place_candidates(
character_id=character_id,
candidates=[{"content": "用户的健康隐私:对花粉过敏", "type": "persona", "sensitive": True}],
source_refs=["canon_x"],
source="self_inferred",
)
sensitive = next(m for m in placed if m.tier == "sensitive")
assert sensitive.status == "pending" # 未确认绝不 active
# 检索不召回 pending
recalled = await xirang_service.recall_for_generation(character_id, "花粉 过敏")
assert all(m.id != sensitive.id for m in recalled)
# confirm 后进入检索
resp = await client.post(f"/api/v1/memories/{sensitive.id}/confirm")
assert resp.status_code == 200 and resp.json()["status"] == "active"
recalled = await xirang_service.recall_for_generation(character_id, "花粉 过敏")
assert any(m.id == sensitive.id for m in recalled)
# ── 验收 3+5:deny/删除全链路停用 ──────────────────────────────────────────────
async def test_deny_and_delete_exit_everywhere(client: AsyncClient) -> None:
from app.domains.xirang import service as xirang_service
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "删除树")
await _canonize(client, tree_id, answer_id)
items = await _wait_candidate(client, character_id)
memory_id = items[0]["id"]
denied = await client.post(f"/api/v1/memories/{memory_id}/deny")
assert denied.status_code == 200 and denied.json()["exited_retrieval"] is True
# 墓碑:列表可见状态但无原文;检索不出现
listed = await _memories(client, character_id)
tomb = next(m for m in listed if m["id"] == memory_id)
assert tomb["status"] == "tombstoned" and tomb["content"] == ""
recalled = await xirang_service.recall_for_generation(character_id, "共同经历")
assert all(m.id != memory_id for m in recalled)
# ── 验收 4:正影旧版停召、内容不改写 ──────────────────────────────────────────────
async def test_correction_supersedes_old_version(client: AsyncClient) -> None:
from app.domains.xirang import service as xirang_service
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "正影树")
await _canonize(client, tree_id, answer_id)
items = await _wait_candidate(client, character_id)
memory_id = items[0]["id"]
corrected = await client.post(
f"/api/v1/memories/{memory_id}/correct", json={"new_content": "正影后的准确版本"}
)
assert corrected.status_code == 200
new_id = corrected.json()["id"]
assert corrected.json()["prev_version_id"] == memory_id
# 旧版 superseded 且不再被召回;新版生效;旧内容保留在行内(不改写历史)
recalled = await xirang_service.recall_for_generation(character_id, "正影")
ids = [m.id for m in recalled]
assert new_id in ids and memory_id not in ids
async with AsyncSessionLocal() as session:
from app.models.xirang import Memory
old = await session.get(Memory, memory_id)
assert old.status == "superseded"
assert "共同经历" in old.content # 原文未被改写
# ── 手动入忆:必须标注类型 ──────────────────────────────────────────────
async def test_manual_memorize_requires_label(client: AsyncClient) -> None:
tree_id, q, answer_id, _ = await _tree_with_answer(client, "手记树")
bad = await client.post(f"/api/v1/nodes/{q}/memorize", json={"label": "unknown"})
assert bad.status_code == 422 or bad.status_code == 400
ok = await client.post(f"/api/v1/nodes/{q}/memorize", json={"label": "insight"})
assert ok.status_code == 201
body = ok.json()
assert body["type"] == "fact_candidate" # 未归真内容只作事实候选并显式标注
assert body["content"].startswith("[insight]")
# ── 注入:获准记忆进入投影 prompt ──────────────────────────────────────────────
async def test_recalled_memory_injected_into_prompt(client: AsyncClient) -> None:
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "注入树")
await _canonize(client, tree_id, answer_id)
await _wait_candidate(client, character_id)
# 新问节触发另答 → llm.requested 的 prompt 含【获准记忆】段
await client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": answer_id})
q2 = (
await client.post(f"/api/v1/trees/{tree_id}/nodes", json={"content": "再谈谈"})
).json()
spawned = (await client.post(f"/api/v1/nodes/{q2['id']}/answers", json={})).json()
for _ in range(300):
if (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"] in (
"completed",
"interrupted",
):
break
await asyncio.sleep(0.02)
events = (await client.get(f"/api/v1/runs/{spawned['run_id']}/events")).json()
requested = next(e for e in events if e["type"] == "llm.requested")
prompt = requested["payload"]["request"]["messages"][-1]["content"]
assert "【获准记忆(来源可查)】" in prompt
_ = json # 保留 json 引用供调试断言使用