一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
(输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)
二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数
三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"
四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
(端点与账号均已不存在,此前稳定报 3 条假红)
五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期
六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
_chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
107 lines
4.7 KiB
Python
107 lines
4.7 KiB
Python
from collections.abc import AsyncIterator
|
||
from dataclasses import asdict
|
||
|
||
from fastapi import APIRouter, Depends, Path, Request, status
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from starlette.responses import StreamingResponse
|
||
|
||
from app.api.dependencies.auth import build_request_context
|
||
from app.api.dependencies.database import get_session
|
||
from app.api.dependencies.negotiation import accepts_event_stream
|
||
from app.api.dependencies.rate_limit import enforce_rate_limit
|
||
from app.api.schemas.agent_runs import (
|
||
AgentRunAcceptedEnvelope,
|
||
AgentRunAcceptedResponse,
|
||
AgentRunCreateRequest,
|
||
AgentRunStatusEnvelope,
|
||
AgentRunStatusResponse,
|
||
)
|
||
from app.api.views.agent_run_sse import encode_events, recovery_events
|
||
from app.core.config import get_settings
|
||
from app.core.contracts import AgentRequest, RequestContext
|
||
from app.core.errors import SseNotAcceptableError
|
||
from app.service.agent_run_application_service import AgentRunApplicationService
|
||
from app.service.run_query_service import RunQueryService
|
||
|
||
router = APIRouter(prefix="/api/v1/agent-runs", tags=["agent-runs"],
|
||
dependencies=[Depends(enforce_rate_limit)])
|
||
|
||
@router.post(
|
||
"",
|
||
response_model=AgentRunAcceptedEnvelope,
|
||
status_code=status.HTTP_202_ACCEPTED,
|
||
)
|
||
async def create_agent_run(
|
||
payload: AgentRunCreateRequest,
|
||
request: Request,
|
||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||
) -> AgentRunAcceptedEnvelope:
|
||
request.state.request_context = context
|
||
accepted = await AgentRunApplicationService(session).accept(
|
||
AgentRequest(**payload.model_dump()), context)
|
||
return AgentRunAcceptedEnvelope(
|
||
data=AgentRunAcceptedResponse(
|
||
run_id=accepted.run_id, trace_id=accepted.trace_id, status=accepted.status,
|
||
status_url=f"/api/v1/agent-runs/{accepted.run_id}",
|
||
events_url=f"/api/v1/agent-runs/{accepted.run_id}/events",
|
||
),
|
||
meta={"trace_id": context.trace_id},
|
||
)
|
||
|
||
|
||
@router.get("/{run_id}", response_model=AgentRunStatusEnvelope)
|
||
async def get_agent_run(
|
||
run_id: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||
) -> AgentRunStatusEnvelope:
|
||
"""查询运行(文档 §6.3)。
|
||
|
||
文档 §3.3 与 §6.3 都把成功响应定义为 `{data, meta:{trace_id}}` 信封;此前这里
|
||
直接返回资源对象,客户端必须为这一个接口特判。**只改包装结构**:`data` 内的字段名
|
||
与语义保持原样,`meta.trace_id` 用本次请求的 trace(`data.trace_id` 仍是运行自身的
|
||
追踪标识,两者语义不同,不能互相替代)。
|
||
"""
|
||
snapshot = await RunQueryService().get(run_id, context)
|
||
return AgentRunStatusEnvelope(
|
||
data=AgentRunStatusResponse(**asdict(snapshot)),
|
||
meta={"trace_id": context.trace_id},
|
||
)
|
||
|
||
|
||
@router.get("/{run_id}/events")
|
||
async def stream_agent_run_events(
|
||
request: Request,
|
||
run_id: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||
) -> StreamingResponse:
|
||
query = RunQueryService()
|
||
# 顺序按文档 §6.4 的主要错误列举:RUN_NOT_FOUND(含 AGENT_PERMISSION_DENIED 同级的
|
||
# 可见性判定)在前、SSE_NOT_ACCEPTABLE 在后。可见性先行(auth 依赖已先于本函数执行)
|
||
# 才能保证"运行是否存在"不因 Accept 头而异:否则用任意 run_id + 非法 Accept 探测,
|
||
# 406 与 404 的差异就等价于一次存在性枚举。
|
||
initial = await query.get(run_id, context)
|
||
if not accepts_event_stream(request.headers.get("Accept")):
|
||
raise SseNotAcceptableError("Accept 必须接受 text/event-stream")
|
||
|
||
async def generate() -> AsyncIterator[str]:
|
||
start_sent = False
|
||
async for snapshot in query.watch(initial, context):
|
||
if snapshot is None:
|
||
yield ": heartbeat\n\n"
|
||
continue
|
||
result = snapshot.result or {}
|
||
events = recovery_events(
|
||
run_id=snapshot.run_id, trace_id=snapshot.trace_id, status=snapshot.status,
|
||
error_code=snapshot.error_code, content=result.get("content"),
|
||
tool_calls=result.get("tool_calls"),
|
||
replay=initial.status in {"succeeded", "failed", "cancelled"},
|
||
chunk_size=get_settings().sse_chunk_characters,
|
||
)
|
||
for encoded in encode_events(run_id, events[1:] if start_sent else events):
|
||
yield encoded
|
||
start_sent = True
|
||
|
||
return StreamingResponse(generate(), media_type="text/event-stream",
|
||
headers={"Cache-Control": "no-cache"})
|