Files
group_fqcd_jr/tools/publish_customer_service_config.py
T
lzf_0626 13bab7c3d0 feat: 客服 Agent 端到端跑通(知识直返 + 答不了引导人工客服)
按业务方确定的取向实现:金融场景确定性优先,能溯源到公司资料的才答,答不了就
引导客户拨打客服热线,绝不用模型猜答案。端到端验收 8/8 通过。

新增:
- app/service/knowledge_search_service.py:知识检索。未复用记忆的 VectorMemoryAdapter
  是因为它只返回 (memory_uuid, score),会丢掉知识块的标题与正文,而客服回答必须能把
  原文与出处一起交付。检索失败一律返回 degraded 而不抛异常,由 Agent 走兜底。
- app/service/knowledge_tool.py + app/core/knowledge_contracts.py:只读工具 search_knowledge。
  走 ToolExecutor 而不是让 Agent 直接持有检索服务,是为了让白名单、权限、审计、超时
  都归基座统一管理;工具只读也符合 ToolRegistry 的硬约束。复用既有权限码
  knowledge:reference:read(customer 角色已具备),不新增权限点。
- app/service/agent/implementations/customer_service.py:Agent 本体,刻意保持薄——
  意图分发 + 四条出口(faq/产品/政策直返、闲聊走模型、其余与异常引导人工)。
  直接返回知识原文而不经模型改写,答案的字面内容全部来自公司已发布资料。
- tools/publish_customer_service_config.py:发布意图工具白名单。
- tools/customer_service_check.py:端到端验收(8 个用例,含越界请求与知识库外问题)。

装配:
- bootstrap 新增 get_knowledge_search_service 工厂,注册 search_knowledge 工具与
  customer_service Agent。
- runtime_config_service 新增 load_active_prompt:提示词绑定 release_id,按当前生效
  版本读取,未发布时回落代码默认值。闲聊话术因此可审核、可回滚,不必改代码发版。

过程中发现并处理的三个问题:
1. 自造 source_references 被基座合规闸门拒绝。governance.review_output 只接受
   「本次召回的记忆」与「本次成功调用的工具」两类引用(用于防止伪造来源),
   knowledge 类型会被判非法并使整个 run 失败。处理方式是**不放开那道校验**,
   而把知识出处(文件标题与内部编号)写进正文,source_references 交给基座自动附加。
2. 发布配置是整版本替换语义:新版本会清空旧版本的全部配置项。若只发客服白名单,
   示例 Agent 的 fund_query_demo:fund_quote 会被静默清空。故发布脚本先读取当前生效
   版本的全部配置项并原样继承,再追加新增项。
3. 验收脚本自身两处自伤:打印 emoji 触发 GBK UnicodeEncodeError、以及读错结果字段
   (RunQueryService 返回的答案键是 content 不是 text)。

已知缺口(未修,已记录):
- CoreResult.transfer_required 未持久化:conversation_message 不存该标记,
  API 读不到"本次是否引导了人工"。当前靠正文里的固定话术判断。
- 知识块引用(source_type=knowledge)尚未启用,需先让 ToolExecutor 把工具返回的
  doc_id 登记为本次可引用来源。

验证:ruff 通过、mypy 107 文件无错、unit+contract 447 passed;
tools/customer_service_check.py 8/8 通过(含越界请求、投诉、知识库外问题三类
必须引导人工的场景,以及 7 个零容忍负面词零命中)。
2026-09-10 20:22:42 +08:00

186 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""发布客服 Agent 的运行期配置:意图工具白名单(走发布状态机)。
两个必须讲清的点:
1. **为什么必须发这一步**:工具白名单是失败关闭的——`ToolExecutor` 拿发布配置里
`agent_tools` / `customer_service:<intent>` 的 `allowed_tools` 与代码声明的
`AgentDefinition.allowed_tools` 取交集,缺配置时交集为空、任何工具调用都被拒。
「Agent 写好了但没发配置」的表现是"客服什么都答不了、一直在引导人工"。
2. **为什么必须继承现有配置项**:`config_release` 是**整版本替换**语义——激活新版本后,
旧版本的所有配置项都不再生效。若只发布客服自己的白名单,示例 Agent 的
`fund_query_demo:fund_quote` 会被静默清空。所以发布前先把当前 effective 版本里的
配置项原样搬进新版本,再追加本次新增项。
用法:python tools/publish_customer_service_config.py
"""
import asyncio
import datetime as dt
import json
import sys
import uuid
from pathlib import Path
from typing import Any
import asyncmy
import httpx
import jwt
from app.core.config import get_settings
from app.main import create_app
ADMIN = "9003"
AGENT_TYPE = "customer_service"
TOOL_NAME = "search_knowledge"
# 只有会调用工具的意图才需要白名单;chitchat(模型生成)与 transfer_human(引导人工)
# 都不查知识库。给它们配空白名单反而会掩盖"配置漏配",因此不发布这两条。
INTENTS = ("faq", "product_inquiry", "policy_explain")
def token(subject: str) -> str:
settings = get_settings()
private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8")
now = dt.datetime.now(dt.UTC)
return jwt.encode(
{
"sub": subject, "iss": settings.jwt_issuer, "aud": settings.jwt_audience,
"exp": now + dt.timedelta(minutes=30), "nbf": now - dt.timedelta(seconds=5),
"jti": str(uuid.uuid4()),
},
private_key,
algorithm="RS256",
)
async def active_config_items() -> list[dict[str, Any]]:
"""读取当前生效版本的全部配置项,用于在新版本里原样继承。"""
settings = get_settings()
# MYSQL_DSN 形如 mysql+asyncmy://user:pass@host:port/db
dsn = settings.mysql_dsn.split("://", 1)[1]
credentials, location = dsn.split("@", 1)
user, password = credentials.split(":", 1)
host_port, database = location.split("/", 1)
host, _, port = host_port.partition(":")
connection = await asyncmy.connect(
host=host, port=int(port or 3306), user=user, password=password, db=database
)
try:
cursor = connection.cursor()
await cursor.execute(
"""
SELECT i.namespace, i.config_key, i.value_json, i.schema_version
FROM platform_config_item i
JOIN config_release r ON r.id = i.release_id
WHERE r.status = 'active'
"""
)
rows = await cursor.fetchall()
finally:
connection.close()
items: list[dict[str, Any]] = []
for namespace, config_key, value_json, schema_version in rows:
value = json.loads(value_json) if isinstance(value_json, str) else value_json
items.append({
"namespace": namespace,
"item_key": config_key,
"value_json": value,
"schema_version": schema_version,
})
return items
async def post(
client: httpx.AsyncClient, path: str, *, auth: dict[str, str],
payload: dict[str, object] | None = None, if_match: str | None = None,
) -> httpx.Response:
headers = {**auth, "Idempotency-Key": uuid.uuid4().hex}
if if_match:
headers["If-Match"] = if_match
return await client.post(path, json=payload, headers=headers)
async def etag_of(client: httpx.AsyncClient, path: str, auth: dict[str, str]) -> str | None:
return (await client.get(path, headers=auth)).headers.get("ETag")
async def main() -> int:
app = create_app()
auth = {"Authorization": f"Bearer {token(ADMIN)}"}
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=60
) as client:
inherited = await active_config_items()
print(f"当前生效版本的配置项:{len(inherited)} 条(将原样继承)")
for item in inherited:
print(f" · {item['namespace']} / {item['item_key']}")
new_items = [
{
"namespace": "agent_tools",
"item_key": f"{AGENT_TYPE}:{intent}",
"value_json": {"allowed_tools": [TOOL_NAME]},
"schema_version": "1",
}
for intent in INTENTS
]
inherited_keys = {(str(i["namespace"]), str(i["item_key"])) for i in inherited}
pending = [
item for item in new_items
if (str(item["namespace"]), str(item["item_key"])) not in inherited_keys
]
if not pending:
print("客服白名单已存在于当前生效版本,无需发布")
return 0
created = await post(client, "/api/v1/admin/config-releases", auth=auth, payload={
"release_no": f"cs-tools-{uuid.uuid4().hex[:12]}",
"title": "客服 Agent 意图工具白名单",
"change_summary": "新增 faq/product_inquiry/policy_explain 的知识检索白名单,并继承既有配置项",
})
if created.status_code != 201:
print(f"创建发布版本失败:{created.status_code} {created.text[:200]}")
return 1
release_id = int(created.json()["data"]["id"])
print(f"\n发布版本 id={release_id}")
base = f"/api/v1/admin/config-releases/{release_id}/platform-config-items"
for item in [*inherited, *pending]:
response = await post(client, base, auth=auth, payload=item)
mark = "继承" if item in inherited else "新增"
print(f" [{mark}] {item['namespace']}/{item['item_key']} → {response.status_code}")
if response.status_code != 201:
print(f" 失败:{response.text[:200]}")
return 1
release_base = f"/api/v1/admin/config-releases/{release_id}"
submitted = await post(
client, f"{release_base}/validations", auth=auth, payload={},
if_match=await etag_of(client, release_base, auth),
)
print(f"\n提交复核:{submitted.status_code}")
reviewed = await post(
client, f"{release_base}/reviews", auth=auth,
payload={"decision": "approved", "comment": "客服工具白名单"},
if_match=await etag_of(client, release_base, auth),
)
print(f"审核:{reviewed.status_code}")
activated = await post(
client, f"{release_base}/activations", auth=auth, payload={},
if_match=await etag_of(client, release_base, auth),
)
print(f"激活:{activated.status_code}")
if activated.status_code not in (200, 201):
print(f" 失败:{activated.text[:200]}")
return 1
print(f"最终状态:{activated.json()['data']['status']}")
remaining = await active_config_items()
print(f"\n激活后生效版本配置项:{len(remaining)} 条")
for item in remaining:
print(f" · {item['namespace']} / {item['item_key']} = {item['value_json']}")
return 0
sys.exit(asyncio.run(main()))