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