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())
|