"""把客服闲聊提示词发布为可审核、可回滚的配置版本。 业务方选定「提示词走发布配置」而不是写死在代码里:改话术要经过审核并留痕, 这也符合金融场景对口径变更的要求。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()))