186 lines
7.4 KiB
Python
186 lines
7.4 KiB
Python
"""发布客服 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()))
|