258 lines
11 KiB
Python
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 引用供调试断言使用
|