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
@@ -0,0 +1,180 @@
"""运行期意图配置(`agent_intent_config`)生效证明(不连数据库)。
重点不是"读了配置",而是**改了配置 → 分类行为随之改变**:
1. 分类提示词里出现配置的意图名/描述/示例/分类要求;
2. 同一份模型输出下,配置里的 `confidence_threshold` 直接改变 `needs_clarification`;
3. 配置不改变既有契约:严格 JSON、未声明意图失败关闭,配置里多出来的意图既不进提示
也不放宽校验;
4. 装载只读 `status='active'` 的行(用编译后的 SQL 断言生效判定条件)。
"""
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
import pytest
from app.core.errors import RecoverableAgentError, ValidationAgentError
from app.model.configuration import AgentIntentConfig
from app.service.intent_classifier import IntentClassifier, IntentConfigEntry
from app.service.model_gateway import ModelExecution
from app.service.runtime_config_service import RuntimeConfigService
class RecordingModel:
"""记录每次分类的提示词,用于断言"配置改了,提示词就不同"。"""
def __init__(self, text: str) -> None:
self.text = text
self.prompts: list[str] = []
async def generate(self, _endpoints: list[object], prompt: str) -> ModelExecution:
self.prompts.append(prompt)
return ModelExecution("intent", self.text, 1)
AGENT_TYPE = "fund_query_demo"
SUPPORTED = ("fund_quote", "general")
def loader_returning(*entries: IntentConfigEntry) -> Any:
async def load(_agent_type: str) -> tuple[IntentConfigEntry, ...]:
return entries
return load
async def classify(classifier: IntentClassifier, message: str = "159382 的行情") -> Any:
return await classifier.classify(
message=message,
supported_intents=SUPPORTED,
endpoints=[object()],
agent_type=AGENT_TYPE,
)
async def test_active_intent_config_changes_classification_prompt() -> None:
model = RecordingModel('{"intent":"fund_quote","confidence":0.9}')
entry = IntentConfigEntry(
intent_code="fund_quote",
intent_name="场内基金行情查询",
description="查询场内基金最新净值与涨跌信息",
examples=("帮我看看 159382 的行情", "查一下这只基金净值"),
classifier_instruction="只处理场内基金代码与行情类问题",
)
await classify(IntentClassifier(model))
await classify(IntentClassifier(model, config_loader=loader_returning(entry)))
without_config, with_config = model.prompts
# 生效配置的意图名/描述/示例/分类要求进入提示词——这就是"配置生效"的可观测差异。
assert "查询场内基金最新净值与涨跌信息" not in without_config
assert "场内基金行情查询" in with_config
assert "帮我看看 159382 的行情" in with_config
assert "只处理场内基金代码与行情类问题" in with_config
# 严格 JSON 输出契约不因配置而改变。
for prompt in (without_config, with_config):
assert "请仅输出 JSON" in prompt
assert "intent 和 confidence" in prompt
async def test_configured_threshold_flips_needs_clarification() -> None:
"""同一模型输出(confidence=0.70),只有配置阈值不同,判定结果必须不同。"""
model = RecordingModel('{"intent":"fund_quote","confidence":0.70}')
default = await classify(IntentClassifier(model))
strict = await classify(
IntentClassifier(
model,
config_loader=loader_returning(
IntentConfigEntry(intent_code="fund_quote", confidence_threshold=0.9)
),
)
)
assert (default.intent, default.needs_clarification) == ("fund_quote", False)
assert (strict.intent, strict.needs_clarification) == ("fund_quote", True)
async def test_config_cannot_declare_intent_outside_definition() -> None:
"""配置里的意图必须落在 `AgentDefinition.supported_intents` 内,否则既不进提示也不放宽校验。"""
model = RecordingModel('{"intent":"trade","confidence":1}')
classifier = IntentClassifier(
model,
config_loader=loader_returning(
IntentConfigEntry(intent_code="trade", description="客户想直接下单交易")
),
)
with pytest.raises(ValidationAgentError):
await classify(classifier, message="帮我下单")
assert "客户想直接下单交易" not in model.prompts[0]
assert "trade" not in model.prompts[0]
async def test_fail_closed_survives_active_config() -> None:
"""配置生效时,格式违约仍然失败关闭。"""
classifier = IntentClassifier(
RecordingModel("不是 JSON"),
config_loader=loader_returning(IntentConfigEntry(intent_code="fund_quote")),
)
with pytest.raises(RecoverableAgentError):
await classify(classifier)
class FakeSession:
"""只实现 `scalars`:记录编译后的 SQL,并返回预置行。"""
def __init__(self, rows: list[Any]) -> None:
self.rows = rows
self.sql: list[str] = []
async def scalars(self, statement: Any) -> list[Any]:
self.sql.append(str(statement.compile(compile_kwargs={"literal_binds": True})))
return self.rows
def active_row() -> AgentIntentConfig:
now = datetime.now(UTC).replace(tzinfo=None)
return AgentIntentConfig(
id=7,
agent_type=AGENT_TYPE,
intent_code="fund_quote",
intent_name="场内基金行情查询",
description="查询场内基金最新净值与涨跌信息",
examples=["帮我看看 159382 的行情"],
classifier_instruction="只处理行情查询",
confidence_threshold=Decimal("0.6000"),
max_clarification_rounds=2,
transfer_on_failure=True,
priority=100,
version=1,
status="active",
created_by=1,
created_at=now,
updated_at=now,
)
async def test_runtime_loader_reads_only_active_rows_and_maps_fields() -> None:
session = FakeSession([active_row()])
entries = await RuntimeConfigService(session).active_intents(AGENT_TYPE) # type: ignore[arg-type]
sql = session.sql[0]
assert "agent_intent_config.agent_type = 'fund_query_demo'" in sql
# 生效判定口径:只认 status='active',并尊重有效期窗口。
assert "agent_intent_config.status = 'active'" in sql
assert "agent_intent_config.effective_at IS NULL OR" in sql
assert "agent_intent_config.expire_at IS NULL OR" in sql
assert entries == (
IntentConfigEntry(
intent_code="fund_quote",
intent_name="场内基金行情查询",
description="查询场内基金最新净值与涨跌信息",
examples=("帮我看看 159382 的行情",),
classifier_instruction="只处理行情查询",
confidence_threshold=0.6,
),
)