fix: 修好访客客服浮窗、投顾工作台数据口径,恢复访客页来源声明
组员这次提交的两套新页面方向都对,但各有一处"接不上"的地方,这里补齐。 1) 访客客服浮窗(此前一问即失败) 客服 Agent 让访客走 query_knowledge(访客令牌的角色是 visitor、权限只有 agent:run + knowledge:query),但发布配置里三个知识意图只发了 search_knowledge, 于是 ToolExecutor 直接抛 ForbiddenAgentError,而客服代码对白名单失败是 「必须冒泡」的 —— 访客拿不到任何回答,登录客户侧却完全正常。 发布脚本的知识类意图改为同时发 search_knowledge 与 query_knowledge: 访客走前者、客户走后者,缺任一条对应人群就失败关闭。 (suitability_check 不发 query_knowledge:访客意图白名单不含它,访客到不了。) 2) 发布脚本会静默丢提示词 旧写法只查 platform_config_item 就当作"继承",而 config_release 是整版本替换 语义,新版本没带上的行等于被删除 —— 实际把 customer_service_chitchat 提示词 漏在了旧版本里(admin 端只在激活时打一句 stderr 警告)。 改为走 ConfigReleaseService.effective_snapshot() 读全三张受管表,补上提示词 搬运(version 重分配、带上 input_schema/output_schema),并在激活后硬校验 配置项与提示词条数,条数不符即失败退出。 (model_routing_rule 本环境为空;不为空则直接中止,不假装支持。) 丢失的那条提示词已按原文恢复,active 版本现为 9 条配置项 + 1 条提示词。 3) 投顾工作台永远为空 published() 取的是 customer_id == 自己 user_id,而投顾是员工账号、不可能是 客户;且只认 advisor_recommendation_plan + approved,而投顾交付的主产物是 investment_goal_book,发布后状态是 published。三重不匹配下页面永远显示空态。 改为按「本人 + sys_customer_assignment 里名下归属客户」过滤(不用 data_scope: 投顾因持有 all 级权限会把整个身份的 scope 抬到 all,那会放开到全部客户), 并覆盖两类 content_type 与两种已发布取值。 实测:投顾可见归属客户 9001 的方案书,客户仍只见自己的,风控仍 403。 4) 访客页把"演示数据"声明删了但假数据还在 mock-data.js 的 MOCK_SOURCE_NOTICE 与两个页面的 data-source-notice 区块被删除, 而 MOCK_PRODUCTS/MOCK_RANKING_CHANGE 仍在渲染(详情页含历史净值曲线)。 恢复声明常量、页面区块与样式,并给 products/product-detail 的 link 与 script 加上版本参数 —— 此前没有版本号,浏览器会命中旧缓存,改动看不见。 其他:投顾页显示交付物类型与客户编号(后端新返回的字段),README 补上投顾页 数据口径、访客/客户两条检索工具的差别,以及"渲染 mock 必须带来源声明"的约定。
This commit is contained in:
@@ -23,16 +23,30 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import asyncmy
|
||||
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"
|
||||
AGENT_TYPE = "customer_service"
|
||||
TOOL_NAME = "search_knowledge"
|
||||
# 访客检索工具。与 `TOOL_NAME` 是**同一个只读检索处理器**的别名(见 `bootstrap.py`),
|
||||
# 但权限码与角色集不同:
|
||||
# search_knowledge -> `knowledge:reference:read`,角色 customer/advisor/operator/admin
|
||||
# query_knowledge -> `knowledge:query`,角色 visitor/customer
|
||||
# 访客令牌(`VisitorTokenIssuer`)只带 `agent:run` + `knowledge:query`,角色是 `visitor`,
|
||||
# 所以公开浮窗**只能**走 `query_knowledge` —— 客服 Agent 也确实是这么分支的
|
||||
# (`_answer_from_knowledge` 里按 `"visitor" in context.roles` 选工具名)。
|
||||
# 漏发它的表现:访客在公开页面一问就抛 `ForbiddenAgentError`(工具不在意图白名单),
|
||||
# 而登录客户侧完全正常 —— 因为客户走的是 `search_knowledge`。
|
||||
# 所以知识类意图必须**同时**发两个:访客走前者、登录客户走后者。
|
||||
VISITOR_TOOL = "query_knowledge"
|
||||
SUITABILITY_TOOL = "check_suitability"
|
||||
# 画像只读工具:客服的"出口零"(本人风险等级/投资偏好/测评是否过期)走它取权威字段。
|
||||
# 那个出口复用的是 `faq` 意图 key(见 `customer_service.PROFILE_WHITELIST_INTENT`),
|
||||
@@ -42,10 +56,14 @@ PROFILE_TOOL = "query_customer_profile"
|
||||
# 只有会调用工具的意图才需要白名单;chitchat(模型生成)与 transfer_human(引导人工)
|
||||
# 都不查知识库。给它们配空白名单反而会掩盖"配置漏配",因此不发布这两条。
|
||||
INTENT_TOOLS: dict[str, tuple[str, ...]] = {
|
||||
"faq": (TOOL_NAME, PROFILE_TOOL),
|
||||
"product_inquiry": (TOOL_NAME,),
|
||||
"policy_explain": (TOOL_NAME,),
|
||||
# 适当性裁决要两步:先从知识库拿到产品的风险等级,再由底座按档案里的客户等级裁决
|
||||
# 三个知识意图都要**同时**发两个检索工具,原因见 VISITOR_TOOL 的注释:
|
||||
# 访客与登录客户落在同一个意图里,却走不同的工具名。
|
||||
"faq": (TOOL_NAME, VISITOR_TOOL, PROFILE_TOOL),
|
||||
"product_inquiry": (TOOL_NAME, VISITOR_TOOL),
|
||||
"policy_explain": (TOOL_NAME, VISITOR_TOOL),
|
||||
# 适当性裁决要两步:先从知识库拿到产品的风险等级,再由底座按档案里的客户等级裁决。
|
||||
# 这里**不发** VISITOR_TOOL:访客意图白名单(`VISITOR_INTENTS`)不含 suitability_check,
|
||||
# 访客根本到不了这条出口,发了只是噪音。
|
||||
"suitability_check": (TOOL_NAME, SUITABILITY_TOOL),
|
||||
}
|
||||
|
||||
@@ -65,41 +83,87 @@ def token(subject: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
async def active_config_items() -> list[dict[str, Any]]:
|
||||
"""读取当前生效版本的全部配置项,用于在新版本里原样继承。"""
|
||||
settings = get_settings()
|
||||
# MYSQL_DSN 形如 mysql+asyncmy://user:pass@host:port/db
|
||||
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
|
||||
#: `PromptPayload` 接受的字段(`app/api/schemas/admin.py`)。表里还有
|
||||
#: `checksum` / `created_by` / `created_at`,那三个由服务端生成,不能搬。
|
||||
PROMPT_API_FIELDS = (
|
||||
"prompt_code", "task_type", "agent_type",
|
||||
"system_prompt", "user_prompt_template", "input_schema", "output_schema",
|
||||
)
|
||||
|
||||
|
||||
def as_json(value: Any) -> Any:
|
||||
"""JSON 列从驱动读出来可能是字符串、也可能已解析,统一归一化。
|
||||
|
||||
不归一化的后果很隐蔽:`value_json` 保持字符串时搬过去会被判成"不是对象",
|
||||
而 `input_schema` / `output_schema` 是 `dict | None` 字段,传字符串直接 422。
|
||||
"""
|
||||
return json.loads(value) if isinstance(value, str) else value
|
||||
|
||||
|
||||
async def active_snapshot() -> dict[str, list[dict[str, Any]]]:
|
||||
"""当前生效版本在**全部三张受管表**里的内容。
|
||||
|
||||
为什么不能只查 `platform_config_item`(旧写法就是这么写的,代价见下):
|
||||
`config_release` 是**整版本替换**语义,新版本没带上的行**等于被删除**。
|
||||
本项目为此丢过两次配置,两次都是"功能看着正常、零告警":
|
||||
|
||||
* release 174 → 181:提示词被漏搬,`load_active_prompt` 读不到,Agent 静默回落
|
||||
到代码里的默认话术;
|
||||
* 本次(254 → 303):本脚本只搬配置项,把 `customer_service_chitchat` 提示词漏在
|
||||
了旧版本里 —— admin 端只在激活时打一句 stderr 警告,很容易被刷过去。
|
||||
|
||||
`ConfigReleaseService.effective_snapshot()` 一次读全三张表,是唯一正确的来源。
|
||||
"""
|
||||
async with SessionFactory() as session:
|
||||
return await ConfigReleaseService(session).effective_snapshot()
|
||||
|
||||
|
||||
def snapshot_items(snapshot: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
||||
"""把快照里的配置项转成 API 载荷形状。
|
||||
|
||||
库是 `config_key`、API 是 `item_key` —— 字段名不同,快照行不能直接 POST。
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"namespace": row["namespace"],
|
||||
"item_key": row["config_key"],
|
||||
"value_json": as_json(row["value_json"]),
|
||||
"schema_version": row["schema_version"],
|
||||
}
|
||||
for row in snapshot["platform_config_item"]
|
||||
]
|
||||
|
||||
|
||||
async def next_prompt_versions(rows: list[dict[str, Any]]) -> list[int]:
|
||||
"""给每条待搬运的提示词分配新 version。
|
||||
|
||||
`prompt_template_version` 的唯一键含 `version`,照搬旧行会主键冲突;
|
||||
逐个 `prompt_code` 取现有最大值 +1。
|
||||
"""
|
||||
assigned: list[int] = []
|
||||
async with SessionFactory() as session:
|
||||
for row in rows:
|
||||
latest = await session.scalar(
|
||||
select(func.max(PromptTemplateVersion.version)).where(
|
||||
PromptTemplateVersion.prompt_code == row["prompt_code"]
|
||||
)
|
||||
)
|
||||
assigned.append(int(latest or 0) + 1)
|
||||
return assigned
|
||||
|
||||
|
||||
def prompt_payloads(
|
||||
rows: list[dict[str, Any]], versions: list[int], release_id: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""提示词的 API 载荷(`input_schema` / `output_schema` **必须带上**,漏了即静默丢失)。"""
|
||||
payloads: list[dict[str, Any]] = []
|
||||
for row, version in zip(rows, versions, strict=True):
|
||||
payload: dict[str, Any] = {"release_id": release_id, "version": version}
|
||||
for field in PROMPT_API_FIELDS:
|
||||
value = row.get(field)
|
||||
payload[field] = as_json(value) if field.endswith("_schema") else value
|
||||
payloads.append(payload)
|
||||
return payloads
|
||||
|
||||
|
||||
async def post(
|
||||
@@ -198,10 +262,23 @@ async def main() -> int:
|
||||
if await ensure_suitability_intent(client, auth) != 0:
|
||||
return 1
|
||||
|
||||
inherited = await active_config_items()
|
||||
print(f"当前生效版本的配置项:{len(inherited)} 条(将原样继承)")
|
||||
snapshot = await active_snapshot()
|
||||
inherited = snapshot_items(snapshot)
|
||||
prompts = list(snapshot["prompt_template_version"])
|
||||
rules = list(snapshot["model_routing_rule"])
|
||||
print(
|
||||
f"当前生效版本:配置项 {len(inherited)} 条、提示词 {len(prompts)} 条、"
|
||||
f"路由规则 {len(rules)} 条(将原样继承)"
|
||||
)
|
||||
for item in inherited:
|
||||
print(f" · {item['namespace']} / {item['item_key']}")
|
||||
for prompt in prompts:
|
||||
print(f" · 提示词 {prompt['prompt_code']}(v{prompt['version']})")
|
||||
if rules:
|
||||
# 本环境当前为空;不为空就必须先支持搬运,否则激活即静默清空。
|
||||
print(f"\n[失败] 生效版本里有 {len(rules)} 条 model_routing_rule,本脚本尚未支持搬运。")
|
||||
print(" 直接发布会把它们清空 —— 先补上搬运逻辑再跑。")
|
||||
return 1
|
||||
|
||||
new_items = [
|
||||
{
|
||||
@@ -239,7 +316,10 @@ async def main() -> int:
|
||||
created = await post(client, "/api/v1/admin/config-releases", auth=auth, payload={
|
||||
"release_no": f"cs-tools-{uuid.uuid4().hex[:12]}",
|
||||
"title": "客服 Agent 意图工具白名单",
|
||||
"change_summary": "新增 faq/product_inquiry/policy_explain 的知识检索白名单,并继承既有配置项",
|
||||
"change_summary": (
|
||||
"知识类意图补发访客检索工具 query_knowledge(访客令牌只有 knowledge:query),"
|
||||
"保留 search_knowledge 供登录客户使用,并继承既有配置项"
|
||||
),
|
||||
})
|
||||
if created.status_code != 201:
|
||||
print(f"创建发布版本失败:{created.status_code} {created.text[:200]}")
|
||||
@@ -256,6 +336,18 @@ async def main() -> int:
|
||||
print(f" 失败:{response.text[:200]}")
|
||||
return 1
|
||||
|
||||
for payload in prompt_payloads(prompts, await next_prompt_versions(prompts), release_id):
|
||||
response = await post(
|
||||
client, "/api/v1/admin/prompt-templates", auth=auth, payload=payload
|
||||
)
|
||||
print(
|
||||
f" [继承] 提示词 {payload['prompt_code']} → v{payload['version']}"
|
||||
f" → {response.status_code}"
|
||||
)
|
||||
if response.status_code not in (200, 201):
|
||||
print(f" 失败:{response.text[:200]}")
|
||||
return 1
|
||||
|
||||
release_base = f"/api/v1/admin/config-releases/{release_id}"
|
||||
submitted = await post(
|
||||
client, f"{release_base}/validations", auth=auth, payload={},
|
||||
@@ -278,10 +370,22 @@ async def main() -> int:
|
||||
return 1
|
||||
print(f"最终状态:{activated.json()['data']['status']}")
|
||||
|
||||
remaining = await active_config_items()
|
||||
print(f"\n激活后生效版本配置项:{len(remaining)} 条")
|
||||
after = await active_snapshot()
|
||||
remaining = snapshot_items(after)
|
||||
print(
|
||||
f"\n激活后生效版本:配置项 {len(remaining)} 条、"
|
||||
f"提示词 {len(after['prompt_template_version'])} 条"
|
||||
)
|
||||
for item in remaining:
|
||||
print(f" · {item['namespace']} / {item['item_key']} = {item['value_json']}")
|
||||
# 条数对不上就是静默丢失 —— 这正是本脚本上一次踩的坑,必须硬校验。
|
||||
if len(remaining) != len(inherited_only) + len(pending):
|
||||
print("[失败] 配置项条数与预期不符,可能发生静默丢失,请人工核查")
|
||||
return 1
|
||||
if len(after["prompt_template_version"]) != len(prompts):
|
||||
print("[失败] 提示词条数与继承前不一致,可能发生静默丢失,请人工核查")
|
||||
return 1
|
||||
print("[OK] 完成")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user