相对第一版 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(含失败关闭反证)。
187 lines
7.5 KiB
Python
187 lines
7.5 KiB
Python
"""C1 回归:转人工申请只能有一条路由,且必须真正写入 Outbox 事件。
|
|
|
|
历史上 `conversations.py` 与 `public_platform.py` 各注册了一份
|
|
`POST /api/v1/conversations/{session_id}/handover-requests`,前者先注册生效且
|
|
只写审计,导致转人工的异步链路(Outbox → Worker)永不触发。本文件用真实 HTTP
|
|
请求 + 真实 MySQL 断言唯一路由与事件落库。
|
|
"""
|
|
|
|
import asyncio
|
|
from collections.abc import Iterator
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import delete, select, text
|
|
|
|
from app.api.dependencies.auth import build_request_context
|
|
from app.core.contracts import RequestContext
|
|
from app.infrastructure.db import SessionFactory
|
|
from app.main import app
|
|
from app.model.audit import InteractionAudit
|
|
from app.model.conversation import ConversationMessage
|
|
from app.model.platform import DomainEventOutbox, HandoverTicket
|
|
from app.model.session import ConversationSession
|
|
|
|
HANDOVER_PATH = "/api/v1/conversations/{session_id}/handover-requests"
|
|
TEST_USER_ID = 9001 # 与 tools/seed_test_rbac.py 的测试客户号段一致
|
|
|
|
|
|
def _flatten(routes: Any) -> Iterator[Any]:
|
|
"""展开 include_router 的结果。
|
|
|
|
当前 FastAPI 版本把 include_router 的记录保留为 `_IncludedRouter` 而不摊平
|
|
到 `app.routes`,查重必须先递归展开到叶子路由。
|
|
"""
|
|
for route in routes:
|
|
inner = getattr(route, "original_router", None)
|
|
if inner is not None:
|
|
yield from _flatten(inner.routes)
|
|
else:
|
|
yield route
|
|
|
|
|
|
def test_handover_route_is_registered_exactly_once_by_writing_controller() -> None:
|
|
all_routes = list(_flatten(app.routes))
|
|
routes = [
|
|
route
|
|
for route in all_routes
|
|
if getattr(route, "path", None) == HANDOVER_PATH
|
|
and "POST" in (getattr(route, "methods", None) or set())
|
|
]
|
|
assert len(routes) == 1, [getattr(route, "path", None) for route in all_routes]
|
|
# 唯一入口必须落在写 Outbox 的那份实现上。
|
|
assert routes[0].endpoint.__module__ == "app.api.controllers.public_platform"
|
|
|
|
seen: list[tuple[str, str]] = [
|
|
(method, getattr(route, "path", ""))
|
|
for route in all_routes
|
|
for method in (getattr(route, "methods", None) or set())
|
|
]
|
|
duplicates = sorted({item for item in seen if seen.count(item) > 1})
|
|
assert duplicates == [], f"存在重复注册的路由: {duplicates}"
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_handover_request_writes_ticket_event_and_audit() -> None:
|
|
session_id = f"it-handover-{uuid4().hex}"
|
|
key = f"it-handover-key-{uuid4().hex}"
|
|
handover_id = ""
|
|
|
|
async def prepare() -> None:
|
|
async with SessionFactory() as db:
|
|
db.add(
|
|
ConversationSession(
|
|
session_id=session_id, user_id=TEST_USER_ID, portal="api",
|
|
agent_type="customer_service", status="active",
|
|
clarification_round=0, message_count=1,
|
|
)
|
|
)
|
|
await db.flush()
|
|
db.add(
|
|
ConversationMessage(
|
|
session_id=session_id, message_no=1, customer_id=TEST_USER_ID,
|
|
portal="api", role="user", content="我要转人工",
|
|
created_at=datetime.now(UTC).replace(tzinfo=None),
|
|
)
|
|
)
|
|
await db.commit()
|
|
|
|
async def context() -> RequestContext:
|
|
return RequestContext(
|
|
user_id=str(TEST_USER_ID), trace_id=str(uuid4()),
|
|
roles=("customer",), permissions=("handover:create",),
|
|
)
|
|
|
|
app.dependency_overrides[build_request_context] = context
|
|
asyncio.run(prepare())
|
|
try:
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
HANDOVER_PATH.format(session_id=session_id),
|
|
json={"reason_code": "user_requested", "reason_detail": "集成测试转人工"},
|
|
headers={"Idempotency-Key": key},
|
|
)
|
|
assert response.status_code == 202, response.text
|
|
body = response.json()
|
|
handover_id = body["data"]["handover_id"]
|
|
assert handover_id.startswith("ticket-")
|
|
assert body["data"]["session_id"] == session_id
|
|
assert body["data"]["status"] == "pending"
|
|
|
|
async def verify() -> tuple[HandoverTicket | None, DomainEventOutbox | None,
|
|
list[InteractionAudit], list[InteractionAudit]]:
|
|
async with SessionFactory() as db:
|
|
ticket = await db.scalar(
|
|
select(HandoverTicket).where(HandoverTicket.ticket_no == handover_id)
|
|
)
|
|
event = await db.scalar(
|
|
select(DomainEventOutbox).where(
|
|
DomainEventOutbox.event_type == "conversation.transfer_requested",
|
|
DomainEventOutbox.aggregate_id == session_id,
|
|
)
|
|
)
|
|
audits = list(
|
|
await db.scalars(
|
|
select(InteractionAudit).where(
|
|
InteractionAudit.action_type == "platform.handover",
|
|
InteractionAudit.session_id == session_id,
|
|
)
|
|
)
|
|
)
|
|
legacy = list(
|
|
await db.scalars(
|
|
select(InteractionAudit).where(
|
|
InteractionAudit.action_type == "conversation.transfer_requested",
|
|
InteractionAudit.session_id == session_id,
|
|
)
|
|
)
|
|
)
|
|
return ticket, event, audits, legacy
|
|
|
|
ticket, event, audits, legacy = asyncio.run(verify())
|
|
assert ticket is not None and ticket.status == "pending"
|
|
assert ticket.source_message_id is not None # 取自会话首条消息
|
|
assert event is not None, "转人工必须写 Outbox 事件"
|
|
assert event.aggregate_type == "conversation"
|
|
assert event.status == "pending"
|
|
assert event.payload["ticket_no"] == handover_id
|
|
assert len(audits) == 1
|
|
assert legacy == [], "旧实现(只写审计、不发事件)不得再被调用"
|
|
finally:
|
|
async def cleanup() -> None:
|
|
async with SessionFactory() as db:
|
|
await db.execute(
|
|
delete(HandoverTicket).where(HandoverTicket.session_id == session_id)
|
|
)
|
|
await db.execute(
|
|
delete(DomainEventOutbox).where(
|
|
DomainEventOutbox.aggregate_id == session_id
|
|
)
|
|
)
|
|
await db.execute(
|
|
delete(InteractionAudit).where(
|
|
InteractionAudit.session_id == session_id
|
|
)
|
|
)
|
|
await db.execute(
|
|
delete(ConversationMessage).where(
|
|
ConversationMessage.session_id == session_id
|
|
)
|
|
)
|
|
await db.execute(
|
|
delete(ConversationSession).where(
|
|
ConversationSession.session_id == session_id
|
|
)
|
|
)
|
|
await db.execute(
|
|
text("DELETE FROM api_request_receipt WHERE idempotency_key = :key"),
|
|
{"key": key},
|
|
)
|
|
await db.commit()
|
|
|
|
asyncio.run(cleanup())
|
|
app.dependency_overrides.clear()
|