Files
group_fqcd_jr/tools/publish_customer_service_config.py
T
lzf_0626 33fbb0eb01 feat(customer-service): 接入适当性裁决,回答"我这个等级能不能买它"
客户实测反馈:「c1客户能买它吗」答的是 C1 的通用规则,没回答"能不能买季季盈90天"。
根因是这个问题需要**组合两个事实**——产品的风险等级(R2)与客户档案等级能不能匹配——
而检索只能给出"最像的那段原文",给不出结论。基座早就有了 check_suitability 裁决工具,
接口留好了但客服没接线(这个项目里第三次遇到同一类情况)。

改动三处:
1. 新增 suitability_check 意图,意图码三处对齐(AgentDefinition.supported_intents、
   agent_intent_config 的 active 行、发布版 agent_tools 白名单)。
2. 新出口 _answer_suitability:产品风险等级**从知识库查出来**(不猜、也不采信问句里
   出现的"R2"字样),产品名只取上一轮回答里的主语(来自知识块字段,可信),客户等级
   交给 check_suitability 按档案解析——**不采信客户自称**。任何一步拿不到确定值就转人工:
   这个出口会给出"能不能买"的结论,宁可答不了也不能答错。
3. 发布脚本加意图注册与工具白名单,并做成幂等(重跑不会因为"已经审过了"而 409)。

实测:意图正确路由到新出口,裁决链路走通。9001 因为在 fin_risk_assessment 里没有测评
记录,系统给出"暂时无法购买 + 您目前没有在有效期内的风险测评结果"——这正是适当性管理
要求的行为,不是故障:不能卖给一个没有有效测评结果的客户。措辞也据此改过,不写
"您的等级为未记录"这种客户看不懂的句子。

顺带修了前端一处误导标记:它用"是否含客服热线"判断"已引导人工",而正常的适当性回答
里也会建议拨打客服热线,于是"已经给出结论"被误报成"已引导人工"。
2026-09-10 22:42:17 +08:00

269 lines
12 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"
SUITABILITY_TOOL = "check_suitability"
# 只有会调用工具的意图才需要白名单;chitchat(模型生成)与 transfer_human(引导人工)
# 都不查知识库。给它们配空白名单反而会掩盖"配置漏配",因此不发布这两条。
INTENT_TOOLS: dict[str, tuple[str, ...]] = {
"faq": (TOOL_NAME,),
"product_inquiry": (TOOL_NAME,),
"policy_explain": (TOOL_NAME,),
# 适当性裁决要两步:先从知识库拿到产品的风险等级,再由底座按档案里的客户等级裁决
"suitability_check": (TOOL_NAME, SUITABILITY_TOOL),
}
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")
SUITABILITY_INTENT = "suitability_check"
# description 与 examples 是**给分类器看的**:意图码本身只是个名字,真正让模型分辨
# "能买吗"和"这个产品是什么"的是这几个例子。所以 examples 全部取客户真实说法。
SUITABILITY_INTENT_SPEC: dict[str, Any] = {
"intent_name": "投资者适当性判断",
"description": "客户询问以自己的风险承受能力能否购买某只产品,或询问自身风险等级与产品的匹配情况",
"examples": [
"c1客户能买它吗", "我能买这个产品吗", "那它我能买吗",
"这只基金适合我吗", "我的风险等级能买吗",
],
"confidence_threshold": "0.6000",
}
async def ensure_suitability_intent(client: httpx.AsyncClient, auth: dict[str, str]) -> int:
"""确保 `suitability_check` 意图在运行期生效(返回 0 成功、1 失败)。
为什么必须做这一步:意图码要三处对齐(见 `customer_service.py` 的注释),而运行期
只读 `agent_intent_config` 里 status='active' 的行。少了这一行,分类链路看不到这个
意图,"能买吗"会被分到别的意图里去,客户拿到的就是"C1 的通用规则"而不是结论。
"""
path = "/api/v1/admin/agent-intent-configs"
listed = await client.get(f"{path}?limit=100", headers=auth)
rows = listed.json().get("data", []) if listed.status_code == 200 else []
existing = next(
(row for row in rows
if row.get("agent_type") == AGENT_TYPE and row.get("intent_code") == SUITABILITY_INTENT),
None,
)
if existing is not None and str(existing.get("status")) == "active":
print(f"[意图配置] id={existing['id']} 已生效,跳过")
return 0
if existing is not None and str(existing.get("status")) in {"draft", "approved"}:
config_id = int(existing["id"])
else:
# 没有历史行或历史行已归档:新版本号(该表 agent_type+intent_code+version 唯一)
version = int(existing.get("version", 0)) + 1 if existing else 1
created = await post(client, path, auth=auth, payload={
"agent_type": AGENT_TYPE,
"intent_code": SUITABILITY_INTENT,
**SUITABILITY_INTENT_SPEC,
"allowed_tools": [TOOL_NAME, SUITABILITY_TOOL],
"version": version,
})
if created.status_code != 201:
print(f"[意图配置] 创建失败:{created.status_code} {created.text[:200]}")
return 1
config_id = int(created.json()["data"]["id"])
print(f"[意图配置] 已创建 id={config_id}({SUITABILITY_INTENT} v{version})")
base = f"{path}/{config_id}"
# 幂等:重跑脚本时某一步可能已经推进过("已经审过了"不该 409 让整个脚本失败)
settled = {"reviews": {"approved", "active"}, "activations": {"active"}}
for action, payload in (
("reviews", {"decision": "approved", "comment": "创建人自审"}),
# 激活端点要求 body 是对象;传 None 时 httpx 根本不发 body,会被判 422
("activations", {}),
):
current = (await client.get(base, headers=auth)).json().get("data", {})
if str(current.get("status")) in settled[action]:
print(f"[意图配置] {action} 已在目标状态({current.get('status')}),跳过")
continue
response = await post(
client, f"{base}/{action}", auth=auth, payload=payload,
if_match=await etag_of(client, base, auth),
)
if response.status_code != 200:
print(f"[意图配置] {action} 失败:{response.status_code} {response.text[:200]}")
return 1
print(f"[意图配置] id={config_id} 已生效(运行期按 status='active' 读取)")
return 0
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:
if await ensure_suitability_intent(client, auth) != 0:
return 1
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": list(tools)},
"schema_version": "1",
}
for intent, tools in INTENT_TOOLS.items()
]
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()))