新增补发画像工具白名单的脚本(dry-run + 合并前防呆)

tools/publish_profile_tool_whitelist.py:把 query_customer_profile 加进
customer_service:faq,供 NL_develop 合并后在本环境补发配置。

关键设计都来自这轮评审核实到的事实:

1. 继承走 ConfigReleaseService.effective_snapshot(),一次拿全三张受管表
   (platform_config_item / prompt_template_version / model_routing_rule)。
   既有的两个 agent_tools 发布脚本都绕开它、自己写 SQL 只查第一张 —— 用它们发版
   会把 201 里那条客服闲聊提示词一起清掉,而 Agent 侧有兜底、功能看着正常、没有告警
   (这个事故在本项目真实发生过一次)。
2. 同 key 覆盖:继承项里的 customer_service:faq 必须被本次新值覆盖,否则旧值
   ["search_knowledge"] 会被 admin 端校验拦下、整次发布失败。
3. 提示词搬运重分配 version(唯一键含 version),并按 PromptPayload 的 9 个字段挑字段、
   归一化 input_schema/output_schema —— 漏带就是静默丢失。
4. 前置防呆:admin 端校验「配置 ⊆ 代码 allowed_tools」(admin_service.py:219-220),
   合并前跑必然 422,而报错只有"配置超出 Agent 工具上限"。脚本先查代码上限:
   dry-run 给警告后继续预览,正式跑直接中止。
5. JSON 列归一化:SELECT * 读出来的 JSON 列可能是字符串,不解析会静默走进
   "生效版本里没有该 key"的分支(本脚本第一版就是这么错的,诊断打印才定位到)。

实测:--dry-run 打印 9 条配置项 + 1 条提示词(v2→v3 重分配)与唯一变化;
正式跑因代码尚未合并而中止,未产生任何写入。
This commit is contained in:
2026-09-11 19:51:44 +08:00
parent d28ccdd88c
commit 6c09cdecbd
+337
View File
@@ -0,0 +1,337 @@
"""补发客服工具白名单:把 `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()))