用户口径:既智能又安全,不靠"答不上就转人工";按本人画像测评列出可购买的产品, 但不引导购买。 出口由五个扩为六个: - 新增 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
299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""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())
|