Files
group_fqcd_jr/tools/publish_chitchat_prompt.py
T
lzf_0626 20a3a2f249 feat: 客服闲聊提示词发布为可配置版本(提示词接入发布配置闭环)
业务方选定提示词走发布配置而不是写死在代码里:改话术要经过审核并留痕,符合金融场景
对口径变更的要求。Agent 侧读取上一批已实现(runtime_config_service.load_active_prompt),
本次补上发布侧,形成闭环。

脚本处理两条路径,实测第一条被拒、自动走了第二条:
1. 直接挂到当前生效版本 → 实测 409「只能修改草稿发布版本」,生效版本不可追加;
2. 新建发布版本,先**原样继承现有全部配置项**再追加提示词。原因:config_release 是
   整版本替换语义,不继承就会把其他 Agent 的工具白名单清空(发布客服白名单时已踩过
   一次这个坑,这次直接带上了继承逻辑)。

结果:新发布版本 174 生效,含 4 条 agent_tools 白名单 + 1 条 prompt_template_version
(prompt_code=customer_service_chitchat、task_type=chat、agent_type=customer_service)。

验证:load_active_prompt 能读到该提示词(release_id=174、version=1、checksum 已生成);
闲聊功能正常(意图 chitchat 置信 0.95,回答带免责声明)。

顺带记录一处错误码语义问题(本次不改):对「生效版本不可追加」这种资源状态冲突,
服务端返回的错误码是 RUN_NOT_CANCELLABLE,与场景不符。原因是 docs/05 §3.6 的码表里
没有表示「资源状态不允许该操作」的码,于是被复用了语义最近的运行类错误码。
建议后续在码表里补一个状态类错误码,而不是继续复用无关的码。
2026-09-10 20:41:09 +08:00

221 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""把客服闲聊提示词发布为可审核、可回滚的配置版本。
业务方选定「提示词走发布配置」而不是写死在代码里:改话术要经过审核并留痕,
这也符合金融场景对口径变更的要求。Agent 侧通过 `load_active_prompt` 读取,
读不到时回落到代码内置默认值(配置缺失不影响可用性,只是不可配)。
两条路径都处理:
1. 若当前生效版本仍可追加数据(draft/approved),直接挂上去,最省事;
2. 若被状态机拒绝(生效版本不可变),则新建一个发布版本,**原样继承现有全部配置项**
再追加提示词——`config_release` 是整版本替换语义,不继承就会把其他 Agent 的配置清空。
跑法:python tools/publish_chitchat_prompt.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 sqlalchemy import 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 ConfigRelease, PromptTemplateVersion
ADMIN = "9003"
PROMPT_CODE = "customer_service_chitchat"
TASK_TYPE = "chat"
AGENT_TYPE = "customer_service"
# 提示词正文:与 Agent 代码里的默认值保持一致,发布后即成为唯一可配置来源。
SYSTEM_PROMPT = (
"你是南方科技的智能客服助手。回应要简短、礼貌,并自然引导用户提出与基金、理财、"
"账户相关的问题。禁止承诺收益,禁止出现「保本」「稳赚」「无风险」「保证收益」"
"「预期收益率」「年化收益率」「安全」等表述。"
)
USER_PROMPT_TEMPLATE = "用户说:{message}\n请用不超过 40 字回应,并把话题引导到业务上。"
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_release_id() -> int | None:
async with SessionFactory() as session:
release = await session.scalar(
select(ConfigRelease).where(ConfigRelease.status == "active")
)
return release.id if release is not None else None
async def prompt_already_published() -> bool:
"""当前生效版本里是否已经有这条提示词。"""
release_id = await active_release_id()
if release_id is None:
return False
async with SessionFactory() as session:
row = await session.scalar(
select(PromptTemplateVersion).where(
PromptTemplateVersion.release_id == release_id,
PromptTemplateVersion.prompt_code == PROMPT_CODE,
PromptTemplateVersion.agent_type == AGENT_TYPE,
)
)
return row is not None
async def active_config_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()
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")
def prompt_payload(release_id: int) -> dict[str, object]:
return {
"release_id": release_id,
"prompt_code": PROMPT_CODE,
"task_type": TASK_TYPE,
"agent_type": AGENT_TYPE,
"version": 1,
"system_prompt": SYSTEM_PROMPT,
"user_prompt_template": USER_PROMPT_TEMPLATE,
}
async def main() -> int:
if await prompt_already_published():
print("当前生效版本已包含该提示词,无需发布")
return 0
release_id = await active_release_id()
if release_id is None:
print("没有生效版本,先跑 tools/publish_customer_service_config.py")
return 1
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:
# 路径一:直接挂到当前生效版本
direct = await post(
client, "/api/v1/admin/prompt-templates", auth=auth,
payload=prompt_payload(release_id),
)
print(f"直接挂到生效版本 {release_id}:{direct.status_code} {direct.text[:160]}")
if direct.status_code in (200, 201):
print("完成:提示词已挂到当前生效版本")
return 0
# 路径二:新建发布版本,继承现有配置项后追加提示词
print("生效版本不可追加,改为新建发布版本并继承现有配置项")
inherited = await active_config_items()
print(f" 待继承配置项 {len(inherited)} 条")
created = await post(client, "/api/v1/admin/config-releases", auth=auth, payload={
"release_no": f"cs-prompt-{uuid.uuid4().hex[:12]}",
"title": "客服闲聊提示词",
"change_summary": "发布客服闲聊提示词,并继承现有配置项",
})
if created.status_code != 201:
print(f" 创建发布版本失败:{created.status_code} {created.text[:200]}")
return 1
new_release = int(created.json()["data"]["id"])
print(f" 新发布版本 id={new_release}")
for item in inherited:
response = await post(
client, f"/api/v1/admin/config-releases/{new_release}/platform-config-items",
auth=auth, payload=item,
)
if response.status_code != 201:
print(f" 继承 {item['item_key']} 失败:{response.text[:160]}")
return 1
print(f" 已继承 {len(inherited)} 条配置项")
added = await post(
client, "/api/v1/admin/prompt-templates", auth=auth,
payload=prompt_payload(new_release),
)
print(f" 添加提示词:{added.status_code} {added.text[:160]}")
if added.status_code not in (200, 201):
return 1
base = f"/api/v1/admin/config-releases/{new_release}"
await post(client, f"{base}/validations", auth=auth, payload={},
if_match=await etag_of(client, base, auth))
await post(client, f"{base}/reviews", auth=auth,
payload={"decision": "approved", "comment": "客服闲聊提示词"},
if_match=await etag_of(client, base, auth))
activated = await post(client, f"{base}/activations", auth=auth, payload={},
if_match=await etag_of(client, base, auth))
print(f" 激活:{activated.status_code}")
if activated.status_code not in (200, 201):
print(f" 失败:{activated.text[:200]}")
return 1
print(f"完成:新发布版本 {new_release} 已生效")
return 0
sys.exit(asyncio.run(main()))