✨ 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:
1 parent
b4242f3844
commit
e333dc5ea5
8 files changed
+1069
No files matched your search
@@ -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"] # 模型真实输出
|
||||
Reference in new issue
Block a user