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
+60 -9
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio
import datetime as dt
import sys
import uuid
from pathlib import Path
from typing import Any
@@ -32,6 +33,7 @@ from app.model.audit import InteractionAudit
from app.model.conversation import ConversationMessage
from app.model.platform import AgentRun, DomainEventOutbox, OutboxDelivery, RequestIdempotency
from app.service.agent.base import BaseAgent
from app.service.agent.bootstrap import get_agent_factory
from app.service.agent.factory import AgentFactory
from app.worker.runtime import WorkerRuntime
@@ -85,22 +87,67 @@ class AcceptanceAgent(BaseAgent):
return CoreResult(text="独立验收通过")
def build_registry() -> AgentFactory:
factory = AgentFactory(StubGovernance())
def register_probes(factory: AgentFactory, *, version: str = "acceptance") -> None:
"""向工厂注册两个探针 Agent(customer_service / risk)。
生产代码不注册业务 Agent(底座只提供框架),而验收需要走完整 HTTP 链路,
因此通过工厂的公开 `register` 接口注册——这与业务组员接入 Agent 的方式一致。
"""
for agent_type, roles in (
("customer_service", ("customer",)),
("risk", ("risk_operator",)),
):
definition = AgentDefinition(
agent_type=agent_type,
version="acceptance",
version=version,
allowed_roles=roles,
allowed_portals=("api",),
)
factory.register(definition, lambda _ctx, _def=definition: AcceptanceAgent(_def))
def build_registry() -> AgentFactory:
"""替身治理装配:供 HTTP 契约验收使用(**不验证生产装配**)。"""
factory = AgentFactory(StubGovernance())
register_probes(factory)
return factory
def production_factory() -> AgentFactory:
"""生产装配 + 探针 Agent,并**断言生产装配完整**。
与旧做法的关键差别:不再无条件把测试工厂塞进生产模块的全局名字——那样做会让
"生产装配到底可不可用"完全验收不到。这里直接用生产工厂,先断言模型服务、
工具执行器、意图分类器都已注入,再把探针 Agent 注册进去。
"""
factory = get_agent_factory()
missing = [
name
for name, value in (
("model_service", factory._model_service),
("tool_executor", factory._tool_executor),
("intent_classifier", factory._intent_classifier),
)
if value is None
]
if missing:
raise SystemExit(f"生产装配不完整,缺少:{missing}")
register_probes(factory, version="acceptance-production")
return factory
def inject_factory(factory: AgentFactory) -> None:
"""把替身工厂绑定到服务模块的全局名字——**仅限默认的 HTTP 契约验收模式**。
这是本脚本唯一的注入手段:Controller 直接构造 `AgentRunApplicationService(session)`
而不接收工厂,因此没有依赖注入点可用。生产装配的验证由 `--production` 模式与
`tools/memory_chain_probe.py` 承担,不会被这个注入掩盖。
"""
for module in (run_service, public_platform_service, admin_service):
if hasattr(module, "get_agent_factory"):
module.get_agent_factory = lambda: factory # type: ignore[attr-defined]
async def _collect_events(
client: httpx.AsyncClient, run_id: str, auth: dict[str, str]
) -> list[str]:
@@ -141,10 +188,13 @@ async def cleanup(session_id: str, run_id: str | None) -> None:
async def main() -> None:
factory = build_registry()
for module in (run_service, public_platform_service, admin_service):
if hasattr(module, "get_agent_factory"):
module.get_agent_factory = lambda: factory # type: ignore[attr-defined]
if "--production" in sys.argv:
factory = production_factory()
print("模式:生产装配验收(真实治理链 + 探针 Agent)")
else:
factory = build_registry()
inject_factory(factory)
print("模式:HTTP 契约验收(替身治理)——未验证生产装配;加 --production 验生产链路")
app = create_app()
session_id = f"acceptance-{uuid.uuid4()}"
@@ -167,7 +217,7 @@ async def main() -> None:
)
record("客户调用已授权 Agent", "202", str(response.status_code))
if response.status_code == 202:
run_id = response.json().get("run_id")
run_id = response.json().get("data", {}).get("run_id")
response = await client.post(
"/api/v1/agent-runs",
@@ -207,7 +257,8 @@ async def main() -> None:
record("SSE 终止事件", "done", events[-1] if events else "无事件")
response = await client.get(f"/api/v1/agent-runs/{run_id}", headers=auth)
body = response.json()
# 文档 §3.3:单资源成功响应是 {data, meta} 信封。
body = response.json().get("data") or {}
record("运行终态", "succeeded", str(body.get("status")))
record(
"运行结果内容",