M3a:息壤三级记忆域——归真触发提取管线/白名单明忆/重忆确认/正影版本链/删除墓碑/双通道召回注入,退役旧记忆-抽取-反思-大周天-演化管线(迁移 004,验收 7 用例)
This commit is contained in:
1 parent
2a640a901b
commit
9c98c2f85e
69 files changed
+1193
-6033
No files matched your search
@@ -0,0 +1,257 @@
|
||||
"""息壤(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 引用供调试断言使用
|
||||
Reference in new issue
Block a user