客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 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 生成的本地产物
This commit is contained in:
+49
-48
@@ -10,6 +10,7 @@ from uuid import uuid4
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.actor import VISITOR_ACTOR_TYPE, anonymous_context, is_visitor
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.contracts import AgentRequest, AgentRequestMetadata, AgentResult, RequestContext
|
||||
from app.core.errors import AgentError, RecoverableAgentError, RunLeaseLostError
|
||||
@@ -35,11 +36,6 @@ from app.service.agent.bootstrap import (
|
||||
from app.service.agent.executor import AgentExecutor
|
||||
from app.service.agent.factory import AgentFactory
|
||||
from app.service.agent_persistence_service import AgentPersistenceService
|
||||
from app.service.customer_service_session_memory_service import (
|
||||
CustomerServiceSessionMemory,
|
||||
CustomerServiceSessionTurn,
|
||||
build_customer_service_session_memory,
|
||||
)
|
||||
from app.service.identity_service import IdentityService
|
||||
from app.service.memory_extraction_service import (
|
||||
ExtractionEndpointResolver,
|
||||
@@ -78,6 +74,24 @@ PROJECTION_AUDIT_ACTION = "memory.projection_cleanup"
|
||||
#: 构造"Milvus 未配置"的场景,降级行为也就无法被固定。
|
||||
_UNSET: Any = object()
|
||||
|
||||
#: 允许生成「待确认画像候选」(`customer_profile.candidate_requested`)的 agent_type 白名单。
|
||||
#:
|
||||
#: 候选链路本身(`CustomerProfileCandidateWorker` → 用户确认 → 管理员复核)属**记忆子系统**,
|
||||
#: 不由任何单一 Agent 独占;这里只决定「哪些 Agent 的对话可以产出候选」。
|
||||
#: **当前为空集,且不是「重构期临时状态」**:`DEC-19` 裁定 (a) 明确「客服不产生画像候选」,
|
||||
#: 而客服是重构前唯一的候选来源,故候选链路整体关闭。要重新打开必须先改 `DEC-19` 口径,
|
||||
#: 而不是往本集合里加 `agent_type`——那样等于绕过合规裁定。
|
||||
PROFILE_CANDIDATE_AGENT_TYPES: frozenset[str] = frozenset()
|
||||
|
||||
#: **永不写长期记忆**的 agent_type 黑名单(`DEC-19`:短期会话记忆=开 / 长期记忆召回=关)。
|
||||
#:
|
||||
#: 客服会话正文含账户、持仓、身份等高敏内容;一旦被抽成 `memory_unit`,就会被**跨会话、
|
||||
#: 跨场景**召回进后续任意对话的生成上下文,等同于把合规裁定绕开。这里保留**显式**闸门
|
||||
#: (而不是指望下游 `should_extract_memory()` 恰好返回 False),使「客服永不写长期记忆」
|
||||
#: 成为一条可测试的不变量:重构期该闸门曾被误删,`test_worker_runtime_mysql` 立刻抓到
|
||||
#: 客服 run 产出 `memory.extraction_requested`。
|
||||
NO_LONG_TERM_MEMORY_AGENT_TYPES: frozenset[str] = frozenset({"customer_service"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectionCleanupOutcome:
|
||||
@@ -93,6 +107,20 @@ class ProjectionCleaner(Protocol):
|
||||
async def cleanup(self, *, memory_uuid: str, operation: str) -> ProjectionCleanupOutcome: ...
|
||||
|
||||
|
||||
def _stored_subject(tool_calls: Any) -> str:
|
||||
"""从消息的 ``tool_calls`` JSON 列取出口声明的主语(``E-05``)。
|
||||
|
||||
该列历史上是**裸列表**(`calls` 之外没有兄弟键),也可能来自不含 `topic` 的
|
||||
旧写入 —— 这两种情况一律返回空串,读侧于是回落到文本反解。格式差异不会让
|
||||
某一轮突然丢掉主语。
|
||||
"""
|
||||
if isinstance(tool_calls, dict):
|
||||
value = tool_calls.get("topic")
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
class WorkerRuntime:
|
||||
def __init__(
|
||||
self, factory: AgentFactory | None = None, settings: Settings | None = None,
|
||||
@@ -106,7 +134,6 @@ class WorkerRuntime:
|
||||
knowledge_embedder: Any = _UNSET,
|
||||
knowledge_endpoint_resolver: Any = _UNSET,
|
||||
profile_vector_client: Any = _UNSET,
|
||||
session_memory: CustomerServiceSessionMemory | None = None,
|
||||
) -> None:
|
||||
self.factory = factory if factory is not None else get_agent_factory()
|
||||
self.settings = settings or get_settings()
|
||||
@@ -128,11 +155,6 @@ class WorkerRuntime:
|
||||
# 这里不兜底。组件内部给默认实现会把"尚未装配"这一事实悄悄盖住——而"未注入即显式
|
||||
# 降级并留痕"是本模块刻意保留的语义(有单测守着),因此默认值保持 None。
|
||||
self.projection_cleaner = projection_cleaner
|
||||
# 客服短期会话 Redis 仅用于当前会话上下文,不参与长期画像召回。
|
||||
self.session_memory = (
|
||||
session_memory if session_memory is not None
|
||||
else build_customer_service_session_memory()
|
||||
)
|
||||
# 图关系服务:画像投影用它写入节点与关系(投顾的多跳推荐、风控的关系网络都读它)。
|
||||
# 默认取生产装配;图库不可用时该值为 None,投影如实降级而不是失败。
|
||||
if relationships is not None:
|
||||
@@ -179,12 +201,10 @@ class WorkerRuntime:
|
||||
) -> RequestContext:
|
||||
"""按受理事件中的可信身份恢复最小执行权限。"""
|
||||
identity = RequestContext(user_id=actor_id, trace_id=trace_id)
|
||||
if actor_type == "visitor":
|
||||
return identity.model_copy(update={
|
||||
"roles": ("visitor",),
|
||||
"permissions": ("agent:run", "knowledge:query"),
|
||||
"data_scope": "public",
|
||||
})
|
||||
if actor_type == VISITOR_ACTOR_TYPE:
|
||||
# `G-01`:与 API 入口(`app/core/security.py`)共用同一构造点。
|
||||
# 此前这里是**第二份手写副本**,两侧漂移时只在异步链路暴露。
|
||||
return anonymous_context(user_id=actor_id, trace_id=trace_id)
|
||||
return await self.resolve_identity(identity)
|
||||
|
||||
@staticmethod
|
||||
@@ -192,14 +212,14 @@ class WorkerRuntime:
|
||||
*, agent_type: str, context: RequestContext, message: str,
|
||||
result: AgentResult, business_events: tuple[str, ...] | list[str],
|
||||
) -> bool:
|
||||
"""长期记忆抽取只接收非客服、非访客的明确业务事实。"""
|
||||
if agent_type == "customer_service":
|
||||
# 客服走的是「候选画像」链路(customer_profile.candidate_requested),
|
||||
# 不写长期记忆。此前这里静默 return False,运维无法区分"不该抽"与"该抽但没抽"。
|
||||
logger.info("memory extraction skipped: agent_type=%s 走候选画像链路,不写长期记忆",
|
||||
agent_type)
|
||||
"""长期记忆抽取只接收非访客、且不在长期记忆黑名单内的明确业务事实。"""
|
||||
if agent_type in NO_LONG_TERM_MEMORY_AGENT_TYPES:
|
||||
# 客服走「画像字段级只读」链路,不写画像、不写长期记忆(`DEC-19` 裁定 (a))。
|
||||
logger.info(
|
||||
"memory extraction skipped: agent_type=%s 不写长期记忆(DEC-19)", agent_type
|
||||
)
|
||||
return False
|
||||
if "visitor" in context.roles:
|
||||
if is_visitor(context):
|
||||
logger.info("memory extraction skipped: 访客身份不写长期记忆")
|
||||
return False
|
||||
tool_result = any(call.status == "succeeded" for call in result.result.tool_calls)
|
||||
@@ -224,8 +244,10 @@ class WorkerRuntime:
|
||||
def should_request_profile_candidate(
|
||||
*, agent_type: str, context: RequestContext, message: str,
|
||||
) -> bool:
|
||||
"""客服仅为已登录且 self 范围内的用户生成待确认画像候选。"""
|
||||
if agent_type != "customer_service" or "visitor" in context.roles:
|
||||
"""仅为白名单内 Agent、已登录且 self 范围内的用户生成待确认画像候选。"""
|
||||
if agent_type not in PROFILE_CANDIDATE_AGENT_TYPES:
|
||||
return False
|
||||
if is_visitor(context):
|
||||
return False
|
||||
if not {"customer", "authenticated_user"}.intersection(context.roles):
|
||||
return False
|
||||
@@ -460,6 +482,7 @@ class WorkerRuntime:
|
||||
turns.append(ConversationTurn(
|
||||
role="assistant" if str(row.role) == "assistant" else "user",
|
||||
content=content,
|
||||
subject=_stored_subject(row.tool_calls),
|
||||
))
|
||||
return tuple(turns)
|
||||
|
||||
@@ -927,28 +950,6 @@ class WorkerRuntime:
|
||||
agent_type=run.agent_type, context=context, message=request.message,
|
||||
),
|
||||
)
|
||||
await self._append_customer_service_session_memory(
|
||||
agent_type=run.agent_type, actor_id=str(run.user_id), session_id=run.session_id,
|
||||
request_message=request.message, response_message=result.result.text,
|
||||
)
|
||||
|
||||
async def _append_customer_service_session_memory(
|
||||
self, *, agent_type: str, actor_id: str, session_id: str,
|
||||
request_message: str, response_message: str,
|
||||
) -> None:
|
||||
"""成功落库后追加短期会话;Redis 故障不影响主事务。"""
|
||||
if agent_type != "customer_service":
|
||||
return
|
||||
try:
|
||||
await self.session_memory.append(
|
||||
actor_id=actor_id, session_id=session_id,
|
||||
turns=(
|
||||
CustomerServiceSessionTurn(role="user", content=request_message),
|
||||
CustomerServiceSessionTurn(role="assistant", content=response_message),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("客服短期会话写入降级,不影响已完成的客服运行", exc_info=True)
|
||||
|
||||
async def _failure(
|
||||
self, run_id: str, worker_id: str, error_code: str, *, retryable: bool
|
||||
|
||||
Reference in New Issue
Block a user