feat: 第二版——接口契约对齐 docs/05,修复静默故障与数据库基线

相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。

一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
   approve→reviews(需 body decision)、activate→activations、
   rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
   UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
   RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
   输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
   {data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
   不再返回 FastAPI 默认的 {"detail": ...}。

二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。

三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
  顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
  但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
  召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
  .env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。

四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。

五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。

验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
This commit is contained in:
2026-09-10 15:55:54 +08:00
parent 46fc976b24
commit 6516ccb385
151 changed files with 18815 additions and 530 deletions
+264
View File
@@ -0,0 +1,264 @@
"""记忆抽取集成测试(真实 MySQL)。
P2 之后记忆链路的语义变化:
- 记忆键来自**受控词表**(如 `preference:risk_level`),不再是 `conversation.{session_id}`;
- 记忆内容是模型抽取出的**结构化值**,而不是用户原文整句。
抽取依赖模型端点,而本地 `model_endpoint_config` 为 0 行,端点缺失时按设计失败关闭。
因此本文件注入确定性替身抽取器,只验证与模型供应商无关的契约:正文回查、幂等边界、
客户隔离、证据落库与清理。
"""
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from sqlalchemy import delete, select
from app.infrastructure.db import SessionFactory
from app.model.conversation import ConversationMessage
from app.model.memory import MemoryConflict, MemoryEvidence, MemoryUnit
from app.model.platform import AgentRun, DomainEventOutbox, OutboxDelivery, RequestIdempotency
from app.service.memory_extraction_service import ExtractedMemory
from app.service.memory_service import MemoryService
from app.worker.memory_extraction_worker import MemoryExtractionWorker
from app.worker.outbox_worker import OutboxWorker
KEY_PREFIX = "it-memory-"
MEMORY_KEY = "preference:risk_level"
EXTRACTED_VALUE = "稳健型"
USER_FACT = f"{KEY_PREFIX}我偏好低风险稳健型基金"
OTHER_FACT = f"{KEY_PREFIX}本人可承受中等风险"
THIRD_FACT = f"{KEY_PREFIX}本人只做货币基金"
PLAN_FACT = f"{KEY_PREFIX}我计划两年内买房"
ASSISTANT_REPLY = "已为您记录该偏好。"
class StubExtractor:
"""确定性替身抽取器:记录收到的正文,返回固定抽取结果,不发网络请求。"""
def __init__(self, value: str = EXTRACTED_VALUE) -> None:
self.value = value
self.messages: list[str] = []
async def extract(
self, *, message: str, agent_type: str = "customer_service"
) -> ExtractedMemory:
del agent_type
self.messages.append(message)
return ExtractedMemory(
memory_key=MEMORY_KEY,
value=self.value,
memory_type="preference",
confidence=0.9,
)
class Case:
def __init__(self, customer_id: int, run_id: str, session_id: str) -> None:
self.customer_id = customer_id
self.run_id = run_id
self.session_id = session_id
@property
def memory_key(self) -> str:
"""抽取结果对应的受控键;同一客户的记忆键与客户一一对应,便于隔离断言。"""
return MEMORY_KEY
def new_case(offset: int) -> Case:
return Case(
customer_id=uuid4().int % 10**14 + offset * 10**14,
run_id=str(uuid4()),
session_id=f"{KEY_PREFIX}{uuid4().hex[:24]}",
)
async def seed(case: Case, *, user_fact: str) -> int:
"""建立 run 到用户消息与助手消息的真实链路,事件只携带 message_id。"""
now = datetime.now(UTC).replace(tzinfo=None)
async with SessionFactory() as session, session.begin():
request_message = ConversationMessage(
session_id=case.session_id, customer_id=case.customer_id, portal="api", role="user",
content=user_fact, trace_id=str(uuid4()), created_at=now,
)
session.add(request_message)
await session.flush()
result_message = ConversationMessage(
session_id=case.session_id, customer_id=case.customer_id, portal="agent",
role="assistant", content=ASSISTANT_REPLY, trace_id=str(uuid4()), created_at=now,
)
session.add(result_message)
await session.flush()
idempotency = RequestIdempotency(
user_id=case.customer_id, session_id=case.session_id, agent_type="customer_service",
idempotency_key=str(uuid4()), request_hash=uuid4().hex * 2, trace_id=str(uuid4()),
status="completed", expire_at=now, created_at=now, updated_at=now,
)
session.add(idempotency)
await session.flush()
session.add(AgentRun(
run_id=case.run_id, idempotency_id=idempotency.id, session_id=case.session_id,
user_id=case.customer_id, agent_type="customer_service", trace_id=str(uuid4()),
request_message_id=request_message.id, result_message_id=result_message.id,
status="succeeded", attempt_count=1, created_at=now, updated_at=now,
))
return result_message.id
async def enqueue(case: Case, message_id: int, *, event_id: str) -> None:
now = datetime.now(UTC).replace(tzinfo=None)
async with SessionFactory() as session, session.begin():
session.add(DomainEventOutbox(
event_id=event_id, event_type="memory.extraction_requested",
aggregate_type="agent_run", aggregate_id=case.run_id, trace_id=str(uuid4()),
payload={"run_id": case.run_id, "message_id": message_id,
"customer_id": case.customer_id},
status="pending", retry_count=0, occurred_at=now, created_at=now, updated_at=now,
))
async def consume_once(case: Case, extractor: StubExtractor) -> bool:
async with SessionFactory() as session:
worker = OutboxWorker(session, {
"memory.extraction_requested": MemoryExtractionWorker(
session, extractor=extractor
).handle,
})
return await worker.publish_one(aggregate_id=case.run_id)
async def realtime_seen_count(extractor: StubExtractor, fact: str) -> int:
return sum(1 for message in extractor.messages if message == fact)
async def cleanup(cases: list[Case]) -> None:
run_ids = [case.run_id for case in cases]
session_ids = [case.session_id for case in cases]
customer_ids = [case.customer_id for case in cases]
async with SessionFactory() as session, session.begin():
message_ids = list(await session.scalars(select(ConversationMessage.id).where(
ConversationMessage.session_id.in_(session_ids))))
memory_ids = list(await session.scalars(select(MemoryUnit.id).where(
MemoryUnit.customer_id.in_(customer_ids))))
# 测试证据只可能挂在本次测试的会话消息或本次测试客户的记忆上。
await session.execute(delete(MemoryEvidence).where(
(MemoryEvidence.source_record_id.in_([str(item) for item in message_ids]))
| (MemoryEvidence.memory_id.in_(memory_ids))))
await session.execute(delete(MemoryConflict).where(
(MemoryConflict.left_memory_id.in_(memory_ids))
| (MemoryConflict.right_memory_id.in_(memory_ids))))
await session.execute(delete(MemoryUnit).where(
MemoryUnit.customer_id.in_(customer_ids)))
event_ids = select(DomainEventOutbox.event_id).where(
DomainEventOutbox.aggregate_id.in_(run_ids))
await session.execute(delete(OutboxDelivery).where(OutboxDelivery.event_id.in_(event_ids)))
await session.execute(delete(DomainEventOutbox).where(
DomainEventOutbox.aggregate_id.in_(run_ids)))
await session.execute(delete(AgentRun).where(AgentRun.run_id.in_(run_ids)))
await session.execute(delete(RequestIdempotency).where(
RequestIdempotency.session_id.in_(session_ids)))
await session.execute(delete(ConversationMessage).where(
ConversationMessage.session_id.in_(session_ids)))
@pytest.mark.integration
@pytest.mark.asyncio
async def test_duplicate_consumption_produces_single_memory() -> None:
case = new_case(9)
extractor = StubExtractor()
event_id = str(uuid4())
try:
message_id = await seed(case, user_fact=USER_FACT)
await enqueue(case, message_id, event_id=event_id)
assert await consume_once(case, extractor)
# 重复投递同一事件:事件回到 pending 后再次消费。
async with SessionFactory() as session, session.begin():
event = await session.scalar(select(DomainEventOutbox).where(
DomainEventOutbox.event_id == event_id))
assert event is not None
event.status = "pending"
event.published_at = None
assert await consume_once(case, extractor)
async with SessionFactory() as session:
memories = list(await session.scalars(select(MemoryUnit).where(
MemoryUnit.customer_id == case.customer_id,
MemoryUnit.memory_key == case.memory_key)))
assert len(memories) == 1
# 落库的是抽取结果,不是用户原文整句。
assert memories[0].content == EXTRACTED_VALUE
assert memories[0].content != USER_FACT
assert memories[0].memory_type == "preference"
assert memories[0].evidence_count <= 1
evidence = list(await session.scalars(select(MemoryEvidence).where(
MemoryEvidence.idempotency_key == f"memory.extraction_requested:{event_id}")))
assert len(evidence) == 1
assert evidence[0].memory_id == memories[0].id
assert evidence[0].source_record_id is not None
finally:
await cleanup([case])
@pytest.mark.integration
@pytest.mark.asyncio
async def test_memory_is_not_recallable_across_customers() -> None:
owner = new_case(8)
other = new_case(7)
# 两个客户用不同的抽取值,才能用内容区分"谁记住了什么"——受控键相同,
# 单靠 memory_key 无法判定归属。
owner_extractor = StubExtractor("稳健型")
other_extractor = StubExtractor("激进型")
try:
owner_message = await seed(owner, user_fact=OTHER_FACT)
other_message = await seed(other, user_fact=THIRD_FACT)
await enqueue(owner, owner_message, event_id=str(uuid4()))
await enqueue(other, other_message, event_id=str(uuid4()))
assert await consume_once(owner, owner_extractor)
assert await consume_once(other, other_extractor)
async with SessionFactory() as session:
service = MemoryService(session)
owner_memories = await service.recall(owner.customer_id)
other_memories = await service.recall(other.customer_id)
assert [item.memory_key for item in owner_memories] == [owner.memory_key]
assert [item.memory_key for item in other_memories] == [other.memory_key]
assert all(item.customer_id == owner.customer_id for item in owner_memories)
assert all(item.customer_id == other.customer_id for item in other_memories)
assert owner_memories[0].content == "稳健型"
assert other_memories[0].content == "激进型"
# 客户范围过滤:即使知道他人的内容,也查不到挂在他人名下的行。
assert await session.scalar(select(MemoryUnit.id).where(
MemoryUnit.customer_id == owner.customer_id,
MemoryUnit.content == "激进型")) is None
assert await session.scalar(select(MemoryUnit.id).where(
MemoryUnit.customer_id == other.customer_id,
MemoryUnit.content == "稳健型")) is None
finally:
await cleanup([owner, other])
@pytest.mark.integration
@pytest.mark.asyncio
async def test_worker_reads_user_content_not_assistant_reply() -> None:
case = new_case(6)
extractor = StubExtractor()
try:
message_id = await seed(case, user_fact=PLAN_FACT)
await enqueue(case, message_id, event_id=str(uuid4()))
assert await consume_once(case, extractor)
# 抽取器收到的必须是库中的用户消息正文,而不是助手回复。
assert extractor.messages == [PLAN_FACT]
assert ASSISTANT_REPLY not in extractor.messages[0]
async with SessionFactory() as session:
memory = await session.scalar(select(MemoryUnit).where(
MemoryUnit.customer_id == case.customer_id,
MemoryUnit.memory_key == case.memory_key))
assert memory is not None
assert memory.content == EXTRACTED_VALUE
finally:
await cleanup([case])