338 lines
15 KiB
Python
338 lines
15 KiB
Python
"""补发客服工具白名单:把 `query_customer_profile` 加进 `customer_service:faq`。
|
||||
|
|
|
|||
|
|
## 为什么必须补发
|
|||
|
|
|
|||
|
|
`ToolExecutor` 取「代码 `AgentDefinition.allowed_tools` ∩ 发布配置
|
|||
|
|
`agent_tools/<agent>:<intent>`」的**交集**,缺配置即**失败关闭**。
|
|||
|
|
|
|||
|
|
`NL_develop` 引入画像问答出口后,客服的代码上限变成
|
|||
|
|
`(search_knowledge, check_suitability, query_customer_profile)`,而本环境生效版本 **201** 的
|
|||
|
|
`customer_service:faq` 只有 `["search_knowledge"]` —— 画像出口一调用就会被
|
|||
|
|
`AGENT_PERMISSION_DENIED` 拒掉,表现为"问等级/画像一律转人工"。
|
|||
|
|
|
|||
|
|
## 为什么不能只发这一条
|
|||
|
|
|
|||
|
|
`config_release` 是**整版本替换**语义:新版本没带上的配置项**等于被删除**。
|
|||
|
|
所以要先把当前生效版本在**全部三张受管表**(`platform_config_item` /
|
|||
|
|
`prompt_template_version` / `model_routing_rule`)里的内容搬进新版本,再覆盖本次要改的那一条。
|
|||
|
|
|
|||
|
|
本环境 201 里有 **9 条 `agent_tools` + 1 条提示词**;只发白名单会把那条提示词一起清掉,
|
|||
|
|
而 Agent 侧有逐字段兜底、会回落代码默认值 —— **功能看着正常、没有任何告警**,
|
|||
|
|
这个事故在本项目真实发生过一次(提示词挂在 release 174、active 变成 181 后读不到)。
|
|||
|
|
|
|||
|
|
## 两个已踩过的坑
|
|||
|
|
|
|||
|
|
1. **同 key 的继承项必须被本次新定义覆盖。** 否则旧值(这里是 `["search_knowledge"]`)
|
|||
|
|
会被 admin 端的子集校验 422 拦下,**整次发布失败**,而报错只有
|
|||
|
|
"配置超出 Agent 工具上限",看不出是继承造成的。
|
|||
|
|
2. **提示词搬运必须重分配 `version`。** `prompt_template_version` 的唯一键含 `version`,
|
|||
|
|
照搬旧行会冲突;同时**必须带上 `input_schema` / `output_schema`**,
|
|||
|
|
`PromptPayload` 只收 9 个字段,漏了就是静默丢失。
|
|||
|
|
|
|||
|
|
跑法:
|
|||
|
|
|
|||
|
|
python tools/publish_profile_tool_whitelist.py --dry-run # 只看要写什么,不动库
|
|||
|
|
python tools/publish_profile_tool_whitelist.py # 真正发布
|
|||
|
|
|
|||
|
|
## ⚠️ 必须在 `NL_develop` 合并进来**之后**才能跑
|
|||
|
|
|
|||
|
|
admin 端会校验「配置项 ⊆ 代码 `allowed_tools`」(`app/service/admin_service.py:219-220`):
|
|||
|
|
|
|||
|
|
if not set(raw) <= set(definition.allowed_tools):
|
|||
|
|
raise ValidationAgentError("配置超出 Agent 工具上限")
|
|||
|
|
|
|||
|
|
合并前代码上限还是 `(search_knowledge, check_suitability)`,**不含** `query_customer_profile`
|
|||
|
|
⇒ 这一版会被 422 拒绝,而报错只有"配置超出 Agent 工具上限",看起来像白名单写错了。
|
|||
|
|
所以脚本启动时会先查代码上限并给出明确原因,不让你去猜。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
import argparse
|
|||
|
|
import datetime as dt
|
|||
|
|
import json
|
|||
|
|
import sys
|
|||
|
|
import uuid
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
import httpx
|
|||
|
|
import jwt
|
|||
|
|
from sqlalchemy import func, select
|
|||
|
|
|
|||
|
|
from app.core.config import get_settings
|
|||
|
|
from app.infrastructure.db import SessionFactory
|
|||
|
|
from app.main import create_app
|
|||
|
|
from app.model.configuration import PromptTemplateVersion
|
|||
|
|
from app.service.agent.bootstrap import get_agent_factory
|
|||
|
|
from app.service.config_release_service import ConfigReleaseService
|
|||
|
|
|
|||
|
|
# Windows 控制台常是 GBK:输出里的部分符号会直接抛 UnicodeEncodeError,让脚本在
|
|||
|
|
# "看起来是逻辑错误"的地方失败(本脚本第一版就踩了)。统一兜住,别让编码问题掩盖真问题。
|
|||
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|||
|
|
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
|
|||
|
|
|
|||
|
|
ADMIN = "9003"
|
|||
|
|
AGENT_TYPE = "customer_service"
|
|||
|
|
INTENT_KEY = "customer_service:faq"
|
|||
|
|
PROFILE_TOOL = "query_customer_profile"
|
|||
|
|
|
|||
|
|
# 本次唯一的变化。其余全部原样继承。
|
|||
|
|
NEW_FAQ_TOOLS: tuple[str, ...] = ("search_knowledge", PROFILE_TOOL)
|
|||
|
|
|
|||
|
|
# `PromptPayload` 接受的字段(`app/api/schemas/admin.py:56-65`)。
|
|||
|
|
# 表里还有 checksum / created_by / created_at,那三个由服务端生成,不能搬。
|
|||
|
|
PROMPT_API_FIELDS = (
|
|||
|
|
"prompt_code", "task_type", "agent_type",
|
|||
|
|
"system_prompt", "user_prompt_template", "input_schema", "output_schema",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _as_json(value: Any) -> Any:
|
|||
|
|
"""`SELECT *` 读出来的 JSON 列,在不同驱动下可能是字符串、也可能已解析。
|
|||
|
|
|
|||
|
|
不归一化的后果很隐蔽:`value_json` 保持字符串时,`isinstance(value, dict)` 为假,
|
|||
|
|
脚本会走到"生效版本里没有该 key"那条分支 —— **看起来像配置缺失,实际只是没解析**。
|
|||
|
|
本脚本第一版就是这么错的。`input_schema` / `output_schema` 同理,而且它们是
|
|||
|
|
`PromptPayload` 的 `dict | None` 字段,传字符串会直接 422。
|
|||
|
|
"""
|
|||
|
|
return json.loads(value) if isinstance(value, str) else value
|
|||
|
|
|
|||
|
|
|
|||
|
|
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 snapshot() -> dict[str, list[dict[str, Any]]]:
|
|||
|
|
"""当前生效版本在全部受管表里的内容;每行已剥掉 id / release_id。"""
|
|||
|
|
async with SessionFactory() as session:
|
|||
|
|
return await ConfigReleaseService(session).effective_snapshot()
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def next_prompt_versions(rows: list[dict[str, Any]]) -> list[int]:
|
|||
|
|
"""给每个待搬运的 prompt_code 分配一个新 version。
|
|||
|
|
|
|||
|
|
唯一键含 version,照搬旧行会冲突,所以逐个 prompt_code 取现有最大值 +1。
|
|||
|
|
"""
|
|||
|
|
assigned: list[int] = []
|
|||
|
|
async with SessionFactory() as session:
|
|||
|
|
for row in rows:
|
|||
|
|
latest = await session.scalar(
|
|||
|
|
select(func.max(PromptTemplateVersion.version)).where(
|
|||
|
|
PromptTemplateVersion.prompt_code == row["prompt_code"]
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
assigned.append(int(latest or 0) + 1)
|
|||
|
|
return assigned
|
|||
|
|
|
|||
|
|
|
|||
|
|
def code_allows_profile_tool() -> bool:
|
|||
|
|
"""代码上限里有没有 `query_customer_profile`。
|
|||
|
|
|
|||
|
|
admin 端要求「配置 ⊆ 代码 allowed_tools」,所以**合并之前发这一版必然 422**,
|
|||
|
|
而报错只是"配置超出 Agent 工具上限"。这里提前判定,把真实原因说出来。
|
|||
|
|
"""
|
|||
|
|
definition = get_agent_factory().definition(AGENT_TYPE)
|
|||
|
|
return PROFILE_TOOL in set(definition.allowed_tools)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def current_faq_tools(items: list[dict[str, Any]]) -> list[str] | None:
|
|||
|
|
for item in items:
|
|||
|
|
if str(item.get("item_key")) == INTENT_KEY:
|
|||
|
|
value = item.get("value_json")
|
|||
|
|
if isinstance(value, dict):
|
|||
|
|
tools = value.get("allowed_tools")
|
|||
|
|
if isinstance(tools, list):
|
|||
|
|
return [str(tool) for tool in tools]
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def merged_items(items: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str]]:
|
|||
|
|
"""继承全部配置项,并把 `customer_service:faq` 覆盖成本次的新值。
|
|||
|
|
|
|||
|
|
返回 (新列表, 被替换掉的旧值描述)。**同 key 覆盖**是关键:留给继承的旧值会被
|
|||
|
|
admin 端的子集校验 422 拦下,整次发布失败。
|
|||
|
|
"""
|
|||
|
|
replaced: list[str] = []
|
|||
|
|
merged: list[dict[str, Any]] = []
|
|||
|
|
for item in items:
|
|||
|
|
if str(item.get("item_key")) == INTENT_KEY:
|
|||
|
|
old = current_faq_tools([item]) or []
|
|||
|
|
replaced.append(f"{INTENT_KEY}: {old} → {list(NEW_FAQ_TOOLS)}")
|
|||
|
|
merged.append({**item, "value_json": {"allowed_tools": list(NEW_FAQ_TOOLS)}})
|
|||
|
|
else:
|
|||
|
|
merged.append(item)
|
|||
|
|
return merged, replaced
|
|||
|
|
|
|||
|
|
|
|||
|
|
def prompt_payloads(
|
|||
|
|
rows: list[dict[str, Any]], versions: list[int], release_id: int
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
payloads: list[dict[str, Any]] = []
|
|||
|
|
for row, version in zip(rows, versions, strict=True):
|
|||
|
|
payload: dict[str, Any] = {"release_id": release_id, "version": version}
|
|||
|
|
for field in PROMPT_API_FIELDS:
|
|||
|
|
value = row.get(field)
|
|||
|
|
payload[field] = _as_json(value) if field.endswith("_schema") else value
|
|||
|
|
payloads.append(payload)
|
|||
|
|
return payloads
|
|||
|
|
|
|||
|
|
|
|||
|
|
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:
|
|||
|
|
parser = argparse.ArgumentParser(description="补发客服工具白名单(画像工具)")
|
|||
|
|
parser.add_argument("--dry-run", action="store_true", help="只打印将写入的内容,不调用任何写接口")
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
|
|||
|
|
if not code_allows_profile_tool():
|
|||
|
|
print("[警告] 代码里的 customer_service.allowed_tools 还没有 "
|
|||
|
|
f"{PROFILE_TOOL}(仍为合并前状态)。")
|
|||
|
|
print(" admin 端会以「配置超出 Agent 工具上限」422 拒绝这一版 —— "
|
|||
|
|
"请先合并 NL_develop。")
|
|||
|
|
if not args.dry_run:
|
|||
|
|
print("[失败] 正式发布已中止。")
|
|||
|
|
return 1
|
|||
|
|
print(" dry-run 继续,仅预览将要写入的内容。\n")
|
|||
|
|
|
|||
|
|
snap = await snapshot()
|
|||
|
|
items = [
|
|||
|
|
{
|
|||
|
|
"namespace": row["namespace"],
|
|||
|
|
"item_key": row["config_key"],
|
|||
|
|
"value_json": _as_json(row["value_json"]),
|
|||
|
|
"schema_version": row["schema_version"],
|
|||
|
|
}
|
|||
|
|
for row in snap["platform_config_item"]
|
|||
|
|
]
|
|||
|
|
prompts = list(snap["prompt_template_version"])
|
|||
|
|
rules = list(snap["model_routing_rule"])
|
|||
|
|
|
|||
|
|
faq = current_faq_tools(items)
|
|||
|
|
if faq is None:
|
|||
|
|
print(f"[失败] 生效版本里没有 {INTENT_KEY},先跑 tools/publish_customer_service_config.py")
|
|||
|
|
print(f" 快照键:{sorted(snap)}")
|
|||
|
|
print(f" 配置项 {len(items)} 条:{[item.get('item_key') for item in items]}")
|
|||
|
|
return 1
|
|||
|
|
if PROFILE_TOOL in faq:
|
|||
|
|
print(f"✅ {INTENT_KEY} 已包含 {PROFILE_TOOL}({faq}),无需发布")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
merged, replaced = merged_items(items)
|
|||
|
|
versions = await next_prompt_versions(prompts)
|
|||
|
|
|
|||
|
|
print(f"当前生效版本:配置项 {len(items)} 条、提示词 {len(prompts)} 条、路由规则 {len(rules)} 条")
|
|||
|
|
print("\n本次变更:")
|
|||
|
|
for line in replaced:
|
|||
|
|
print(f" · {line}")
|
|||
|
|
print(f"\n将继承:{len(merged)} 条配置项、{len(prompts)} 条提示词")
|
|||
|
|
for prompt, version in zip(prompts, versions, strict=True):
|
|||
|
|
print(f" · 提示词 {prompt['prompt_code']} v{prompt['version']} → v{version}(重分配)")
|
|||
|
|
|
|||
|
|
if rules:
|
|||
|
|
# 本环境当前为空;不为空就必须先支持搬运,否则激活后会静默丢规则。
|
|||
|
|
print(f"\n❌ 生效版本里有 {len(rules)} 条 model_routing_rule,本脚本尚未支持搬运。")
|
|||
|
|
print(" 直接发布会把它们清空 —— 先补上搬运逻辑再跑。")
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
if args.dry_run:
|
|||
|
|
print("\n[dry-run] 未调用任何写接口。去掉 --dry-run 即真正发布。")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
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:
|
|||
|
|
created = await post(client, "/api/v1/admin/config-releases", auth=auth, payload={
|
|||
|
|
"release_no": f"cs-profile-tool-{uuid.uuid4().hex[:12]}",
|
|||
|
|
"title": "客服画像工具白名单",
|
|||
|
|
"change_summary": (
|
|||
|
|
f"{INTENT_KEY} 增加 {PROFILE_TOOL}(画像问答出口),"
|
|||
|
|
f"并继承既有 {len(merged)} 条配置项与 {len(prompts)} 条提示词"
|
|||
|
|
),
|
|||
|
|
})
|
|||
|
|
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 merged:
|
|||
|
|
response = await post(client, base, auth=auth, payload=item)
|
|||
|
|
if response.status_code != 201:
|
|||
|
|
print(f" 写配置项 {item['item_key']} 失败:{response.status_code} {response.text[:200]}")
|
|||
|
|
return 1
|
|||
|
|
print(f" 已写入 {len(merged)} 条配置项")
|
|||
|
|
|
|||
|
|
for payload in prompt_payloads(prompts, versions, release_id):
|
|||
|
|
response = await post(client, "/api/v1/admin/prompt-templates", auth=auth, payload=payload)
|
|||
|
|
if response.status_code not in (200, 201):
|
|||
|
|
print(f" 写提示词 {payload['prompt_code']} 失败:{response.text[:200]}")
|
|||
|
|
return 1
|
|||
|
|
print(f" 已写入 {len(prompts)} 条提示词")
|
|||
|
|
|
|||
|
|
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}")
|
|||
|
|
if submitted.status_code not in (200, 201):
|
|||
|
|
print(f" 失败:{submitted.text[:300]}")
|
|||
|
|
return 1
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
after = await snapshot()
|
|||
|
|
restored = current_faq_tools([
|
|||
|
|
{
|
|||
|
|
"item_key": row["config_key"],
|
|||
|
|
"value_json": _as_json(row["value_json"]),
|
|||
|
|
}
|
|||
|
|
for row in after["platform_config_item"]
|
|||
|
|
])
|
|||
|
|
print(f"\n激活后:{INTENT_KEY} = {restored}")
|
|||
|
|
print(f" 配置项 {len(after['platform_config_item'])} 条、"
|
|||
|
|
f"提示词 {len(after['prompt_template_version'])} 条")
|
|||
|
|
if PROFILE_TOOL not in (restored or []):
|
|||
|
|
print("[失败] 激活后白名单里仍没有画像工具,请人工核查")
|
|||
|
|
return 1
|
|||
|
|
if len(after["prompt_template_version"]) != len(prompts):
|
|||
|
|
print("[失败] 提示词条数变了,可能发生了静默丢失,请人工核查")
|
|||
|
|
return 1
|
|||
|
|
print("[OK] 完成")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
sys.exit(asyncio.run(main()))
|