Files
group_fqcd_jr/tools/publish_chitchat_prompt.py
T
lzf_0626 f72a545c39 refactor: 品牌统一为「南方财富」(项目方定)
背景:品牌名此前三处不一致 —— 后端 Agent 自称「奶龙基金」(customer_service_rules
的防诈骗/转人工话术、risk_agent 与 risk_analysis_service 的系统提示词)、前端全站
「南方财富」、闲聊提示词与知识素材「南方科技」。docs/36 已把它登记为"上报项目方后
待定",现按项目方决定统一为「南方财富」。

改动:
- app/core/customer_service_rules.py:P0 防诈骗话术与 P2 转人工话术里的品牌名
- app/service/agent/implementations/customer_service.py:COMPANY 常量,
  以及那处引用实测样本的注释(改为不绑定具体品牌名,免得下次改名又过时)
- app/service/agent/implementations/risk_agent.py:docstring、自我介绍、system prompt
- app/service/risk_analysis_service.py:SYSTEM_PROMPT
- app/worker/risk_scan_scheduler.py:--help 描述
- app/static/index.html(旧联调页 4 处)、portal/employee-risk/dashboard/index.html
- tests/unit/api/test_customer_service_test_page.py:同步断言(它断言的正是页面里的品牌名)
- tools/publish_chitchat_prompt.py:SYSTEM_PROMPT 改品牌;并修掉"存在即跳过"的检查
  —— 原来只判当前版本有没有这一行,于是改了文案也发不出去(脚本打印"无需发布"直接
  退出),没有任何提示。改为比对 system_prompt/user_prompt_template 内容。

闲聊提示词已重发为 release 308 / v5,生效内容为"你是南方财富的智能客服助手…"。

刻意未动:
- fin_product.fund_manager = "南方基金" —— 它被 market_quote_sync_service 与
  product_history_sync_service 当过滤条件使用,改名会让同步链路查不到产品
- knowledge_search_service.py 注释里引用的知识库实际标题「南方科技有限公司…」
- docs/客服docs 下的历史素材与 docs/ 下的过程记录(属历史留痕)

⚠️ Milvus 里的知识条目仍写「奶龙基金」(RAG-*/NF-*)与「南方科技」(PROD-*),
属知识数据,需重灌才能统一;本轮不动。

同时:
- docs/40 把品牌条目标为已处理,并补上知识库缺口的现状
- 新增 docs/42-场内基金知识条目草稿.md:按 fin_product 的 20 只产品生成,
  含通用交易规则与产品清单;费率等缺失字段一律标"以交易页面为准",未编造数字。
  **该文件是草稿,未入库**,待审核后走 POST /api/v1/knowledge/documents 灌库。
2026-09-13 19:17:56 +08:00

253 lines
10 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.
"""把客服闲聊提示词发布为可审核、可回滚的配置版本。
业务方选定「提示词走发布配置」而不是写死在代码里:改话术要经过审核并留痕,
这也符合金融场景对口径变更的要求。
**为什么要重发**:提示词配过(挂在 release 174),但 174 已被取代;当前 active 是 181,
而 `load_active_prompt` 是先定位 active 版本、再按 `release_id` 查的 —— 于是读不到,
Agent 回落到代码里的默认提示词。功能看着正常(`_chitchat_prompt` 有逐字段兜底),
所以一直没人发现,也没有任何告警。
三个必须处理的点:
1. **`version` 必须重新分配。** `prompt_template_version` 的唯一键是
`(prompt_code, version)`,客服那条已经占了 `v1`;照搬旧行会主键冲突。这里取
现有最大值 +1。
2. **继承要走 `ConfigReleaseService.effective_snapshot()`。** `config_release` 是整版本
替换语义,新版本没带上的配置项就等于被删。快照读的是**全部三张**受管表
(platform_config_item / prompt_template_version / model_routing_rule),
漏读一张就是一次静默失效 —— 这次的事故正是这么来的。
3. **快照是库的形状,API 是另一套字段名**(`config_key` ↔ `item_key`),
所以搬运时必须映射,不能把快照行直接 POST。
跑法: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 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.config_release_service import ConfigReleaseService
ADMIN = "9003"
PROMPT_CODE = "customer_service_chitchat"
TASK_TYPE = "chat"
AGENT_TYPE = "customer_service"
# 提示词正文:与 Agent 代码里的默认值保持一致 —— 发布后这份配置即成为话术的唯一来源
# (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:
return await ConfigReleaseService(session).active_release_id()
async def prompt_needs_publish() -> bool:
"""当前生效版本里的这条提示词,是否与脚本定义**一致** —— 一致才算无需重发。
⚠️ 不能只判「有没有这一行」:那样改了文案也发不出去(脚本会认为"已存在"直接退出),
表现是"改了 SYSTEM_PROMPT、跑完脚本、生效的还是旧话术",而且没有任何提示。
品牌名从「南方科技」改成「南方财富」时正好撞上这个坑。
"""
async with SessionFactory() as session:
release_id = await ConfigReleaseService(session).active_release_id()
if release_id is None:
return True
row = await session.scalar(
select(PromptTemplateVersion).where(
PromptTemplateVersion.release_id == release_id,
PromptTemplateVersion.prompt_code == PROMPT_CODE,
PromptTemplateVersion.agent_type == AGENT_TYPE,
)
)
if row is None:
return True
return (
row.system_prompt != SYSTEM_PROMPT
or row.user_prompt_template != USER_PROMPT_TEMPLATE
)
async def next_prompt_version(prompt_code: str) -> int:
"""该 prompt_code 的下一个可用版本号(唯一键是 `prompt_code + version`)。"""
async with SessionFactory() as session:
latest = await session.scalar(
select(func.max(PromptTemplateVersion.version)).where(
PromptTemplateVersion.prompt_code == prompt_code
)
)
return int(latest or 0) + 1
async def inherited_items() -> list[dict[str, Any]]:
"""当前生效版本在 `platform_config_item` 里的内容,转成 API 载荷形状。
字段名不同(库是 `config_key`,API 是 `item_key`),而且 `value_json` 从驱动读出来
可能是字符串,这里一并归一化 —— 直接 POST 快照行会 422。
"""
async with SessionFactory() as session:
snapshot = await ConfigReleaseService(session).effective_snapshot()
return [
{
"namespace": row["namespace"],
"item_key": row["config_key"],
"value_json": (
json.loads(row["value_json"])
if isinstance(row["value_json"], str) else row["value_json"]
),
"schema_version": row["schema_version"],
}
for row in snapshot["platform_config_item"]
]
async def prompt_versions_in_active_release() -> list[dict[str, Any]]:
"""当前生效版本里的提示词(用于搬运;本次为空,但保留这一步以免将来漏搬)。"""
async with SessionFactory() as session:
snapshot = await ConfigReleaseService(session).effective_snapshot()
return list(snapshot["prompt_template_version"])
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, version: int) -> dict[str, object]:
return {
"release_id": release_id,
"prompt_code": PROMPT_CODE,
"task_type": TASK_TYPE,
"agent_type": AGENT_TYPE,
"version": version,
"system_prompt": SYSTEM_PROMPT,
"user_prompt_template": USER_PROMPT_TEMPLATE,
}
async def main() -> int:
if not await prompt_needs_publish():
print("当前生效版本里的提示词与脚本定义一致,无需发布")
return 0
release = await active_release_id()
if release is None:
print("没有生效版本,先跑 tools/publish_customer_service_config.py")
return 1
version = await next_prompt_version(PROMPT_CODE)
items = await inherited_items()
carried_prompts = await prompt_versions_in_active_release()
print(f"当前生效版本={release};待继承配置项 {len(items)} 条、提示词 {len(carried_prompts)} 条")
print(f"本次发布提示词 {PROMPT_CODE} v{version}")
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, version),
)
print(f"直接挂到生效版本 {release}:{direct.status_code} {direct.text[:160]}")
if direct.status_code in (200, 201):
print("完成:提示词已挂到当前生效版本")
return 0
# 路径二:新建发布版本,继承现有配置项后追加提示词
print("生效版本不可追加,改为新建发布版本并继承现有配置项")
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 items:
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(items)} 条配置项")
added = await post(
client, "/api/v1/admin/prompt-templates", auth=auth,
payload=prompt_payload(new_release, version),
)
print(f" 添加提示词 v{version}:{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} 已生效,提示词 v{version} 随之生效")
return 0
sys.exit(asyncio.run(main()))