Files
group_fqcd_jr/tools/publish_customer_service_tool_config.py
张胜宇 3e24033f72 feat(W27): L0 表层判定层 + 出口 E6 行情 + 收益过滤槽位白名单 + 免责声明分档(设计与代码同轮完成)
设计与依据:新增 D3.9-客服Agent智能路由与行情出口设计-2026-09-21.md(CS-ARCH-2026-024)
DEC-W27-1~12 全部批准并落地。

## 新增(3 个源文件 + 3 个测试文件)
- app/core/exit_codes.py:出口码注册表(E0/E1/E2a~E2e/E3/E4/E5a~E5c/E6/E8/CHAT/LOGIN/CONTACT)
  + NON_BUSINESS_EXIT_CODES 白名单(免责分档的判据面)
- app/service/fund_trend_service.py(E6):summarize_trend() 纯函数 + query_fund_trend 工具
  读 fin_nav_history;按净值日个数(5/20/60/120)给区间涨跌 / 区间高低 / 来源与区间
- tests/unit/{service,tools} 新增 45 条守卫单测(E6 出口 / L0 表层判定 / 工具配置)

## 改动(重点)
- customer_service.py:
  · _route_surface()(L0-a 闲聊 / L0-b 行情 / L0-e 无信息量),位置在安全路由之后
  · drop_yield_claims() 重写为「入口闸 × 槽位白名单」+ fail-closed
    (旧黑名单会误删权重:A-06 业绩基准公式整行消失)
  · allowed_tools 补 query_fund_trend —— 修掉发布被拒 422「配置超出 Agent 工具上限」
    (该 422 是**子集**校验而非数量上限,根因即此项漏配)
  · 25 处 CoreResult 全部补 exit_code(AST 守卫保证零遗漏)
- customer_service_rules.py:闲聊判定重写(业务实体边界 + 词表 + 特征串 + 语气词)
  + is_low_information_message() + is_contact_inquiry()
- governance.py:免责声明按 exit_code 分档(业务档完整 / 非业务档轻型)
- actor.py:VISITOR_PERMISSIONS 补 fund:quote:read(E6 对访客开放,DEC-W27-11)
- tools/:seed_compliance_baseline 新增 TPL_DISCLAIMER_LIGHT;publish 工具改为
  「从生效版本派生原列表再追加」(_inherit_tools 取不到返回 None,防静默改窄)

## 实测
- 金标扩容 46 → 55(新增 Q 组行情 5 条 + C 组闲聊 4 条)
- M-1 55/55、M-4 55/55;M-5 与四项零容忍(M-7/M-8/M-9/M-10)全 0
- 原 46 条可比基线逐项不变(46/46);M-6 9.1%(5 条全在转人工白名单内)
- 全量 pytest 2094 passed / 3 skipped;ruff 零新增(余 5 条与 HEAD 逐条对应)
- 配置版本 244(cs-tools-75813de45421)已发布并激活

## 文档
- 新增 D3.9(设计专册);D1.1 §34 补「代码实施已同轮完成」并把旧表述作废
- D3.7:新增 Q 组判据表 + §6.4 第四次实测 + M-3/M-9 口径澄清
- D2.9:新增 §2.11(9 条新增用例的实测答复原文)+ 汇总表改双列口径
- D2.1:新增 v6.41 执行条目
- 权威副本(D:\桌面\金融\)→ 仓库镜像 全量比对一致(0 缺失 / 0 不一致)

## 诚实未做项
- 知识出口(E3/E4)侧的实体锚点闸门未做(故金标扩容 9 条而非 12 条,A-11~A-13 未成集)
- 阈值未重标(D3.9 §3.4 只给方法);M-3 分母未改(仅明示口径);
  D1.1 §0 与 §4.0 历史计数偏移(差 1)仍沿用
2026-09-21 22:32:39 +08:00

365 lines
14 KiB
Python
Raw Permalink 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.
"""Publish the customer-service tool allowlist for the ``E2c-my`` eligible-product exit.
Why a release instead of an UPDATE: the active ``config_release`` is environment data, and
``call_tool`` resolves the per-intent allowlist from it. ``E2c-my`` needs
``query_eligible_products`` under ``customer_service:suitability_check``; without this item the
tool call fails closed with ``AGENT_PERMISSION_DENIED`` and the exit degrades to ``E5b``
("can't get your risk level"), which looks like a data problem instead of a config problem.
The script carries forward every item of the active release and replaces only the
``agent_tools/customer_service:suitability_check`` entry, so nothing else changes.
"""
from __future__ import annotations
import argparse
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
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.core.config import get_settings # noqa: E402
from app.main import create_app # noqa: E402
ADMIN_ID = "9003"
AGENT_TYPE = "customer_service"
#: 只改这一个键。`faq` / `policy_explain` / `product_inquiry` 原样继承,不在本脚本里重写
#: —— 少写一个键就少一处"顺手改宽"的机会。
INTENT_TOOLS: dict[str, tuple[str, ...]] = {
"suitability_check": (
"search_knowledge",
"check_suitability",
"query_eligible_products",
),
}
#: ✅ `W27` / `DEC-W27-6`:`E6` 行情出口要用的工具,加进 `customer_service:faq`。
#:
#: **为什么加在 `faq` 键**:`query_customer_profile` 已经在那里,而发布配置里
#: `customer_service:faq` 是唯一一个"跨意图工具都挂上"的键;新开一个键会让
#: `customer_service:<intent>` 的工具交集在不同意图下不一致,调用会 fail-closed
#: —— 那会表现成"行情功能坏了",而不是"配置少了"。
#:
#: ⚠️ 这个键**不从硬编码列表重建**,而是从**当前生效版本读出原列表再追加**(见
#: `_inherit_tools`)。理由:硬编码一份"当时恰好有哪些工具"的快照,会在别处增删工具后
#: 被下一次跑本脚本**静默回退** —— 那是比漏加一个工具危险得多的失败形态。
TREND_TOOL_ADDITIONS: dict[str, tuple[str, ...]] = {
"faq": ("query_fund_trend",),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Publish customer-service demo tool allowlist")
parser.add_argument("--apply", action="store_true", help="Create, review, and activate release")
return parser.parse_args()
def issue_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_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()
return [
{
"namespace": namespace,
"item_key": key,
"value_json": json.loads(value) if isinstance(value, str) else value,
"schema_version": schema_version,
}
for namespace, key, value, schema_version in rows
]
def _json_or_none(value: Any) -> Any:
"""库里的 JSON 列取出来可能是字符串 ``"null"`` / ``"{}"``,统一还原成对象。"""
if value is None:
return None
if isinstance(value, str):
parsed = json.loads(value)
return None if parsed is None else parsed
return value
async def active_prompt_templates() -> list[dict[str, Any]]:
"""取要继承的提示词模板行(`prompt_template_version`),版本号 +1。
为什么必须有这一步:提示词模板与工具白名单**不在同一张表**。只继承
`platform_config_item` 建新版本,会把提示词**静默丢掉** —— 激活接口只给一句告警,
点下去那条提示词就真的失效了。实测 2026-09-20:`customer_service_chitchat`
因此丢过一次(激活版里 0 行),闲聊出口当场退化成兜底话术。
所以这里两级回退:**活跃版本有就继承活跃的;活跃版本没有,就从最近一个有该表的版本继承**
(并把这件事打在屏幕上,避免"看起来一切正常")。
"""
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
)
columns = (
"release_id, prompt_code, task_type, agent_type, version, "
"system_prompt, user_prompt_template, input_schema, output_schema"
)
try:
cursor = connection.cursor()
await cursor.execute(
f"SELECT {columns} FROM prompt_template_version WHERE release_id = "
"(SELECT id FROM config_release WHERE status = 'active')"
)
rows = await cursor.fetchall()
if not rows:
print(
"WARNING: active release has no prompt_template_version rows; "
"inheriting from the newest release that does"
)
await cursor.execute(
f"SELECT {columns} FROM prompt_template_version "
"WHERE release_id = (SELECT MAX(release_id) FROM prompt_template_version)"
)
rows = await cursor.fetchall()
finally:
connection.close()
return [
{
"prompt_code": prompt_code,
"task_type": task_type,
"agent_type": agent_type,
"version": int(version) + 1,
"system_prompt": system_prompt,
"user_prompt_template": user_prompt_template,
"input_schema": _json_or_none(input_schema),
"output_schema": _json_or_none(output_schema),
}
for (
_release_id,
prompt_code,
task_type,
agent_type,
version,
system_prompt,
user_prompt_template,
input_schema,
output_schema,
) in rows
]
async def request(
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(client: httpx.AsyncClient, path: str, auth: dict[str, str]) -> str | None:
return (await client.get(path, headers=auth)).headers.get("ETag")
def _inherit_tools(
inherited: list[dict[str, Any]], intent: str, additions: tuple[str, ...]
) -> tuple[str, ...] | None:
"""读出生效版本里 `customer_service:<intent>` 的工具列表,再追加 `additions`。
取不到生效条目时返回 `None`(**不猜**):调用方会跳过该键并在日志里说明。
这里刻意不"取不到就用 additions 新建一个" —— 那会把 `faq` 键原有的十来个
工具一次性砍掉,是最坏的一种"顺手改窄"。
"""
key = f"{AGENT_TYPE}:{intent}"
for item in inherited:
if str(item.get("namespace")) != "agent_tools" or str(item.get("item_key")) != key:
continue
value = item.get("value_json")
if isinstance(value, str):
# `active_items()` 已经解析过一次,这里是防御性分支。
# 解析失败**不抛**:抛出去会让整个发布失败,而"发布失败"与
# "跳过该键、让新版本原样继承旧条目"相比,后者才是"最接近没改动"的结果
# (失败时 `customer_service:faq` 仍是旧值,安全,但操作者会以为脚本坏了)。
try:
value = json.loads(value)
except json.JSONDecodeError:
return None
if not isinstance(value, dict):
return None
tools = value.get("allowed_tools")
if not isinstance(tools, list):
return None
merged = [str(tool) for tool in tools]
for tool in additions:
if tool not in merged:
merged.append(tool)
return tuple(merged)
return None
def cs_items() -> list[dict[str, object]]:
return [
{
"namespace": "agent_tools",
"item_key": f"{AGENT_TYPE}:{intent}",
"value_json": {"allowed_tools": list(tools)},
"schema_version": "1",
}
for intent, tools in INTENT_TOOLS.items()
]
async def publish() -> int:
app = create_app()
auth = {"Authorization": f"Bearer {issue_token(ADMIN_ID)}"}
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
inherited = await active_items()
prompt_templates = await active_prompt_templates()
items = cs_items()
for intent, additions in TREND_TOOL_ADDITIONS.items():
merged = _inherit_tools(inherited, intent, additions)
if merged is None:
print(f"skip: 生效版本里没有 agent_tools/{AGENT_TYPE}:{intent},不新建以免改窄")
continue
items.append({
"namespace": "agent_tools",
"item_key": f"{AGENT_TYPE}:{intent}",
"value_json": {"allowed_tools": list(merged)},
"schema_version": "1",
})
print(f"{AGENT_TYPE}:{intent} -> {', '.join(merged)}")
replacement_keys = {("agent_tools", str(item["item_key"])) for item in items}
carried = [
item
for item in inherited
if (str(item["namespace"]), str(item["item_key"])) not in replacement_keys
]
created = await request(
client,
"/api/v1/admin/config-releases",
auth=auth,
payload={
"release_no": f"cs-tools-{uuid.uuid4().hex[:12]}",
"title": "Customer service tool allowlist (E2c-my eligible + E6 fund trend)",
"change_summary": (
"Add query_eligible_products to suitability_check; add query_fund_trend to faq"
),
},
)
if created.status_code != 201:
print(f"create release failed: {created.status_code} {created.text[:240]}")
return 1
release_id = int(created.json()["data"]["id"])
base = f"/api/v1/admin/config-releases/{release_id}"
for item in [*carried, *items]:
response = await request(
client, f"{base}/platform-config-items", auth=auth, payload=item
)
if response.status_code != 201:
print(
f"write {item['namespace']}/{item['item_key']} failed: "
f"{response.status_code} {response.text[:400]}"
)
return 1
for template in prompt_templates:
response = await request(
client,
# 提示词模板**不走** release 作用域前缀:`register_resource` 对它用的
# 是 `scoped=False`,路由挂在 `/api/v1/admin/prompt-templates`,
# release 由 body 里的 `release_id` 指定(实测 2026-09-20 踩过 404)。
"/api/v1/admin/prompt-templates",
auth=auth,
payload={**template, "release_id": release_id},
)
if response.status_code != 201:
print(
f"write prompt {template['prompt_code']} failed: "
f"{response.status_code} {response.text[:240]}"
)
return 1
for suffix, payload in (
("validations", {}),
("reviews", {"decision": "approved", "comment": "add eligible-product + fund-trend tools"}),
("activations", {}),
):
response = await request(
client,
f"{base}/{suffix}",
auth=auth,
payload=payload,
if_match=await etag(client, base, auth),
)
if response.status_code not in (200, 201):
print(f"{suffix} failed: {response.status_code} {response.text[:240]}")
return 1
print(f"published customer-service tool release id={release_id}")
return 0
def main() -> int:
args = parse_args()
for intent, tools in INTENT_TOOLS.items():
print(f"customer_service:{intent} -> {', '.join(tools)}")
if not args.apply:
print("dry run only; pass --apply to publish a new config release")
return 0
return asyncio.run(publish())
if __name__ == "__main__":
raise SystemExit(main())