Files
group_fqcd_jr/tools/publish_customer_service_tool_config.py
T
张胜宇 35effab70f feat(W20): 新增 E2c-my 出口 + 展示层净化 + P2 自助流程豁免(金标零回归)
用户口径:既智能又安全,不靠"答不上就转人工";按本人画像测评列出可购买的产品,
但不引导购买。

出口由五个扩为六个:
- 新增 E2c-my:按客户本人权威测评等级给出可购买范围 + 在售清单
  (只列代码/名称/类别/风险等级,按代码升序;出口后置引导词护栏,命中即降级为仅范围)
- 点名档位(「我可以买 R3 的产品吗」)先给一句直接裁决再列范围
  —— 修复前只给清单,"不"字始终没说出来
- 新工具 query_eligible_products(suitability:read)+ 发布 release 220

真实业务缺陷:
- P2_SELF_SERVICE_PATTERNS:问「怎么修改绑定的银行卡」不再建单转人工(FAQ 里就有答案);
  「帮我把绑定银行卡换一下」仍走 P2
- 展示层净化 render_plain / drop_yield_claims / prettify_title 接在 E3/E4/E5b/澄清四处
  (E4 模型会照抄证据包的 markdown 标记)
- 空结果护栏:语料里 15 个纯收益切片被净化清空时回退 E5b,不给空气泡

测试:定向 334 passed;全量回归 1969 passed / 3 skipped / 0 failed(基线 1946);
46 条金标 M-1 46/46=100%、M-4 100%、M-6 5/46=10.9%、M-7~M-10 全 0 —— 与
score_w11b 逐项一致(零回归)。

落档:D1.6 §4.49 / D2.1 v6.36 / D1.1 §30
2026-09-20 18:55:22 +08:00

299 lines
11 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.
"""Publish the customer-service tool allowlist for the ``E2c-my`` eligible-product exit.
Why a release instead of an UPDATE: the active ``config_release`` is environment data, and
``call_tool`` resolves the per-intent allowlist from it. ``E2c-my`` needs
``query_eligible_products`` under ``customer_service:suitability_check``; without this item the
tool call fails closed with ``AGENT_PERMISSION_DENIED`` and the exit degrades to ``E5b``
("can't get your risk level"), which looks like a data problem instead of a config problem.
The script carries forward every item of the active release and replaces only the
``agent_tools/customer_service:suitability_check`` entry, so nothing else changes.
"""
from __future__ import annotations
import argparse
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
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.core.config import get_settings # noqa: E402
from app.main import create_app # noqa: E402
ADMIN_ID = "9003"
AGENT_TYPE = "customer_service"
#: 只改这一个键。`faq` / `policy_explain` / `product_inquiry` 原样继承,不在本脚本里重写
#: —— 少写一个键就少一处"顺手改宽"的机会。
INTENT_TOOLS: dict[str, tuple[str, ...]] = {
"suitability_check": (
"search_knowledge",
"check_suitability",
"query_eligible_products",
),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Publish customer-service demo tool allowlist")
parser.add_argument("--apply", action="store_true", help="Create, review, and activate release")
return parser.parse_args()
def issue_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_items() -> list[dict[str, Any]]:
settings = get_settings()
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()
return [
{
"namespace": namespace,
"item_key": key,
"value_json": json.loads(value) if isinstance(value, str) else value,
"schema_version": schema_version,
}
for namespace, key, value, schema_version in rows
]
def _json_or_none(value: Any) -> Any:
"""库里的 JSON 列取出来可能是字符串 ``"null"`` / ``"{}"``,统一还原成对象。"""
if value is None:
return None
if isinstance(value, str):
parsed = json.loads(value)
return None if parsed is None else parsed
return value
async def active_prompt_templates() -> list[dict[str, Any]]:
"""取要继承的提示词模板行(`prompt_template_version`),版本号 +1。
为什么必须有这一步:提示词模板与工具白名单**不在同一张表**。只继承
`platform_config_item` 建新版本,会把提示词**静默丢掉** —— 激活接口只给一句告警,
点下去那条提示词就真的失效了。实测 2026-09-20:`customer_service_chitchat`
因此丢过一次(激活版里 0 行),闲聊出口当场退化成兜底话术。
所以这里两级回退:**活跃版本有就继承活跃的;活跃版本没有,就从最近一个有该表的版本继承**
(并把这件事打在屏幕上,避免"看起来一切正常")。
"""
settings = get_settings()
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
)
columns = (
"release_id, prompt_code, task_type, agent_type, version, "
"system_prompt, user_prompt_template, input_schema, output_schema"
)
try:
cursor = connection.cursor()
await cursor.execute(
f"SELECT {columns} FROM prompt_template_version WHERE release_id = "
"(SELECT id FROM config_release WHERE status = 'active')"
)
rows = await cursor.fetchall()
if not rows:
print(
"WARNING: active release has no prompt_template_version rows; "
"inheriting from the newest release that does"
)
await cursor.execute(
f"SELECT {columns} FROM prompt_template_version "
"WHERE release_id = (SELECT MAX(release_id) FROM prompt_template_version)"
)
rows = await cursor.fetchall()
finally:
connection.close()
return [
{
"prompt_code": prompt_code,
"task_type": task_type,
"agent_type": agent_type,
"version": int(version) + 1,
"system_prompt": system_prompt,
"user_prompt_template": user_prompt_template,
"input_schema": _json_or_none(input_schema),
"output_schema": _json_or_none(output_schema),
}
for (
_release_id,
prompt_code,
task_type,
agent_type,
version,
system_prompt,
user_prompt_template,
input_schema,
output_schema,
) in rows
]
async def request(
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(client: httpx.AsyncClient, path: str, auth: dict[str, str]) -> str | None:
return (await client.get(path, headers=auth)).headers.get("ETag")
def cs_items() -> list[dict[str, object]]:
return [
{
"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()
]
async def publish() -> int:
app = create_app()
auth = {"Authorization": f"Bearer {issue_token(ADMIN_ID)}"}
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
inherited = await active_items()
prompt_templates = await active_prompt_templates()
replacement_keys = {("agent_tools", str(item["item_key"])) for item in cs_items()}
carried = [
item
for item in inherited
if (str(item["namespace"]), str(item["item_key"])) not in replacement_keys
]
created = await request(
client,
"/api/v1/admin/config-releases",
auth=auth,
payload={
"release_no": f"cs-tools-{uuid.uuid4().hex[:12]}",
"title": "Customer service E2c-my eligible product tool allowlist",
"change_summary": (
"Add query_eligible_products to customer_service:suitability_check"
),
},
)
if created.status_code != 201:
print(f"create release failed: {created.status_code} {created.text[:240]}")
return 1
release_id = int(created.json()["data"]["id"])
base = f"/api/v1/admin/config-releases/{release_id}"
for item in [*carried, *cs_items()]:
response = await request(
client, f"{base}/platform-config-items", auth=auth, payload=item
)
if response.status_code != 201:
print(f"write {item['namespace']}/{item['item_key']} failed: {response.status_code}")
return 1
for template in prompt_templates:
response = await request(
client,
# 提示词模板**不走** release 作用域前缀:`register_resource` 对它用的
# 是 `scoped=False`,路由挂在 `/api/v1/admin/prompt-templates`,
# release 由 body 里的 `release_id` 指定(实测 2026-09-20 踩过 404)。
"/api/v1/admin/prompt-templates",
auth=auth,
payload={**template, "release_id": release_id},
)
if response.status_code != 201:
print(
f"write prompt {template['prompt_code']} failed: "
f"{response.status_code} {response.text[:240]}"
)
return 1
for suffix, payload in (
("validations", {}),
("reviews", {"decision": "approved", "comment": "add eligible-product tool"}),
("activations", {}),
):
response = await request(
client,
f"{base}/{suffix}",
auth=auth,
payload=payload,
if_match=await etag(client, base, auth),
)
if response.status_code not in (200, 201):
print(f"{suffix} failed: {response.status_code} {response.text[:240]}")
return 1
print(f"published customer-service tool release id={release_id}")
return 0
def main() -> int:
args = parse_args()
for intent, tools in INTENT_TOOLS.items():
print(f"customer_service:{intent} -> {', '.join(tools)}")
if not args.apply:
print("dry run only; pass --apply to publish a new config release")
return 0
return asyncio.run(publish())
if __name__ == "__main__":
raise SystemExit(main())