一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
(输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)
二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数
三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"
四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
(端点与账号均已不存在,此前稳定报 3 条假红)
五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期
六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
_chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
182 lines
8.6 KiB
Python
182 lines
8.6 KiB
Python
"""一键准备演示数据:按依赖顺序跑齐所有 seed 脚本。
|
||
|
||
## 为什么需要它
|
||
|
||
演示数据此前散在 10 个脚本里,**顺序有讲究**(账号 → 口令 → 账户 → 行情 → 配置 → 知识),
|
||
而且有一个环节(知识库素材)根本没有脚本、靠手工调接口。换台机器接手时没人知道该跑哪些、
|
||
按什么顺序跑。本脚本是唯一入口。
|
||
|
||
## 顺序与依赖
|
||
|
||
| # | 步骤 | 为什么在这个位置 |
|
||
|---|---|---|
|
||
| 1 | 账号与权限 | 后面所有步骤都要用到这些账号 |
|
||
| 2 | 演示口令 | 依赖 1 建出的账号 |
|
||
| 3 | 客户账户与持仓 | 客户页面与下单的前提 |
|
||
| 4 | 场内行情 | **下单的硬前置**;必须在演示前跑,行情会过期 |
|
||
| 5 | 历史净值 | 产品详情页净值走势图的数据源(`fin_nav_history`) |
|
||
| 6 | 风控预警样本 | 风控页面要有东西可看 |
|
||
| 7 | 投顾演示数据 | 投顾工作台要有方案可看 |
|
||
| 8-10 | 各类发布配置 | Agent 工具白名单与提示词,缺了客服/风控会"失败关闭" |
|
||
| 11 | 知识库素材 | 客服答得出问题的前提 |
|
||
| 12 | 风控扫描演示数据 | 造**上游**业务数据,让扫描器自己生成预警(客户 12001-12005) |
|
||
|
||
⚠️ **第 6 步与第 12 步是两套不同的风控演示数据,不是重复**:
|
||
- 第 6 步 `seed_risk_alert_demo_data.py` **直接摆好**三条不同状态的预警,保证风控页面
|
||
一打开就有东西可看;
|
||
- 第 12 步 `seed_risk_demo_data.py` **只造上游**(客户/产品/账户/持仓/交易/流水/登录/工单),
|
||
**刻意不写 `fin_risk_alert`**,让风控扫描器按规则生成 —— 演示时覆盖的是
|
||
**规则 → 证据 → 通知**完整链路,而不是"预先摆好的假预警"。
|
||
|
||
**第 12 步需要带参数跑**(它默认是只打印计划、不连库的干跑模式),所以它额外传
|
||
`--apply --dry-run-scan`:写入上游数据,然后跑一次规则干跑**并把结果回滚**,
|
||
顺带验证 5 个场景都真的命中预期规则。
|
||
|
||
## ⚠️ 三个必须知道的点
|
||
|
||
1. **最后一步与行情都需要 Agent Worker 才会真正生效**:知识入库只写 MySQL + 投 outbox 事件,
|
||
向量由 Worker 消费事件后写 Milvus;没有 Worker 时现象是"客服照旧答不上",且**没有报错**。
|
||
所以跑完本脚本**必须**再起 Worker(`start.ps1` 会一起起)。
|
||
2. **第 2 步不是幂等的**:`set_user_password.py` 重跑等于**重设密码**(bcrypt 每次加盐不同)。
|
||
这是有意的(改密就该覆盖),但要知道它不是"已存在就跳过"。
|
||
3. **第 12 步不依赖 Worker**:它调的是 `RiskRuleEngine.refresh_alerts()`(进程内),
|
||
不是通过事件队列;干跑部分结束会 `rollback()`,不会留下正式预警。
|
||
|
||
## 用法
|
||
|
||
python tools/seed_demo_data.py # 全跑
|
||
python tools/seed_demo_data.py --from 4 # 从第 4 步开始(重跑行情等)
|
||
python tools/seed_demo_data.py --only 4,10 # 只跑指定步骤
|
||
python tools/seed_demo_data.py --only 12 # 只准备风控扫描演示数据
|
||
python tools/seed_demo_data.py --list # 只列步骤
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
|
||
|
||
#: (标题, 脚本相对路径, 备注, 额外命令行参数)
|
||
#:
|
||
#: 第四个字段是为「默认行为不是写入」的脚本准备的:`seed_risk_demo_data.py` 不带参数时
|
||
#: 只打印计划、**不连数据库**,必须显式传 `--apply` 才会真正写入。不给它传参的话,
|
||
#: 加进本脚本只会打印一段"确认无误后使用 --apply 正式写入"然后什么也不做 ——
|
||
#: 看起来像成功,实际什么都没准备,这类"假成功"最难查。
|
||
STEPS: tuple[tuple[str, str, str, tuple[str, ...]], ...] = (
|
||
("账号与权限", "tools/seed_test_rbac.py", "演示角色 + 权限号段", ()),
|
||
("演示口令", "tools/set_user_password.py", "⚠️ 非幂等:重跑等于重设密码", ()),
|
||
("客户账户与持仓", "tools/seed_sim_account_demo.py", "客户 9001 开 10 万虚拟资金 + 持仓", ()),
|
||
("场内行情", "tools/sync_market_prices.py", "⚠️ 下单硬前置;行情会过期,演示前必跑", ()),
|
||
("历史净值", "tools/sync_nav_history.py", "产品详情页走势图的数据源(fin_nav_history)", ()),
|
||
("风控预警样本", "tools/seed_risk_alert_demo_data.py", "三条不同状态的演示预警", ()),
|
||
("投顾演示数据", "tools/seed_advisor_demo.py", "投顾工作台要展示的方案与归属", ()),
|
||
("风控 Agent 白名单", "tools/publish_risk_agent_config.py", "缺了风控助手工具会失败关闭", ()),
|
||
("知识库素材", "tools/seed_knowledge_demo.py", "⚠️ 需要 Worker 才会写进 Milvus", ()),
|
||
(
|
||
"风控扫描演示数据",
|
||
"tools/seed_risk_demo_data.py",
|
||
"上游业务数据(客户 12001-12005),让扫描器自己生成预警",
|
||
# 不带参数时它只打印计划、不连库;--dry-run-scan 跑完规则干跑会回滚,
|
||
# 所以净效果是「写入上游数据 + 顺带验证 5 个场景都命中预期规则」。
|
||
("--apply", "--dry-run-scan"),
|
||
),
|
||
)
|
||
|
||
|
||
def run_step(
|
||
index: int,
|
||
title: str,
|
||
script: str,
|
||
note: str,
|
||
extra_args: tuple[str, ...],
|
||
*,
|
||
dry_run: bool,
|
||
) -> bool:
|
||
path = PROJECT_ROOT / script
|
||
print()
|
||
print("=" * 88)
|
||
print(f"[{index}/{len(STEPS)}] {title} —— {note}")
|
||
# 把额外参数一并打出来:有些脚本不带参数是"只打印计划不写库",
|
||
# 手跑时能一眼看出这一步到底会不会落库。
|
||
print(" " + " ".join([script, *extra_args]))
|
||
print("=" * 88)
|
||
if not path.exists():
|
||
print(f"[跳过] 脚本不存在:{path}")
|
||
return False
|
||
if dry_run:
|
||
print("[dry-run] 未执行")
|
||
return True
|
||
started = time.perf_counter()
|
||
# 刻意**不捕获输出**(继承 stdio):既不吞掉子脚本的报错,
|
||
# 也避免在受限环境里因 piped stdio 失败。
|
||
result = subprocess.run(
|
||
[sys.executable, str(path), *extra_args],
|
||
check=False,
|
||
cwd=str(PROJECT_ROOT),
|
||
)
|
||
elapsed = time.perf_counter() - started
|
||
ok = result.returncode == 0
|
||
print(f"[{'完成' if ok else '失败'}] {title} 用时 {elapsed:.1f}s 退出码 {result.returncode}")
|
||
return ok
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="一键准备演示数据")
|
||
parser.add_argument("--list", action="store_true", help="只列步骤")
|
||
parser.add_argument("--from", dest="start", type=int, default=1, help="从第几步开始")
|
||
parser.add_argument("--only", default="", help="只跑这些步骤(逗号分隔,如 4,10)")
|
||
parser.add_argument("--dry-run", action="store_true", help="只打印将执行什么")
|
||
args = parser.parse_args()
|
||
|
||
if args.list:
|
||
print(f"共 {len(STEPS)} 步:")
|
||
for index, (title, script, note, extra) in enumerate(STEPS, 1):
|
||
suffix = f" [{' '.join(extra)}]" if extra else ""
|
||
print(f" {index:>2}. {title:<20} {script:<48} {note}{suffix}")
|
||
return 0
|
||
|
||
only = {int(part) for part in args.only.split(",") if part.strip()} if args.only else None
|
||
|
||
def wanted(index: int) -> bool:
|
||
return index in only if only is not None else index >= args.start
|
||
|
||
selected = [(index, *step) for index, step in enumerate(STEPS, 1) if wanted(index)]
|
||
if not selected:
|
||
print("没有匹配的步骤。")
|
||
return 1
|
||
|
||
print(f"准备演示数据:{len(selected)} 步" + ("(dry-run)" if args.dry_run else ""))
|
||
failed: list[str] = []
|
||
for index, title, script, note, extra in selected:
|
||
if not run_step(index, title, script, note, extra, dry_run=args.dry_run):
|
||
failed.append(title)
|
||
|
||
print()
|
||
print("=" * 88)
|
||
if failed:
|
||
print(f"有 {len(failed)} 步失败:{'、'.join(failed)}")
|
||
print("先看上面的原始报错;多数失败是外部依赖没起来(MySQL / Redis / Docker)。")
|
||
return 1
|
||
print("全部完成。")
|
||
if not args.dry_run:
|
||
print(
|
||
"\n下一步:\n"
|
||
" 1. 起服务:powershell -ExecutionPolicy Bypass -File start.ps1\n"
|
||
" (它会把 API 与 **Agent Worker** 一起起 —— 没有 Worker,知识检索不到、\n"
|
||
" 客服对话会一直显示'超时')\n"
|
||
" 2. 验证:python tools/e2e_smoke_test.py"
|
||
)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|