✨ feat: WP-04 记忆与知识——候选版本闭环、检索管线与大周天(ADR-011/018/023)

- 迁移 9:memory_candidate(来源/敏感度/作用域/状态检查约束)、memory(resource_version 作 ETag 源、有效期、当前版本指针)、memory_version((memory_id, version) 唯一、取代链)、big_cycle(cycle_ 聚合根、phase_memory、产物版本引用)
- 候选闭环:手动提交;白名单自动批准仅限 auto+fact+低敏感(撤销窗 72h 记录于审计);敏感/高影响默认人工审核;批准同事务写 Memory+Version+Audit+Outbox(契约 4.4)
- 纠正:If-Match 校验 resource_version(缺失/过期 412),创建新版本不原地覆盖(ADR-011)
- 删除:立即阻断召回 + 清除全部版本原文(墓碑不存被删原文,契约 4.4)
- 检索管线:数据库层域/角色预过滤 → 状态/有效期过滤 → 文本匹配 → 响应级召回证据(语义检索待 embedding 端点)
- 大周天最小实现:手动触发 → 后台四阶段(回顾/对照/提议/提交)→ 模型真实评估产出 pending persona_candidate(候选先于写入);无端点 fail-closed
- 测试 60 项全绿(+6):审批流、白名单三例、ETag 正/缺失/过期、检索隔离与召回证据、删除传播与墓碑原文清除、大周天端到端
This commit is contained in:
chenxin committed 2026-09-11 10:54:20 +08:00
1 parent b4242f3844
commit e333dc5ea5
8 files changed
+1069

No files matched your search

+173
View File
@@ -1,5 +1,6 @@
"""垂直切片测试:幂等原子事务、IDOR fail-closed、CSRF、SSE 与回滚。"""
import contextlib
import json
import pytest
@@ -708,3 +709,175 @@ class TestGatewayExecution:
assert "content.block.failed" in types
failed = next(e for e in evs if e.type == "content.block.failed")
assert failed.data["error"] == "no_endpoint_available"
@contextlib.asynccontextmanager
async def _test_db():
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from tests.conftest import TEST_DATABASE_URL
engine = create_async_engine(TEST_DATABASE_URL)
factory = async_sessionmaker(engine, expire_on_commit=False)
try:
yield factory
finally:
await engine.dispose()
@pytest.mark.usefixtures("client")
class TestMemories:
async def _submit(self, client, headers, body) -> dict:
resp = await client.post("/v1/memories/candidates", json=body, headers=headers)
assert resp.status_code == 201, resp.text
return resp.json()
async def test_manual_candidate_review_flow(self, client: AsyncClient) -> None:
headers = await login(client, "mem1@example.com")
c = await self._submit(client, headers, {
"kind": "relationship", "sensitivity": "high",
"payload": {"text": "用户偏好深夜交谈", "note": "高影响关系结论"},
})
assert c["status"] == "pending" # 敏感默认人工
approve = await client.post(
f"/v1/memories/candidates/{c['id']}/decisions",
json={"approve": True}, headers=headers,
)
assert approve.status_code == 201
assert approve.json()["status"] == "approved"
assert approve.json()["created_memory_id"] is not None
async def test_whitelist_auto_approve_only_low_sensitivity_fact(self, client) -> None:
headers = await login(client, "mem2@example.com")
auto = await self._submit(client, headers, {
"kind": "fact", "sensitivity": "low", "source": "auto",
"payload": {"text": "用户住在杭州"},
})
assert auto["status"] == "approved" # 白名单自动写入
assert auto["created_memory_id"] is not None
# 高敏感自动候选不自动批准
sensitive = await self._submit(client, headers, {
"kind": "fact", "sensitivity": "high", "source": "auto",
"payload": {"text": "敏感事实"},
})
assert sensitive["status"] == "pending"
# 非 fact 也不自动
pref = await self._submit(client, headers, {
"kind": "preference", "sensitivity": "low", "source": "auto",
"payload": {"text": "喜欢猫"},
})
assert pref["status"] == "pending"
async def test_correction_with_etag_and_conflict(self, client) -> None:
headers = await login(client, "mem3@example.com")
c = await self._submit(client, headers, {
"kind": "fact", "sensitivity": "low", "source": "auto",
"payload": {"text": "原记录"},
})
mid = c["created_memory_id"]
# 缺 If-Match:412
no_header = await client.patch(
f"/v1/memories/{mid}", json={"payload": {"text": "纠正"}}, headers=headers
)
assert no_header.status_code == 412
# 错误版本:412
stale = await client.patch(
f"/v1/memories/{mid}", json={"payload": {"text": "纠正"}},
headers={**headers, "If-Match": '"99"'},
)
assert stale.status_code == 412
# 正确 ETag → v2
ok = await client.patch(
f"/v1/memories/{mid}", json={"payload": {"text": "纠正后"}},
headers={**headers, "If-Match": '"1"'},
)
assert ok.status_code == 200
body = ok.json()
assert body["version"] == 2 and body["payload"]["text"] == "纠正后"
assert body["resource_version"] == 2
async def test_search_filters_and_isolation(self, client) -> None:
headers = await login(client, "mem4@example.com")
for text in ("杭州西湖", "喜欢跑步", "工作在软件园"):
await self._submit(client, headers, {
"kind": "fact", "sensitivity": "low", "source": "auto",
"payload": {"text": text},
})
hits = await client.get("/v1/memories?q=西湖", headers=headers)
data = hits.json()["data"]
assert len(data) == 1 and "西湖" in data[0]["payload"]["text"]
assert data[0]["recall"]["channel"] == "text" # 召回证据
# 跨域隔离
other = await login(client, "mem4-evil@example.com")
leak = await client.get("/v1/memories?q=西湖", headers=other)
assert leak.json()["data"] == []
async def test_delete_purges_content_and_blocks_recall(self, client) -> None:
from sqlalchemy import select as sa_select
from app.models import MemoryVersion as MV
headers = await login(client, "mem5@example.com")
c = await self._submit(client, headers, {
"kind": "fact", "sensitivity": "low", "source": "auto",
"payload": {"text": "将被删除的秘密"},
})
mid = c["created_memory_id"]
resp = await client.delete(f"/v1/memories/{mid}", headers=headers)
assert resp.status_code == 204
# 不再召回
after = await client.get("/v1/memories?q=秘密", headers=headers)
assert after.json()["data"] == []
# 墓碑:版本原文已清除(契约:墓碑不存被删原文)
async with _test_db() as factory:
async with factory() as session:
versions = list(
(
await session.execute(sa_select(MV).where(MV.memory_id == mid))
).scalars().all()
)
assert versions and all(v.payload == {} for v in versions)
@pytest.mark.usefixtures("client")
class TestBigCycle:
async def test_manual_trigger_produces_pending_candidate(self, client, db) -> None:
from app.models import BigCycle, CharacterVersion
headers = await login(client, "bc1@example.com")
c = await client.post("/v1/characters", json={"name": "砚舟"}, headers=headers)
cid = c.json()["id"]
await client.post(f"/v1/characters/{cid}/activations", headers=headers)
# 先有一条对话消息供回顾
conv = await _create_conversation(client, headers)
await client.post(
f"/v1/conversations/{conv}/messages",
json={"content": [{"type": "text", "text": "最近在想什么呢"}], "start_run": False},
headers={**headers, "Idempotency-Key": "idem-bc1"},
)
trigger = await client.post(f"/v1/characters/{cid}/big-cycles", headers=headers)
assert trigger.status_code == 201, trigger.text
cycle_id = trigger.json()["id"]
# 轮询等待 cycle 终态
import asyncio
deadline = asyncio.get_event_loop().time() + 10
cycle = None
while asyncio.get_event_loop().time() < deadline:
async with db() as session:
cycle = await session.get(BigCycle, cycle_id)
if cycle and cycle.status in {"completed", "failed"}:
break
await asyncio.sleep(0.05)
assert cycle is not None and cycle.status == "completed", (
cycle.status if cycle else "missing"
)
assert cycle.produced_version_ids
async with db() as session:
version = await session.get(CharacterVersion, cycle.produced_version_ids[0])
assert version is not None
assert version.kind == "persona_candidate"
assert version.status == "pending" # 候选先于写入
assert version.payload["source"] == "big_cycle"
assert version.payload["proposal"] # 模型真实输出