diff --git a/.gitignore b/.gitignore
index 96f7848..a9d9050 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,6 +39,9 @@ Thumbs.db
*.log
logs/
+# 数据分析 20 题 live battery 本地报告(不入库,见 scripts/dev/run_query_battery.py)
+scripts/dev/battery_report.json
+
# Milvus / Neo4j local
.milvus/
diff --git a/AGENTS.md b/AGENTS.md
index 0426fa7..2886e17 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,6 +38,6 @@ app/repository/core_ro.py # Core 只读 + check_suitability(R-02)
scripts/core/reset.ps1 # 本地灌 Core 模拟库
```
-**当前分支:** `merger` · **测试基线:** `python -m pytest` → 786 passed · **Redis:** Docker `6380` · **前端:** `cd web && npm run build/test`
+**当前分支:** `merger` · **测试基线:** `python -m pytest` → 795 passed · **Redis:** Docker `6380` · **前端:** `cd web && npm run build/test`
技术选型硬阀门见 MEMORY 第 3、7 节。Cursor 以 `.cursor/rules/project-memory.mdc` 为准。
diff --git a/app/service/dict_service.py b/app/service/dict_service.py
index 3c55625..c28b9fd 100644
--- a/app/service/dict_service.py
+++ b/app/service/dict_service.py
@@ -78,6 +78,12 @@ def default_registry() -> MetricRegistry:
"申购交易金额合计", "元", "近30天"))
reg.add(Metric("redeem_amount", "赎回金额", ["赎回金额", "赎回额"],
"赎回交易金额合计", "元", "近30天"))
+ # 「交易流水/流水」为多义指标(N-01):同时命中「金额」与「笔数」两个口径,
+ # resolve() 返回 Ambiguity,Agent 反问「按金额还是按笔数」后绑定本轮口径。
+ reg.add(Metric("trade_flow_amount", "交易流水金额", ["交易流水", "流水", "流水金额"],
+ "交易流水按成交金额排序", "元", "近30天"))
+ reg.add(Metric("trade_flow_count", "交易流水笔数", ["交易流水", "流水", "流水笔数"],
+ "交易流水笔数合计", "笔", "近30天"))
reg.add(Metric("customer_count", "客户数", ["客户数量", "客户数"],
"客户数量", "个", "当前"))
reg.add(Metric("risk_count", "高风险客户数", ["高风险客户数", "高风险数量"],
diff --git a/app/service/guardrail.py b/app/service/guardrail.py
index 1edd2de..d408fed 100644
--- a/app/service/guardrail.py
+++ b/app/service/guardrail.py
@@ -7,10 +7,30 @@ from __future__ import annotations
import re
from dataclasses import dataclass, field
+from decimal import Decimal
-NUMBER_RE = re.compile(r"-?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?")
+# 数字必须紧跟前一个字符不是「字母/数字/下划线」(\w 含中文),
+# 从而避免把「沪深300指数」「中证500」这类名称里的数字误当金额。
+NUMBER_RE = re.compile(r"(? list[float]:
- """提取文本中的数字(含小数、负数、千分位逗号)。"""
- return [float(x.replace(",", "")) for x in NUMBER_RE.findall(text or "")]
+ """提取文本中的数字(含小数、负数、千分位逗号)。
+
+ 先屏蔽 CUST-/TRD-/PROD-/STAFF- 编号与日期/时间 token,再用带左边界约束的正则匹配,
+ 避免「CUST-3001」「沪深300指数」「9月3日」这类标识被误提取为金额。
+ """
+ text = _ID_TOKEN.sub(" ", text or "")
+ text = _DATE_TIME_TOKEN.sub(" ", text)
+ text = _WINDOW_TOKEN.sub(" ", text)
+ return [float(x.replace(",", "")) for x in NUMBER_RE.findall(text)]
def extract_percent_numbers(text: str) -> list[float]:
@@ -32,6 +59,8 @@ def extract_percent_numbers(text: str) -> list[float]:
def _to_float(v) -> float | None:
if isinstance(v, bool):
return None
+ if isinstance(v, Decimal):
+ return float(v)
if isinstance(v, (int, float)):
return float(v)
if isinstance(v, str):
@@ -72,7 +101,8 @@ def _approx_in(n: float, allowed: set[float], tol: float = 0.01) -> bool:
def check_numbers(answer: str, table) -> list[float]:
"""返回解读中出现、但结果表推导不出的数字(视为疑似说错)。
- 支持常见单位换算:解读带「万/亿」时,同时尝试按 1e4 / 1e8 放大比对。
+ 支持单位换算:解读带「万/亿」时按 1e4 / 1e8 放大比对;
+ 结果列含比率(0~1)时,允许解读按百分比(n = 比率×100)表述,追加 0.01 刻度比对。
"""
allowed = result_numbers(table)
scales = [1.0]
@@ -80,6 +110,8 @@ def check_numbers(answer: str, table) -> list[float]:
scales.append(10000.0)
if "亿" in answer:
scales.append(100000000.0)
+ if any(0.0 < a < 1.0 for a in allowed):
+ scales.append(0.01)
issues: list[float] = []
for n in extract_numbers(answer):
if not any(_approx_in(n * s, allowed) for s in scales):
diff --git a/docs/course/jinrong-module-risk/_base.html b/docs/course/jinrong-module-risk/_base.html
index 054730d..048890a 100644
--- a/docs/course/jinrong-module-risk/_base.html
+++ b/docs/course/jinrong-module-risk/_base.html
@@ -33,6 +33,9 @@
+
+
+
diff --git a/docs/course/jinrong-module-risk/index.html b/docs/course/jinrong-module-risk/index.html
index 302faa0..8cf9f7e 100644
--- a/docs/course/jinrong-module-risk/index.html
+++ b/docs/course/jinrong-module-risk/index.html
@@ -33,6 +33,9 @@
+
+
+
@@ -46,6 +49,7 @@
风控专员 Demo STAFF-30001 带 risk_officer + risk_demo 角色:
能看预警台账、处置、跑 AML 扫描,还能在模拟交易页触发规则引擎。
本地登录页一键切换,改 JWT 角色后需重新登录。
+ 仓库基线 merger · python -m pytest → 795 passed。
@@ -56,8 +60,8 @@
GET /api/risk/alerts 分页查询;POST .../handle 人工处置(仅 risk_officer)。响应固定带 disclaimer。
-
适当性校验 FR-2
-
POST /api/risk/suitability/check:客户仅本人、理财师名下、风控全量。每次直调落 audit。
+
适当性校验 R-02 / FR-2
+
POST /api/risk/suitability/check 与网关共用 Core 只读层 的 check_suitability(旧 SUIT-001~008 已退役)。客户本人 / 理财师名下 / 风控全量。
AML 扫描 FR-5
@@ -199,6 +203,24 @@
+
+
R-02 阻断 vs R-01 大额待审(同网关、不同结果)
+
模拟写 Core 网关(trade_gateway.py)里先跑适当性:不过 → 交易写不进去(R-02);过了 → 写入 core_trade → 规则引擎再扫 R-01 大额等 → 出 pending_review 预警。
+
+
+
A-1 · R-02 阻断
+
CUST-1001 + PROD-161725 · 风评与产品 R 档不匹配 → 网关拒绝 · 可有 suitability 类预警 · 无成交
+
+
+
A-3 · R-01 大额待审
+
CUST-3001 · 50 万申购 → 成交写入 Core → 引擎出大额类 pending_review · 台账可见
+
+
+
+ 指挥 AI 时: 别把「阻断交易」和「出预警待审」混成一个状态——前者在网关,后者在引擎聚合。
+
+
+
代码地图
@@ -260,11 +282,11 @@
模块 3 · REST 与对话 Tool
-
台账 REST 要带 risk 头
对话靠五只 Tool
+
台账 REST 要带 risk 头
对话靠六只 Tool
风控REST
- 走 get_auth_context,前端必须带 X-Agent-Type: risk。
- 风控对话则通过风控对话 Tool 注册表(chat_tools.py)注册五只只读 Tool + query_agent_behavior,由对话 Tool 编排(tool_service.py)统一分发。
+ 走模块鉴权工厂(deps.py 的 get_auth_context),前端必须带 X-Agent-Type: risk。
+ 风控对话走顾问通用编排(agent_service.py)的 risk 分支 + SSE;Tool 由风控对话 Tool 注册表(chat_tools.py)注册 六只只读 Tool,经对话 Tool 编排(tool_service.py)分发。
@@ -387,7 +409,7 @@
- 仓库现状(2026-09):STAFF-30001 含 risk_demo;python -m pytest → 774 passed。
+ 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 795 passed。
@@ -439,7 +461,7 @@
- reset 后台账空: reset.ps1 只重建 jinrong_core;risk_alert 要跑 prepare_risk_demo.sql + AML 种子才有演示数据。
+ reset 后台账空: reset.ps1 只重建 jinrong_core;risk_alert 要跑 prepare_risk_demo.sql + AML 种子才有演示数据。完整步骤见 docs/项目框架设计/演示SOP-风控模块.md §2。
@@ -471,6 +493,390 @@
+
+
+
+
模块 5 · FR-8/9/10
+
集中度、超期升级
与代理人行为链
+
+ PRD v1.1 追加的三条能力(FR-8~10)已在 merger 落地。
+ 指挥 AI 改代码时要分清:规则引擎 / cron 脚本负责「出单、升级」;对话 Tool 只读查库,不能替运维跑定时任务。
+
+
+
+
三条规则 · 各管什么
+
+
+
FR-8 · RISK-006 集中度
+
客户持仓 R4+R5 占比 ≥ 阈值 → 并入事件类预警聚合(同客户同日一张事件单)。演示 A-10。
+
+
+
FR-9 · RISK-007 超期升级
+
pending_review 超阈值未处置 → 超期升级 cron(escalation_scan.py)写 payload 分级 · status 不变。
+
+
+
FR-10 · RISK-008 行为链
+
代理人 A/B/C 条件命中 → 按 actor_id 维度出单;明细仅 officer/manager 可见。演示 A-12。
+
+
+
+
+
+
数据流:引擎出单 vs Chat 只读查
+
+
+
⚙引擎 / cron
+
💬风控 Chat Tool
+
🗄risk_alert 表
+
+
点击「下一步」
+
+
+
+
+
+
+
+
+
+
+
对话 Tool · 超期查询(chat_tools.py)
+
+
+
+
+
+
白话
+
+
想让超期单「升级」→ 运维跑 scripts/cron/escalation_scan.py,不是改 prompt。
+
想让行为链出单 → 先积累 audit 痕迹,再跑 agent_behavior_scan.py。
+
Chat 里问「有哪些超期单」→ query_overdue_alerts 查现有数据即可。
+
+
+
+
+
+
+
FR-9 超期升级后,预警单的 status 会怎样?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
模块 6 · L3 与角色
+
监测档怎么写
谁能看谁能处置
+
+ L3 监测画像写入服务(profile_l3.py)把 AML 命中、规则结果写入 customer_profile_l3(防降级合并)。
+ 模块鉴权工厂(deps.py)按 JWT 角色决定台账 REST 与对话 Tool 能看到什么——接前端或新 Tool 前先对这张矩阵。
+
+
+
+
L3 监测档 · 谁写、谁读
+
+
+
①
+
写入 · AML 扫描服务(aml_service.py)命中 → 写 L3 monitor_tier=high + 预警出单(R-03 通知不冻户)
+
+
+
②
+
合并 · 只升不降(normal < watch < high);禁止 Agent 把 high 改回 normal
+
+
+
③
+
读取 · customer_context Tool:Core L0 + L3 + 待审预警摘要(只读,供风控 Chat 解读)
+
+
+
+ 铁律: L3 是 enrich 监测标签,不覆盖 Core 正式 C1~C5 风评;审计表只 INSERT。
+
+
+
+
+
角色矩阵(REST + 对话数据面)
+
+
+
risk_officer
+
台账全量 · POST .../handle 处置 · AML scan · 模拟 risk_demo · 行为链明细可见
+
+
+
risk_manager
+
台账全量 · 一般不 handle(按矩阵)· 行为链明细可见
+
+
+
compliance
+
台账强制 aml 域 · 无 simulate · 对话 Tool 仍只读
+
+
+
+
+
+
群聊:AML 命中后 L3 与台账
+
+
+
+
扫
+
+
aml_service.scan
+
CUST-1002 展示名命中名单 → 独立 aml 预警单
+
+
+
+
L3
+
+
profile_l3.upsert
+
monitor_tier → high(若原档更低则抬升,不降级)
+
+
+
+
Tool
+
+
customer_context
+
风控 Chat 问客户档案 → 返回 L0 + L3 high + 待审预警数
+
+
+
+
员
+
+
risk_officer
+
台账 handle 处置 · 不自动冻户 · 不改 Core 正式风评
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
L3 防降级 · profile_l3.py 口径
+
+
+
+
+
+
白话
+
+
指挥 AI 加「自动降档」= 违规;L3 只能抬监测强度或保持。
+
客户 Agent 不可读 L3 全文念给客户;只有风控/顾问 Tool 在 RBAC 内读。
+
处置预警用 REST handle,不是让 LLM 在对话里改库。
+
+
+
+
+
+
+
AML 扫描命中 CUST-1002 后,系统会做什么?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
模块 7 · 写侧并发
+
同一天两笔交易
为什么还是一条预警?
+
+ 进程内聚合锁原语(locks.py)把「同一客户、同一聚合 key」串行化;L3 监测画像写入服务(profile_l3.py)用
+ computed_at 乐观锁防丢更新。指挥 AI 改写路径时,先分清悲观锁(串行)和乐观锁(版本戳比对)各管哪一段。
+
+
+
+
两层锁 · 各管什么
+
+
+ | 职责 | 代码入口 | 机制 |
+
+
+
+ | 预警同日聚合串行 |
+ 预警处置与聚合服务(alert_service.py)+ 锁原语(locks.py · run_locked) |
+ 单进程 threading.Lock;拿锁超时降级执行,冲突由锁内重查兜底 |
+
+
+ | L3 写侧防覆盖 |
+ L3 写入(profile_l3.py)+ 仓储更新(risk_repository.update_l3) |
+ 乐观锁:WHERE computed_at = :expected,失败重读重试(最多 3 次) |
+
+
+ | 处置 + 审计同事务 |
+ 人工处置(handle_alert)+ 风控仓储(risk_repository) |
+ 状态变更与 audit 同连接提交(B7 挂账⑦ 已收口) |
+
+
+
+
+ 不做: 风控线不接 L1/L2 画像 Redis 热缓存(归客服/顾问);L3 读侧已有 profile:l3:{customer_id} cache-aside + 写后 DEL。
+
+
+
+
+
+
+
锁原语 · 预警聚合 key 示例
+
+run_locked(
+ f"agg:event:{customer_id}:{today}",
+ lambda locked: _merge_or_insert(...),
+)
+
+
+run_locked(f"l3:{customer_id}", _write_with_optimistic_retry)
+
+
+
白话
+
想象收银台叫号器:同一客户、同一天的「首单聚合」必须排队进窗口,窗口里再查「今天是否已有单」——两笔并发进来也不会开出两张重复首单。
+
L3 则是贴便签前先对表上的时间戳:若别人刚改过,你的 UPDATE 打不中,就重读再合并(只升不降监测档)。
+
+
+
+
+
+
群聊:并发两笔 · 聚合与 L3
+
+
+
+
T1
+
+
trade #1
+
引擎出单 → 请求 run_locked(agg:event:…)
+
+
+
+
T2
+
+
trade #2
+
同时到达 → 在锁外等待或超时降级;锁内重查并入同一 risk_alert
+
+
+
+
L3
+
+
profile_l3
+
upsert 前读 computed_at → UPDATE 带 expected → 失败则重试
+
+
+
+
审
+
+
audit
+
处置 handle:改 status + 写 audit_log 同一事务(避免「状态变了没审计」)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
数据流:写侧三条线
+
+
+
🔒聚合锁
+
📋risk_alert
+
📊L3 + audit
+
+
点击「下一步」
+
+
+
+
+
+
+
+
+
+
+
指挥 AI「给风控加 L1 Redis 热读」合理吗?
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/course/jinrong-module-risk/modules/01-capabilities.html b/docs/course/jinrong-module-risk/modules/01-capabilities.html
index 903e49e..f4ed541 100644
--- a/docs/course/jinrong-module-risk/modules/01-capabilities.html
+++ b/docs/course/jinrong-module-risk/modules/01-capabilities.html
@@ -6,6 +6,7 @@
风控专员 Demo STAFF-30001 带 risk_officer + risk_demo 角色:
能看预警台账、处置、跑 AML 扫描,还能在模拟交易页触发规则引擎。
本地登录页一键切换,改 JWT 角色后需重新登录。
+ 仓库基线 merger · python -m pytest → 795 passed。
@@ -16,8 +17,8 @@
GET /api/risk/alerts 分页查询;POST .../handle 人工处置(仅 risk_officer)。响应固定带 disclaimer。
-
适当性校验 FR-2
-
POST /api/risk/suitability/check:客户仅本人、理财师名下、风控全量。每次直调落 audit。
+
适当性校验 R-02 / FR-2
+
POST /api/risk/suitability/check 与网关共用 Core 只读层 的 check_suitability(旧 SUIT-001~008 已退役)。客户本人 / 理财师名下 / 风控全量。
AML 扫描 FR-5
diff --git a/docs/course/jinrong-module-risk/modules/02-engine.html b/docs/course/jinrong-module-risk/modules/02-engine.html
index 6bca6d8..4b79657 100644
--- a/docs/course/jinrong-module-risk/modules/02-engine.html
+++ b/docs/course/jinrong-module-risk/modules/02-engine.html
@@ -31,6 +31,24 @@
+
+
R-02 阻断 vs R-01 大额待审(同网关、不同结果)
+
模拟写 Core 网关(trade_gateway.py)里先跑适当性:不过 → 交易写不进去(R-02);过了 → 写入 core_trade → 规则引擎再扫 R-01 大额等 → 出 pending_review 预警。
+
+
+
A-1 · R-02 阻断
+
CUST-1001 + PROD-161725 · 风评与产品 R 档不匹配 → 网关拒绝 · 可有 suitability 类预警 · 无成交
+
+
+
A-3 · R-01 大额待审
+
CUST-3001 · 50 万申购 → 成交写入 Core → 引擎出大额类 pending_review · 台账可见
+
+
+
+ 指挥 AI 时: 别把「阻断交易」和「出预警待审」混成一个状态——前者在网关,后者在引擎聚合。
+
+
+
代码地图
diff --git a/docs/course/jinrong-module-risk/modules/03-tools-rest.html b/docs/course/jinrong-module-risk/modules/03-tools-rest.html
index 8bfa841..117211a 100644
--- a/docs/course/jinrong-module-risk/modules/03-tools-rest.html
+++ b/docs/course/jinrong-module-risk/modules/03-tools-rest.html
@@ -1,11 +1,11 @@
模块 3 · REST 与对话 Tool
-
台账 REST 要带 risk 头
对话靠五只 Tool
+
台账 REST 要带 risk 头
对话靠六只 Tool
风控REST
- 走 get_auth_context,前端必须带 X-Agent-Type: risk。
- 风控对话则通过风控对话 Tool 注册表(chat_tools.py)注册五只只读 Tool + query_agent_behavior,由对话 Tool 编排(tool_service.py)统一分发。
+ 走模块鉴权工厂(deps.py 的 get_auth_context),前端必须带 X-Agent-Type: risk。
+ 风控对话走顾问通用编排(agent_service.py)的 risk 分支 + SSE;Tool 由风控对话 Tool 注册表(chat_tools.py)注册 六只只读 Tool,经对话 Tool 编排(tool_service.py)分发。
@@ -128,7 +128,7 @@
- 仓库现状(2026-09):STAFF-30001 含 risk_demo;python -m pytest → 774 passed。
+ 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 795 passed。
diff --git a/docs/course/jinrong-module-risk/modules/04-gaps.html b/docs/course/jinrong-module-risk/modules/04-gaps.html
index 5486b19..a287ef8 100644
--- a/docs/course/jinrong-module-risk/modules/04-gaps.html
+++ b/docs/course/jinrong-module-risk/modules/04-gaps.html
@@ -45,7 +45,7 @@
- reset 后台账空: reset.ps1 只重建 jinrong_core;risk_alert 要跑 prepare_risk_demo.sql + AML 种子才有演示数据。
+ reset 后台账空: reset.ps1 只重建 jinrong_core;risk_alert 要跑 prepare_risk_demo.sql + AML 种子才有演示数据。完整步骤见 docs/项目框架设计/演示SOP-风控模块.md §2。
diff --git a/docs/course/jinrong-module-risk/modules/05-fr-extensions.html b/docs/course/jinrong-module-risk/modules/05-fr-extensions.html
new file mode 100644
index 0000000..827842c
--- /dev/null
+++ b/docs/course/jinrong-module-risk/modules/05-fr-extensions.html
@@ -0,0 +1,93 @@
+
+
+
模块 5 · FR-8/9/10
+
集中度、超期升级
与代理人行为链
+
+ PRD v1.1 追加的三条能力(FR-8~10)已在 merger 落地。
+ 指挥 AI 改代码时要分清:规则引擎 / cron 脚本负责「出单、升级」;对话 Tool 只读查库,不能替运维跑定时任务。
+
+
+
+
三条规则 · 各管什么
+
+
+
FR-8 · RISK-006 集中度
+
客户持仓 R4+R5 占比 ≥ 阈值 → 并入事件类预警聚合(同客户同日一张事件单)。演示 A-10。
+
+
+
FR-9 · RISK-007 超期升级
+
pending_review 超阈值未处置 → 超期升级 cron(escalation_scan.py)写 payload 分级 · status 不变。
+
+
+
FR-10 · RISK-008 行为链
+
代理人 A/B/C 条件命中 → 按 actor_id 维度出单;明细仅 officer/manager 可见。演示 A-12。
+
+
+
+
+
+
数据流:引擎出单 vs Chat 只读查
+
+
+
⚙引擎 / cron
+
💬风控 Chat Tool
+
🗄risk_alert 表
+
+
点击「下一步」
+
+
+
+
+
+
+
+
+
+
+
对话 Tool · 超期查询(chat_tools.py)
+
+
+
+
+
+
白话
+
+
想让超期单「升级」→ 运维跑 scripts/cron/escalation_scan.py,不是改 prompt。
+
想让行为链出单 → 先积累 audit 痕迹,再跑 agent_behavior_scan.py。
+
Chat 里问「有哪些超期单」→ query_overdue_alerts 查现有数据即可。
+
+
+
+
+
+
+
FR-9 超期升级后,预警单的 status 会怎样?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/course/jinrong-module-risk/modules/06-l3-rbac.html b/docs/course/jinrong-module-risk/modules/06-l3-rbac.html
new file mode 100644
index 0000000..d8d39ff
--- /dev/null
+++ b/docs/course/jinrong-module-risk/modules/06-l3-rbac.html
@@ -0,0 +1,139 @@
+
+
+
模块 6 · L3 与角色
+
监测档怎么写
谁能看谁能处置
+
+ L3 监测画像写入服务(profile_l3.py)把 AML 命中、规则结果写入 customer_profile_l3(防降级合并)。
+ 模块鉴权工厂(deps.py)按 JWT 角色决定台账 REST 与对话 Tool 能看到什么——接前端或新 Tool 前先对这张矩阵。
+
+
+
+
L3 监测档 · 谁写、谁读
+
+
+
①
+
写入 · AML 扫描服务(aml_service.py)命中 → 写 L3 monitor_tier=high + 预警出单(R-03 通知不冻户)
+
+
+
②
+
合并 · 只升不降(normal < watch < high);禁止 Agent 把 high 改回 normal
+
+
+
③
+
读取 · customer_context Tool:Core L0 + L3 + 待审预警摘要(只读,供风控 Chat 解读)
+
+
+
+ 铁律: L3 是 enrich 监测标签,不覆盖 Core 正式 C1~C5 风评;审计表只 INSERT。
+
+
+
+
+
角色矩阵(REST + 对话数据面)
+
+
+
risk_officer
+
台账全量 · POST .../handle 处置 · AML scan · 模拟 risk_demo · 行为链明细可见
+
+
+
risk_manager
+
台账全量 · 一般不 handle(按矩阵)· 行为链明细可见
+
+
+
compliance
+
台账强制 aml 域 · 无 simulate · 对话 Tool 仍只读
+
+
+
+
+
+
群聊:AML 命中后 L3 与台账
+
+
+
+
扫
+
+
aml_service.scan
+
CUST-1002 展示名命中名单 → 独立 aml 预警单
+
+
+
+
L3
+
+
profile_l3.upsert
+
monitor_tier → high(若原档更低则抬升,不降级)
+
+
+
+
Tool
+
+
customer_context
+
风控 Chat 问客户档案 → 返回 L0 + L3 high + 待审预警数
+
+
+
+
员
+
+
risk_officer
+
台账 handle 处置 · 不自动冻户 · 不改 Core 正式风评
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
L3 防降级 · profile_l3.py 口径
+
+
+
+
+
+
白话
+
+
指挥 AI 加「自动降档」= 违规;L3 只能抬监测强度或保持。
+
客户 Agent 不可读 L3 全文念给客户;只有风控/顾问 Tool 在 RBAC 内读。
+
处置预警用 REST handle,不是让 LLM 在对话里改库。
+
+
+
+
+
+
+
AML 扫描命中 CUST-1002 后,系统会做什么?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/course/jinrong-module-risk/modules/07-concurrency.html b/docs/course/jinrong-module-risk/modules/07-concurrency.html
new file mode 100644
index 0000000..f44176c
--- /dev/null
+++ b/docs/course/jinrong-module-risk/modules/07-concurrency.html
@@ -0,0 +1,152 @@
+
+
+
模块 7 · 写侧并发
+
同一天两笔交易
为什么还是一条预警?
+
+ 进程内聚合锁原语(locks.py)把「同一客户、同一聚合 key」串行化;L3 监测画像写入服务(profile_l3.py)用
+ computed_at 乐观锁防丢更新。指挥 AI 改写路径时,先分清悲观锁(串行)和乐观锁(版本戳比对)各管哪一段。
+
+
+
+
两层锁 · 各管什么
+
+
+ | 职责 | 代码入口 | 机制 |
+
+
+
+ | 预警同日聚合串行 |
+ 预警处置与聚合服务(alert_service.py)+ 锁原语(locks.py · run_locked) |
+ 单进程 threading.Lock;拿锁超时降级执行,冲突由锁内重查兜底 |
+
+
+ | L3 写侧防覆盖 |
+ L3 写入(profile_l3.py)+ 仓储更新(risk_repository.update_l3) |
+ 乐观锁:WHERE computed_at = :expected,失败重读重试(最多 3 次) |
+
+
+ | 处置 + 审计同事务 |
+ 人工处置(handle_alert)+ 风控仓储(risk_repository) |
+ 状态变更与 audit 同连接提交(B7 挂账⑦ 已收口) |
+
+
+
+
+ 不做: 风控线不接 L1/L2 画像 Redis 热缓存(归客服/顾问);L3 读侧已有 profile:l3:{customer_id} cache-aside + 写后 DEL。
+
+
+
+
+
+
+
锁原语 · 预警聚合 key 示例
+
+run_locked(
+ f"agg:event:{customer_id}:{today}",
+ lambda locked: _merge_or_insert(...),
+)
+
+
+run_locked(f"l3:{customer_id}", _write_with_optimistic_retry)
+
+
+
白话
+
想象收银台叫号器:同一客户、同一天的「首单聚合」必须排队进窗口,窗口里再查「今天是否已有单」——两笔并发进来也不会开出两张重复首单。
+
L3 则是贴便签前先对表上的时间戳:若别人刚改过,你的 UPDATE 打不中,就重读再合并(只升不降监测档)。
+
+
+
+
+
+
群聊:并发两笔 · 聚合与 L3
+
+
+
+
T1
+
+
trade #1
+
引擎出单 → 请求 run_locked(agg:event:…)
+
+
+
+
T2
+
+
trade #2
+
同时到达 → 在锁外等待或超时降级;锁内重查并入同一 risk_alert
+
+
+
+
L3
+
+
profile_l3
+
upsert 前读 computed_at → UPDATE 带 expected → 失败则重试
+
+
+
+
审
+
+
audit
+
处置 handle:改 status + 写 audit_log 同一事务(避免「状态变了没审计」)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
数据流:写侧三条线
+
+
+
🔒聚合锁
+
📋risk_alert
+
📊L3 + audit
+
+
点击「下一步」
+
+
+
+
+
+
+
+
+
+
+
指挥 AI「给风控加 L1 Redis 热读」合理吗?
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/memory/FLOW.md b/docs/memory/FLOW.md
index fe3171d..1dec963 100644
--- a/docs/memory/FLOW.md
+++ b/docs/memory/FLOW.md
@@ -56,7 +56,7 @@ Client → Gateway(JWT/RBAC) → api/chat → agent_service(LangGraph) → Tools
当前:**风控全链路 + Wave 0 共用底座 + 代销平台 v0.1 + AL-09 + 客服 S2 Wave3 部分 + 前端 P0(2026-09-10,`merger`)**。**测试基线:`python -m pytest` → 786 passed 1 skipped** · **`web/` 19 Vitest 绿**。
-**下一步(统筹 P1):** 接口契约发群 · 风控演示运维脚本 · 画像 Redis L1/L2 热读扩展 · customer 阈值 push(未做)。
+**下一步(统筹 P1):** 接口契约发群 · 风控前端 polish(台账筛选)· **客服** L1/L2 Redis 热读(风控不做 L1/L2)· customer 阈值 push(未做)。
**本机已就位状态(2026-09-10):** `.env` 含 `REDIS_URL=redis://127.0.0.1:6380/0`;pytest **786 绿**;客户 Chat SSE 已接 `prepare_customer_stream`。
diff --git a/docs/memory/FRAMEWORK.md b/docs/memory/FRAMEWORK.md
index 68a1c2d..9f0f934 100644
--- a/docs/memory/FRAMEWORK.md
+++ b/docs/memory/FRAMEWORK.md
@@ -37,7 +37,7 @@
| Agent Gateway / Auth SDK | JWT、RBAC、归属校验 | Redis、MySQL customer_advisor_rel | **已实现(T-01 + AL-09)**:模块 `service/auth_service.py` + `api/deps.py`;宿主 `gateway/` 四件套并存;`/api/auth/login` 统一走 `issue_dev_token`;S2 接缝 `auth_adapter.module_auth_from_host` |
| 客户财富 Agent | L1 画像、事实查询、阈值提醒 | Core RO、Milvus 产品库 | **S2 + Wave3 部分(2026-09-10)**:Chat SSE · 1B/R1 · **C-04 阈值** · **C-05 nav** · **C-11 匹配** · 13 槽 L1 · **786 pytest**
| 代理人助手 Agent | L2 画像、RAG、草稿 | L1 只读、Milvus | 空壳 service(chat 骨架已通) |
-| 数据分析 Agent | NL→SQL→解读 | Core RO、画像只读 | **S3+P2(2026-09-09)**:问数 · dashboard metrics · 资产沉淀 API/UI · 口径种子 SQL |
+| 数据分析 Agent | NL→SQL→解读 | Core RO、画像只读 | **S3+P2 + 445cfbb 护栏/口径(2026-09-10)**:guardrail Decimal/编号/时间窗 · 流水 N-01 消歧 · **795 pytest** |
| 风控监测 Agent | 预警、L3、R-02 适当性 | 交易事件、AML 名单 | **已实现 B1~B9b + C1~C6(FR-1~10)**:事件线 + 对话线 + 集中度/时效升级/代理人行为链;**AL-09 已并入 `merger` 分支** |
| Core 只读层 | L0 事实查询 | `jinrong_core` | **已实现 + 已接对话 Tool(T-04)**:core_ro 经 app/tool/core_tools.py 三只读 Tool(L0/持仓/流水)进 chat;风控扩展查询照旧 |
| 共用底座 | 会话、审计、输入防护 | MySQL 11 表 + Redis | **已接入(2026-09-07)**:会话(T-06 session_repository + memory_service 窗口)、审计中间件(T-02 http_access + input_guard_log 双写)、agent_tool_call Tool 留痕(T-04)、输入防护(T-03 input_guard:注入词表纯函数检测 + oversize + Redis 固定窗口限流,chat 链路 限流→注入/超长→归属) |
diff --git a/docs/memory/ITERATION.md b/docs/memory/ITERATION.md
index f6fcadb..719aae3 100644
--- a/docs/memory/ITERATION.md
+++ b/docs/memory/ITERATION.md
@@ -21,5 +21,6 @@
| 2026-09-08 | P1 环境收口(续):补建 `risk_aml_list` + AML 种子 · `fix_utf8_seed.py` 修复 Windows 中文乱码 · 集成测试 11 例恢复 · **530 passed 0 skipped** | 真库集成 skip/失败 | MEMORY / ENVIRONMENT / FLOW / bootstrap 脚本 |
| 2026-09-09 | **四角色收益/洞察 Dashboard 需求 v2**(锚点修正·四角色 Demo 数·风控 Empty·Hook/联调) | 用户再完善需求 | wealth-dashboard spec v2 / frontend-p0 §2.5 / TODO |
| 2026-09-09 | **客服 Agent 代码合并**(`customer-service-agent` 只增不盖) | 用户要求合并上传内容 | `客服Agent-合并说明.md` · Wave1~5 新增文件 · CS-C-11 迁移 SQL · wave 测试暂 ignore |
-| 2026-09-10 | **客服 Wave3 批次**:1B 抽到 `sanitize_postprocess`(visitor 对齐)· C-04 `threshold_service` · C-05 `nav_query` · C-11/C-07/C-08 意图/槽位 · keyword 优先级修复 · **786 pytest** | 用户「全做」批次收尾 | MEMORY / REQUIREMENTS / FLOW / TODO / 课程模块 7 / TEST-LOG v1.1 |
+| 2026-09-10 | **客服 Wave3 批次**:1B 共用 sanitize · C-04/C-05/C-11 等 · **786 pytest** | 用户「全做」批次 | MEMORY / REQUIREMENTS / FLOW / TODO / 课程 |
+| 2026-09-10 | **数据分析 Agent 护栏/口径迭代**(cherry-pick `445cfbb` 功能层)· guardrail Decimal/编号/时间窗 · 流水 N-01 消歧 · battery 脚本改 `issue_dev_token` | 远程 `data-analysis-agent` 未合 merger | guardrail / dict_service / wave6 测试 / `数据分析Agent-代码迭代.md` |
| 2026-09-10 | **客服合规拍板落地**:1B sanitize 分治 · R1 L1 Top-K · MEMORY/课程/测试包 | 误转人工 + 画像膨胀 | MEMORY §3 · TEST-2026-09-10-CS-001 · 776 pytest |
diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md
index f70f81e..82e2e36 100644
--- a/docs/memory/MEMORY.md
+++ b/docs/memory/MEMORY.md
@@ -9,7 +9,7 @@
**项目是什么:** 金融四 Agent(客户财富 / 代理人 / 数据分析 / 风控)共用数据层与合规底座;**不**互调 LLM,跨 Agent 走 L1/L2/L3 画像与预警表。
-**当前进度:** 需求与表设计已定 · **风控 + 平台 API + 客服 S2 Wave3 部分落地 + 数据分析 S3/P2** · **customer Chat SSE** · **C-04/C-05/C-11 等** · **786 pytest** · **19 Vitest** · **Redis @ 6380**
+**当前进度:** 需求与表设计已定 · **风控 + 平台 API + 客服 S2 Wave3 部分 + 数据分析 S3/P2(445cfbb 护栏/口径)** · **795 pytest** · **19 Vitest** · **Redis @ 6380**
**工作分支:** 团队开发在 **`merger`**;历史 `risk-control-agent` 交付冻结。
@@ -47,7 +47,7 @@
| `scripts/core/*.sql` + `reset.ps1` | **已实现** | Core 模拟库 DDL + 种子 |
| `scripts/agent/` `scripts/demo/` `scripts/dev/` | **已实现** | AML 种子 + 演示数据 + `run_sql_file.py` + **`start-redis.ps1`**(Docker Redis 优先)+ issue_dev_token |
| `scripts/sync/*.py` | **已实现** | 归属同步 + Neo4j 全图 |
-| `tests/` | **已实现** | **786 用例** 1 skipped(Wave3 customer + threshold + sanitize_postprocess + 1B/R1)
+| `tests/` | **已实现** | **795 用例** 1 skipped(Wave3 customer + threshold + sanitize_postprocess + 1B/R1)
| `docs/course/` | **交互课程集(2026-09-09)** | 打开 `docs/course/index.html` 导览中心 · 总览 + 7 模块深潜课 |
| `docs/PRD/PRD-风控监测Agent.md` | **已冻结(v1.1)** | 风控 PRD v1.0 + v1.1 追加 FR-8/9/10(§4A)+ 规则表附录 |
| `docs/项目框架设计/实现方案-风控追加需求v1.1-C4C6.md` | **已定稿** | C4~C6 编码依据(经独立 AI 评审修订闭环);分支/进度速览另见项目根 `交接文档.md` |
@@ -72,12 +72,12 @@
(风控演示:scripts/demo/prepare_risk_demo.sql,reset 后重跑)
6. python scripts/sync/sync_advisor_rel.py && python scripts/sync/sync_neo4j.py
7. `docker compose up -d redis`(或 `.\scripts\dev\start-redis.ps1`)→ **REDIS_URL=redis://127.0.0.1:6380/0**(Docker Redis 7;避开本机 Windows Redis 占 6379)
-8. uvicorn … · python -m pytest(**786 绿**);问数冒烟 · 可选 `mysql … < scripts/agent/seed-analyst-metric-dict.sql`
+8. uvicorn … · python -m pytest(**795 绿**);问数冒烟 · 可选 `mysql … < scripts/agent/seed-analyst-metric-dict.sql`
```
**AL-09 合并后架构(一句话):** 宿主 `gateway/` + 模块 `deps.py` **双栈并存**;对外登录/token **统一**;chat/risk 均走模块鉴权;接缝 S2 用 `auth_adapter`。
-**下一步(见 TODO):** 接口契约发群(复制稿)· 风控演示种子/cron · L1/L2 Redis · Vitest 补测(可选)
+**下一步(见 TODO):** 接口契约发群(复制稿)· 风控前端 polish(台账筛选等)· 客服 L1/L2 Redis(**风控不做 L1/L2**)· Vitest 补测(可选)
**Redis(2026-09-09):** 推荐 **Docker** `jinrong-redis`(`redis:7-alpine`)· 宿主机 **6380** → 容器 6379 · `.env` `REDIS_URL=redis://127.0.0.1:6380/0` · 客户端 **RESP2**(`database.py` / `redis_gateway.py`)。
@@ -103,7 +103,7 @@
- **名称:** JinRong 金融四 Agent 智能管家
- **当前阶段:** **代销平台 v0.1 + AL-09 + 客服 S2 + 数据分析 S3/P2 + 前端 P0(2026-09-09)**
-- **当前优先级:** 接口契约发群 · 风控演示运维脚本 · 画像 Redis L1/L2
+- **当前优先级:** 接口契约发群 · 风控前端 polish · 客服画像 Redis L1/L2(风控仅 L3 已接)
------
@@ -180,7 +180,7 @@ Core 模拟:scripts/core/reset.ps1 · 文档 docs/项目框架设计/Core模
依赖:requirements.txt(LangGraph + langchain-core/openai + FastAPI + SQLAlchemy)
启动:uvicorn app.main:app --reload → GET /health
Redis:`docker compose up -d redis` · `REDIS_URL=redis://127.0.0.1:6380/0` · `scripts/dev/start-redis.ps1`
-测试:python -m pytest(**786 绿**;集成需本机 MySQL + `risk_aml_list` + `prepare_risk_demo.sql`)
+测试:python -m pytest(**795 绿**;集成需本机 MySQL + `risk_aml_list` + `prepare_risk_demo.sql`)
前端:cd web && npm run dev · npm run build/test/lint(**19** Vitest)
运维/演示脚本:scripts/demo/subscribe_alerts.py(订阅推送演示)· rebuild_alerts.py TRD-xxx(引擎异常补偿重放)
JWT 联调:python scripts/dev/issue_dev_token.py --sub STAFF-30001 --roles risk_officer(+ Authorization: Bearer + X-Agent-Type)
@@ -229,6 +229,6 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md
2. 改动属于 api / service / tool / repository 哪一层?
3. 是否需 customer_id 归属与 JWT RBAC?
4. Core 是模拟库只读还是 agent 库读写?
-5. 如何验证?(`python -m pytest` **786 绿** · Redis **6380** · uvicorn + `/health` · 客户助手 Chat SSE · 测试包 `docs/memory/tests/2026-09-10-customer-1b-r1/`)
+5. 如何验证?(`python -m pytest` **795 绿** · Redis **6380** · uvicorn + `/health` · 客户助手 Chat SSE · 测试包 `docs/memory/tests/2026-09-10-customer-1b-r1/`)
大任务:FRAMEWORK/FLOW 与实现状态不符时先更新 memory 再编码(用户确认跳过除外)。
diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md
index 5c6bebc..215774a 100644
--- a/docs/memory/TODO.md
+++ b/docs/memory/TODO.md
@@ -5,10 +5,41 @@
## 进行中
-**2026-09-09 批次**:P1 收尾 + P2 analyst + customer SSE + risk_demo · 待复制发群稿 · 口径种子 SQL 本机执行
+**2026-09-10 批次**(`merger` · 基线 **795 pytest**):客服 Wave3 + 数据分析 445cfbb 功能层已落工作区 · **未 commit**
+
+### 本批次 · 优先收尾(推荐顺序)
+
+- [ ] **git commit `merger`**:客服 Wave3 + analyst guardrail/dict + 课程 layout 修复 + 文档(你确认后执行)
+- [x] **风控深潜课改版(方案 1 · 6 模块 · 读者 dev)**:见 `docs/superpowers/specs/2026-09-10-risk-course-design.md` · 模块 1–6 已写 · `build_all.py` 已跑
+- [ ] **数据分析 · 20 题 live battery**:`uvicorn` + DeepSeek → `python scripts/dev/run_query_battery.py` → 本地写 `scripts/dev/battery_report.json`(**已拍板:不入库** · 见 `.gitignore`)
+- [ ] **客服 · 手工验收**:`docs/memory/tests/2026-09-10-customer-1b-r1/MANUAL-CHECKLIST.md`(CUST-9527 持仓/sanitize/R1 种子)
+- [ ] **客服 · Redis DEL**:灌 R1 种子后 `redis-cli -p 6380 DEL profile:l1:CUST-9527`(6380 未起则先 `start-redis.ps1`)
+- [ ] **测试包签核**:`TEST-LOG-2026-09-10-CS-001` → 模块负责人 zhangyong 确认
+- [ ] **接口契约发群稿**:`docs/项目管理/接口契约发群-2026-09-09.md`(复制到群即完成)
+- [ ] **口径字典种子**:本机执行 `scripts/agent/seed-analyst-metric-dict.sql`(若未灌)
+
+### 需拍板(定了再开任务)
+
+| 项 | 选项 / 说明 |
+| --- | --- |
+| **C-04 阈值 push** | 维持 inline(现状) vs Redis pub / 定时扫仓 |
+| **C-11 匹配说明放开度** | 已拍板:「我能买什么」→ suitability;「推荐稳赚」仍 reject |
+| **`battery_report.json`** | **已拍板:不入库**(`.gitignore` · 本地跑 `run_query_battery.py` 生成) |
+| **本地 `data-analysis-agent` 分支** | 是否 `git pull xinghuo data-analysis-agent` 对齐远程(与 merger 独立) |
+
+**已拍板(2026-09-10 · 见下方风控/课程待办)**
+
+- [x] 风控 L1/L2 Redis **不做**(仅 L3 cache-aside)
+- [x] 风控前端 P1:**台账筛选 + 适当性页 + AML 扫描页一起做**(自有组件栈)
+- [x] 风控演示:**`scripts/demo/prepare_all.ps1` 一键灌库**
+- [x] 风控深潜课:**加模块 7(写侧并发)**
+
+---
## 待办(统筹 · P1 推荐顺序)
+> 2026-09-09 P0 主链路已通;**当前优先看上方「2026-09-10 批次」**。
+
- [x] **代销平台 API v0.1 实现**(2026-09-08)
- [x] **`web/` P0 脚手架 + 主链路真接**(2026-09-09)
- [x] **`web/src/api/risk.ts` 补 `X-Agent-Type: risk`**(2026-09-09)
@@ -37,8 +68,9 @@
- [x] **Wave 1/2 解禁**:`test_wave1_*` · `test_wave2_*` 已移出 `collect_ignore` 并绿(132+ 例)
- [x] **Wave 3 profile / 4 / 5 解禁**:`test_wave3_profile_service` · `test_wave4_e2e` · `test_wave5_notes` · 基线 **730 passed**
- [x] **1B/R1 拍板落地**(2026-09-10):`sanitize_postprocess` · Top-K · TEST-LOG · 776 pytest
-- [x] **Wave3 能力批次**(2026-09-10):C-04 threshold · C-05 nav · C-11/C-07/C-08 · visitor 1B 对齐 · keyword 优先级 · **786 pytest**
-- [x] **企业级测试包** `docs/memory/tests/2026-09-10-customer-1b-r1/`(模块负责人 zhangyong · 修改人 Andrew)
+- [x] **Wave3 能力批次**(2026-09-10):C-04 threshold · C-05 nav · C-11/C-07/C-08 · visitor 1B 对齐 · keyword 优先级 · **786→795 pytest**
+- [x] **企业级测试包** `docs/memory/tests/2026-09-10-customer-1b-r1/`
+- [x] **课程 layout 修复**(2026-09-10):`showLayer` 单参回退 · `.module-inner` CSS 别名 · `build_all.py`
- [x] **客服 KB 第二套(fin_*)· Ollama 灌库**(2026-09-09 本机):
1. `ollama pull bge-m3` ✓
2. Ollama 服务 `http://127.0.0.1:11434` ✓
@@ -50,7 +82,7 @@
### 数据分析 Agent · S3 接缝(2026-09-09 已接线)
-> 清单:`docs/项目框架设计/数据分析Agent-合并说明.md` · **773 passed** · 冒烟 `scripts/dev/smoke_analyst.py`
+> 清单:`docs/项目框架设计/数据分析Agent-合并说明.md` · **795 passed** · 冒烟 `scripts/dev/smoke_analyst.py`
- [x] merge + 接缝(`analyst_router` · `analyst_auth_adapter` · 问数走 `get_platform_auth_context`)
- [x] customer `self` 域 + 「AI 分析有风险」尾注
@@ -58,62 +90,75 @@
- [x] 前端问数页 · Scope B 冒烟 19/19
- [x] **P2 · D-11 资产沉淀 UI**(`AnalystAssetsPage` · `POST /api/analyst/assets`)
- [x] **P2 · 问数页 dashboard 卡片**(`GET /api/analyst/dashboard` · `AnalystQueryPage` 顶部 metrics)
-- [x] **P2 · 口径字典种子**(`scripts/agent/seed-analyst-metric-dict.sql` · 本机需手工执行)
+- [x] **P2 · 口径字典种子 SQL**(`scripts/agent/seed-analyst-metric-dict.sql` · 本机需手工执行)
- [x] **customer Chat SSE**(`chat.py` stream → `prepare_customer_stream` · 前端 `mode="stream"`)
- [x] **STAFF-30001/30002 追加 risk_demo**(`jwt_service.py` · 模拟交易页可造预警)
+- [x] **445cfbb 功能层 cherry-pick**(2026-09-10):`guardrail.py` · `dict_service.py` 流水消歧 · wave6 测试 · `run_query_battery.py` · `数据分析Agent-代码迭代.md`
-### 风控 Agent · 前端/运维(盘点 2026-09-09 · 部分已与 P0 重叠)
+### 数据分析 Agent · 445cfbb 合入后开放项
-**HTTP · 前端**
+- [ ] **20 题 live battery 跑分**(见上方「本批次 · 优先收尾」)
+- [ ] **agent 编排接流水 Ambiguity**:`dict_service` 已返 Ambiguity · 确认 `analyst_agent` 反问链路在 live 20 题里生效(Q 含「流水」)
+- [ ] **已知挂账(迭代文档)**:Q17 `create` 误杀 · Q7 无城市字段静默改职业 · 待排期
+- [ ] **远程分支同步**(可选):本地 `data-analysis-agent` @ `fd9464d` 落后 `xinghuo/data-analysis-agent` 2 commit
-- [x] **`RiskAlertsPage`**:列表 + 处置 UI · JWT 已带 `X-Agent-Type: risk`
-- [ ] **`GET /api/risk/alerts` 筛选 UI 补全**(`alert_type` / `customer_id` / 日期窗 — 页面目前部分参数)
-- [ ] **`POST /api/compliance/suitability-check`** · **`POST /api/risk/suitability/check`**:前端未接
-- [ ] **`POST /api/risk/aml/scan`** · **`POST /api/simulate/trade`**:无 UI(Swagger/curl)
+### 风控 Agent · 2026-09-10 拍板收口
-**对话 Tool(risk Chat SSE 触达)**
+> **后端主链路(FR-1~10 · 对话 6 Tool · 处置 API)已完成。** 下列为 polish / 演示 / 文档,不是「风控坏了」。
-- [x] risk Chat SSE 前端已接(`ChatPanel` · `X-Agent-Type: risk`)
-- [x] **`alert_query` / `customer_context` / `suitability_check` / `aml_lookup` / `query_overdue_alerts`**(后端 C1 已注册)
-- [x] **`query_agent_behavior`**:已写入 `RISK_TOOL_REGISTRY`(2026-09-09)
+**拍板(已定 · 不再扩 scope)**
-**运维 / 动态演示(无 UI · 见 `演示SOP-风控模块.md`)**
+- [x] **画像 Redis L1/L2 不进风控**:L1/L2 热缓存归**客服线**(`customer_service` + `ProfileHotCache`);风控只保留 **L3 cache-aside**(`profile_l3.get_profile_l3` · `customer_context` Tool 已走)— **不做** risk 侧 L1/L2 Redis
+- [x] **前端用自有组件**:扩页时复用 `PageShell` · `Button`(`components/ui`)· `ApiErrorResult` · AntD `Table/Modal/Select`(对齐 `RiskAlertsPage` / `RiskSimulatePage`),不新引 UI 库
+- [x] **模拟交易 UI 已有**:`RiskSimulatePage` + `api/simulate.ts`(A-1/A-3 预设)
-- [ ] **`scripts/demo/subscribe_alerts.py`**:订阅 Redis `risk:pub:alert` 实时推送(Pub/Sub 无持久化,须先开订阅再发交易)
-- [ ] **`scripts/cron/escalation_scan.py`**:超期未处置升级(FR-9 · 需库内已有 `pending_review` 且 `created_at` 超阈值)
-- [ ] **`scripts/cron/agent_behavior_scan.py`**:代理人行为链扫描(FR-10 · 需 audit_log 先积累越权/诱导交易痕迹)
-- [ ] **`scripts/demo/rebuild_alerts.py`**:按 `trade_id` 幂等重放引擎(engine_error 补偿)
+**P1 · 前端代码(2026-09-10 拍板 · 三页一起做 · 2026-09-10 完成)**
-**演示种子 / 测试数据补充(测未接接口 · 非 everyday `reset.ps1`)**
+- [x] **台账筛选 UI**:`RiskAlertsPage` · `alert_type` / `customer_id` / 日期窗
+- [x] **适当性检查页**:`RiskSuitabilityPage` · `POST /api/risk/suitability/check`
+- [x] **AML 扫描页**:`RiskAmlScanPage` · `POST /api/risk/aml/scan`
+- [x] **路由/菜单**:`/app/risk/suitability` · `/app/risk/aml-scan`
-> Core reset 后 **`risk_alert` 为空**、AML/测评可能未就位;测预警/处置/推送/FR-9/FR-10 须先补数据。
+**P1 · 演示一键灌库(2026-09-10 完成)**
-- [ ] **`scripts/agent/seed-aml-list.sql`**:8 条 AML 名单(A-5:`CUST-1002` 展示名故意命中)
-- [ ] **`scripts/demo/prepare_risk_demo.sql`**:6 客户测评刷新(可交易);`CUST-1004` 保持过期(FM-03 阻断演示)
-- [ ] **风控走查重灌(SOP §2)**:双库 DROP + agent 表 + 上两项 + `sync_advisor_rel`(`reset.ps1` **只重建 core**)
-- [ ] **动态造预警 · A-1~A-5**:`POST /api/simulate/trade`(Swagger/curl)— A-3 `CUST-3001`/`PROD-510300`/50 万 · A-4 `CUST-9527` 连发 4 笔 · A-5 `CUST-1002`/`PROD-005828`/100
-- [ ] **Redis 实时推送演示**:终端 1 `subscribe_alerts.py` → 终端 2 发 simulate/trade(Pub/Sub 无持久化)
-- [ ] **FR-9 超期升级**:库内 `pending_review` 且 `created_at` 超 `RISK_ESCALATION_L1_HOURS`(默认 4h)→ `escalation_scan.py`
-- [ ] **FR-10 代理人行为链**:A-12 ① 同一 `X-Debug-Actor=STAFF-10087` 发 3 组赎回+申购 + 越权 audit → `agent_behavior_scan.py`
-- [ ] **L3 监测档数据**:A-5 AML 命中后 `customer_profile_l3.monitor_tier=high`(测 `customer_context` Tool / Dashboard 高风险客户数)
-- [ ] **适当性阻断样例**:A-1 `CUST-1001`/`PROD-161725`/1 万 · A-2 `CUST-4001`/2 万(测 handle/台账 suitability 单)
-- [ ] **引擎补偿**:`rebuild_alerts.py `(engine_error 场景)
-- [ ] **可选 dev 静态预警 seed**:2~3 条 `pending_review` 样例(免每次 simulate;与集成测试当日窗冲突须文档说明)
-- [ ] **链式 reset 脚本**:AML + prepare 并入 `reset.ps1` 或新增 `scripts/demo/prepare_all.ps1`
+- [x] **`scripts/demo/prepare_all.ps1`**:双库 DROP → core + agent 种子 → AML → prepare_risk_demo → sync_advisor_rel
+
+**P2 · 深潜课(2026-09-10 完成)**
+
+- [x] **模块 7 · 写侧并发**:`modules/07-concurrency.html` · nav 7 dots · `build.sh` / python 拼接 `index.html`
+
+**后端 / 架构 · 挂账(非 P1 · 动契约前先出方案)**
+
+- [ ] **AL-06**:chat 链路 `risk_suitability_log.actor_id` 暂落 SYSTEM(`agent_tool_call` 已覆盖发起者;改须动 `core_tools`/`kb_tools` 冻结签名)
+
+**对话 Tool(risk Chat SSE)— 已完成**
+
+- [x] risk Chat SSE(`ChatPanel` · `X-Agent-Type: risk`)
+- [x] 6 只 Tool 已注册(含 `query_agent_behavior`)
+
+**运维 / 演示 checklist(脚本已有 · 非产品化 · 见 `演示SOP-风控模块.md`)**
+
+> 走查时手工跑即可;**不等于**后端缺 API。
+
+- [ ] `subscribe_alerts.py` → Redis `risk:pub:alert` 实时推送演示
+- [ ] `escalation_scan.py` / `agent_behavior_scan.py`(FR-9/10 · cron 无 UI,运维手跑)
+- [ ] `rebuild_alerts.py`(engine_error 补偿)
+- [x] SOP §2 灌库:`seed-aml-list.sql` + `prepare_risk_demo.sql`(`reset.ps1` 只重建 core)→ **`prepare_all.ps1` 一键串联**
+- [ ] 走查样例 A-1~A-5 · FR-9/10 · L3 high 样例(文档步骤,非代码任务)
### 客服 Agent · Wave3 开放项(2026-09-10 后)
-- [ ] **C-04 push/cron**:阈值命中仅持仓查询内联提醒;无 Redis pub / 定时扫仓
+- [ ] **C-04 push/cron**:阈值命中仅持仓查询内联提醒;无 Redis pub / 定时扫仓(**拍板**)
- [ ] **C-05 Phase B**:`nav-snapshot` sync · 前端行情 Phase B(见 v0.2 草案)
- [ ] **C-08 偏离检测**:仅 L1 槽位,无自动调仓建议
- [ ] **Vitest**:客户 Chat 新 intent 无前端单测(后端已覆盖)
+- [ ] **手工验收 + TEST-LOG 签核**(见上方「本批次 · 优先收尾」)
-- [x] **L3 热读**:`get_profile_l3` cache-aside(读 Redis miss → MySQL → SET EX 5m;写 upsert DEL)· `customer_context` Tool 已走此路径
-- [ ] **L1 热读/写**:`ProfileHotCache` 仅在客服线 `customer_service.py` · 宿主 `POST /api/chat` 未接
-- [ ] **L2 热读/写**:表与 JWT scope 已有 · Repository/Redis **未实现**
+### 画像 Redis · cache-aside(2026-09-09 · **客服域,非风控**)
-
-- [ ] chat 链路 `risk_suitability_log.actor_id` 暂落 SYSTEM(run_tool 统一透传需动 core_tools/kb_tools 冻结 func 契约,发起者留痕已由 agent_tool_call 覆盖)——AL-06 遗留,动 Tool 契约前先出方案
+- [x] **L3 热读(风控)**:`get_profile_l3` cache-aside · `customer_context` Tool 已走此路径
+- [ ] **L1 热读/写(客服)**:`ProfileHotCache` 仅在 `customer_service.py` · 宿主 `POST /api/chat` 未接 — **风控不做**
+- [ ] **L2 热读/写(顾问)**:表与 JWT scope 已有 · Repository/Redis **未实现** — **风控不做**
- [x] **【前端接入·方案 B】chat 拉侧三端点**(commit `8328c24`,2026-09-08):`GET /api/chat/sessions`(本人+本 Agent 线分页列表)、`GET /api/chat/sessions/{id}/messages`(历史消息升序分页,closed 仍可读)、`POST /api/chat/sessions/{id}/close`(active→closed,重复/非 active 409);chat.py 抽 `_resolve_agent_type`/`_assert_chat_entry`/`_guard_session` 三守卫供四端点共用,POST "" 行为零回归;risk_manager 在对话线数据面保持 403(PRD 4A.1)。测试 482→**494 绿**;独立评审 P0=0,P1(close 并发 rowcount 静默 200)已修复
- [x] **【前端接入·方案 C】SSE 流式对话**(commit `01ec5fc`,2026-09-08):新增 `POST /api/chat/stream`(OpenAI 兼容 chunk:首帧 meta → delta → finish_reason=stop → `[DONE]`);`agent_service.stream_chat` 生成器 + `needs_disclaimer`;`_guard_request`/`_prepare_turn` 与同步端点共用守卫;`session_repository.insert_turn` user+assistant 同事务落库(修评审 P0/P1)。测试 494→**503 绿**。遗留:无心跳帧、断连留空会话待清理
- [x] chat 同步/流式落库免责声明口径统一(2026-09-09):`chat.py` `_assistant_content_for_persist` · sync/stream 共用 · `test_chat` 回归
@@ -149,5 +194,6 @@
- [x] 2026-09-05 `core_ro.py` + `settings.mysql_core_database` + sync 脚本
- [x] 2026-09-05 Agent 编排依赖改为 LangGraph(requirements.txt)
- [x] 2026-09-05 memory 文件夹更新(新 Agent 交接清单)
+- [x] 2026-09-10 客服 Wave3 + analyst 445cfbb cherry-pick + 课程 layout 修复(工作区 · **795 pytest** · 未 commit)
- [x] 2026-09-09 数据分析 Agent S3 合并接线 + 问数页 + 迁移/冒烟(`773 passed` · commit `af9fb71`)
- [x] 2026-09-09 数据分析 Agent 需求规格定稿 v1.1(`docs/需求拆解/01-数据分析Agent需求规格.md`)
diff --git a/docs/superpowers/specs/2026-09-10-risk-course-design.md b/docs/superpowers/specs/2026-09-10-risk-course-design.md
new file mode 100644
index 0000000..48c0de4
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-10-risk-course-design.md
@@ -0,0 +1,93 @@
+# 风控监测 Agent 深潜课 · 改版设计(2026-09-10)
+
+> **读者:** A · 内部 dev(指挥 AI 改 `app/service/risk/*`、接前端、跑演示)
+> **结构:** 方案 1 · **7 模块**(保留并刷新 1–4,新增 5–7)
+> **基线:** `merger` · **795 pytest** · AL-09 双栈 · FR-1~10 已实现
+
+---
+
+## 模块地图
+
+| # | 标题 | 指挥 AI 时解决什么 | 主要代码入口 |
+| --- | --- | --- | --- |
+| **1** | 四条能力线与鉴权 | 风控 REST 为什么要 `X-Agent-Type: risk`;Demo 账号与角色 | `risk.ts` · `deps.get_auth_context` · `risk.py` |
+| **2** | 模拟交易 → 预警出单 | 事件线四段;R-02 网关阻断 vs 大额待审 | `simulate.py` · `trade_gateway.py` · `risk_engine.py` |
+| **3** | REST 台账与对话 Tool | 台账 handle 状态机;6 只 Tool 只读红线 | `chat_tools.py` · `tool_service` · `agent_service` risk 分支 |
+| **4** | 演示数据、缺口与硬边界 | reset 后台账空;cron 无 UI;不冻户不改 C 等级 | `prepare_risk_demo.sql` · `演示SOP` · 模块 4 缺口表 |
+| **5** | FR-8/9/10 规则扩展 | 集中度/超期升级/行为链:引擎 vs Chat 查询 | `concentration` · `escalation_service` · `agent_behavior_service` · Tool |
+| **6** | L3 监测档与角色矩阵 | AML→L3;officer/manager/compliance 数据面差异 | `profile_l3.py` · `aml_service` · RBAC 矩阵 |
+| **7** | 写侧并发 | 同日聚合锁 · L3 乐观锁 · 处置+审计同事务;**不做 L1/L2 Redis** | `locks.py` · `alert_service.py` · `profile_l3.py` · `handle_alert` |
+
+---
+
+## 模块 1–4 · 刷新要点(不重写骨架)
+
+- 基线数字 → 795 pytest;STAFF-30001 + risk_demo 保留
+- 适当性:强调 **core_ro.check_suitability**(SUIT-001~008 已退役一句带过)
+- 模块 2:补 **R-02 阻断** 与 **R-01 大额待审** 分叉(同网关、不同结果)
+- 模块 3:确认 6 Tool 列表与 `query_agent_behavior`;Chat 走 SSE + risk 头
+- 模块 4:与 `TODO.md` 风控开放项对齐;链接 `演示SOP-风控模块.md`
+
+---
+
+## 模块 5 · FR-8/9/10(新建)
+
+**教学目标:** dev 改规则/演示时知道「引擎出单」和「对话只读查」的边界。
+
+| FR | 讲什么 | 演示锚点 |
+| --- | --- | --- |
+| FR-8 / RISK-006 | 持仓 R4+R5 集中度 · 并入事件类聚合 | A-10 · `customer_context` 集中度字段 |
+| FR-9 / RISK-007 | 超期 pending · payload 升级 · status 不变 | `query_overdue_alerts` · `escalation_scan.py` |
+| FR-10 / RISK-008 | 代理人 A/B/C 条件 · actor_id 维度出单 | `query_agent_behavior` · `agent_behavior_scan.py` |
+
+**Quiz 示例:** 「对话里能让 Agent 跑 escalation_scan 吗?」→ 否,只能查库 / 运维跑脚本。
+
+---
+
+## 模块 6 · L3 与角色矩阵(新建)
+
+**教学目标:** dev 接 Tool / API 时不越权、不写降级 L3。
+
+- L3 `monitor_tier` 合并防降级 · AML 命中写 high
+- `customer_context` Tool:L0 + L3 + 待审预警(只读)
+- 角色:risk_officer(handle/dispose)· risk_manager · compliance(aml 域)
+- 铁律复述:审计只 INSERT · 预警默认 pending_review
+
+---
+
+## 模块 7 · 写侧并发(新建 · 2026-09-10 拍板)
+
+**教学目标:** dev 指挥 AI 改写路径时,分清悲观锁(聚合串行)与乐观锁(L3 `computed_at`),不误接 L1/L2 Redis。
+
+| 主题 | 讲什么 | 代码锚点 |
+| --- | --- | --- |
+| 预警聚合 | 同日同客户事件类 → `run_locked(agg:event:…)` | `alert_service.py` · `locks.py` |
+| L3 upsert | `WHERE computed_at = :expected` · 失败重试 | `profile_l3.py` · `risk_repository.update_l3` |
+| 处置审计 | status 变更 + audit_log **同事务** | `handle_alert` · B7 挂账⑦ 已收口 |
+| 边界 | L1/L2 Redis 归客服/顾问;风控仅 L3 cache-aside | MEMORY § 已拍板 |
+
+**Quiz 示例:** 「给风控加 L1 Redis 热读合理吗?」→ 否,L3 cache-aside 足够。
+
+---
+
+## 明确不讲(模块 4 一句)
+
+- SUIT 旧编号体系细节 · 自动冻户 · cron 前端化 · SOP 11 步全文
+
+---
+
+## 验收
+
+- [x] `_base.html` 7 nav dots · `build.sh` / python 拼接 `index.html`
+- [x] 每模块 ≥1 quiz · translation-block 白话+代码
+- [x] entity 先概览(文件/服务名第一次出现带一句职责)
+- [x] 与 `docs/memory/REQUIREMENTS.md` Wave2 状态一致
+
+---
+
+## 待用户确认后执行
+
+1. ~~新建 `modules/05-fr-extensions.html` · `06-l3-rbac.html`~~ ✅
+2. ~~刷新 `01–04` 上述要点~~ ✅
+3. ~~`python docs/course/build_all.py`~~ → `bash docs/course/jinrong-module-risk/build.sh`
+4. ~~新建 `modules/07-concurrency.html` · 更新 `_base.html` nav~~ ✅(2026-09-10)
diff --git a/docs/项目框架设计/数据分析Agent-代码迭代.md b/docs/项目框架设计/数据分析Agent-代码迭代.md
new file mode 100644
index 0000000..083bd38
--- /dev/null
+++ b/docs/项目框架设计/数据分析Agent-代码迭代.md
@@ -0,0 +1,146 @@
+# 数据分析 Agent · 代码迭代文档
+
+> 演化记录:从空壳到可用的 NL2SQL Agent,含模块实现、测试基线、问题修复与待办。
+> 关联:`docs/memory/FRAMEWORK.md`(技术选型 / 实现状态)、`docs/需求拆解/01-数据分析Agent需求规格.md`(需求 D-xx)、`docs/项目框架设计/数据分析Agent架构说明书.md`。
+
+## 1. 定位与范围
+
+数据分析 Agent(`AnalystAgent`)把**自然语言问数**翻译为一条只读 SELECT 并执行,面对 `jinrong_core`(模拟 Core 库,L0 事实)与部分 `jinrong_agent` 表,最终输出「人话解读 + 数据表格 + 元信息」。当前为 **analyst 角色全量(full 域)可跑**的实现。
+
+约束(不可越界):仅 SELECT 只读 · 不带处置(不写预警)· 数字不可编造 · 留痕审计。
+
+## 2. 代码演进时间线
+
+| 日期 | 里程碑 | Git | 说明 |
+| --- | --- | --- | ---- |
+| 2026-09-05 | 项目脚手架 + 需求/框架定稿 | `0374010` `1ddd44a` | FastAPI 分层 / 双库 / LangGraph 选型;分析 Agent 仅空壳 |
+| 2026-09-09 | **分析 Agent 全套落地** | `b19a241` | api/service/schema/校验/护栏/缓存/字典/LLM + tests + 文档,41 文件 +3463 行 |
+| 2026-09-10 | 三问查数 + **护栏 Decimal/标识误报修复** + 流水消歧 | 工作区 | guardrail.py(Decimal/编号/日期时间/左边界)、dict_service(流水多义),degrade→success/clarify |
+| 2026-09-10 | 20 题实测 + **数字护栏误拦截修复** | 工作区 | guardrail.py 两处修复,4 题 degrade → success |
+
+核心逻辑类:`app/service/analyst_agent.py`(`AnalystAgent.run()` 7 步编排,兼容 `build_graph()` LangGraph 适配)。
+
+## 3. 模块实现清单
+
+| 模块 | 文件 | 职责 | 状态 | 本轮变化 |
+| --- | --- | --- | --- | --- |
+| 编排 | `service/analyst_agent.py` | 消歧→SQL→校验→执行→解读→护栏→留痕 | 已实现 | – |
+| 路由 | `api/analyst.py` | `/chat` `/dashboard` `/assets` `/ops/metrics` | 已实现 | – |
+| 鉴权 | `utils/auth.py` + `api/deps.py` | JWT(HS256) + 角色→数据域 RBAC | 已实现 | – |
+| 数据访问 | `service/analytics_repo.py` | 只读执行 / 归属白名单 / 留痕 / 资产沉淀 / 空态三分类 | 已实现 | – |
+| SQL 校验 | `service/sql_guard.py` | 五层只读校验(关键字 / 表白名单 / 多语句 / 行级归属 / 粒度) | 已实现 | – |
+| 数字护栏 | `service/guardrail.py` | 解读数字与结果逐字校验(D-10) | 已实现 | **本轮修复** |
+| 指标口径 | `service/dict_service.py` | 指标字典 + 多义词消歧(N-01) | 已实现 | – |
+| Schema 提示 | `service/schema_meta.py` | 注入表/列说明给 LLM | 已实现 | – |
+| LLM | `service/llm.py` | DeepSeek 对话 / 提取 SQL / 成本估算 | 已实现 | – |
+| 查询缓存 | `service/cache_service.py` | Redis→内存降级;权限指纹键;TTL 分层(D-06) | 已实现 | – |
+| 未接入 | `rag_service.py` `memory_service.py` | RAG 知识库 / L1-L3 业务记忆 | 空壳存根 | – |
+
+### 请求流转(`AnalystAgent.run()`)
+
+```
+auth 域判定 → 指标消歧(若多义→clarify) → 生成SQL →
+SQL 五层校验(失败→deny) → 执行(缓存优先) → 空态三分类 →
+LLM 解读 → 数字护栏(失败重试1次→降级) → 组装响应 → 留痕 double-write
+```
+
+## 4. 需求覆盖(对照需求规格)
+
+| 需求 | 能力 | 落点 |
+| --- | --- | --- |
+| D-01/02 | NL→SQL、只读查询 | `_generate_sql` + `validate` |
+| D-04 | 查询/审计留痕 | `log_query` + `log_audit` |
+| D-06 | 结果/模板缓存 | `cache_service` |
+| D-07 / N-01 | 口径字典 / 消歧 | `dict_service` |
+| D-10 | 数字护栏 | `guardrail`(本轮加强) |
+| D-11 | 资产沉淀 | `assets` + `insert_asset` |
+| D-12 / N-08 | 看数板 / 运营指标 | `dashboard` / `ops_metrics` |
+| RBAC | 角色→数据域 | `auth.assert_analyst_access` |
+
+## 5. 质量基线
+
+**单元测试:** `tests/test_*.py` 覆盖 agent / api / cache / dict / guardrail(14) / sql_guard / llm / repo。改为本机确认 `tests/test_guardrail.py` OK。
+
+**实测基线(2026-09-10,analyst 全量域,20 题):**
+
+| 维度 | 结果 |
+| --- | --- |
+| 成功返回可用结果 | 18 / 20(90%) |
+| 澄清后继续(多义) | 1(「规模」持仓/产品多义,属预期) |
+| 白名单误杀 deny | 1(Q17 触发 `create` 危险词误判) |
+| 数字护栏误拦截(修复前) | 4(Q1/Q9/Q19/Q20)→ 修复后可全过 |
+| 内容正确性 | 17 / 18 数字无误写;1 题口径跑题(Q7) |
+
+## 6. 本轮迭代详情(2026-09-10)
+
+### 背景
+
+用户问「用户持仓前三? 申购前三? 交易流水前三?」验证 NL2SQL 查数。端到端跑通后暴露两个问题:数字护栏把正确回答误判为降级,「交易流水前三」语义漂移。
+
+### 问题 1:数字护栏误拦截(三类根因)
+
+- **Decimal 不被识别**:pymysql 对 `DECIMAL`/`SUM(...)` 返回 `Decimal`,`_to_float` 只认 `int/float/str`,金额被当 `None` 丢弃,`result_numbers()` 只剩 `{0.0, 行数}`,导致「315.00万元」这类正确解读被判失败,三次查询全部 `degrade`。
+- **ID/名称/日期里的数字误报**:`extract_numbers` 把 `CUST-3001` 拆成 `-3001`、把「沪深300指数」拆出 `300`、把「9月3日10:00」拆出 `9`,从而误判「说错数字」。
+- 其中「中文后直接接编号」(客户CUST-3001、产品PROD-510300)会使 `\b` 边界失效(中文也属 `\w`)。
+
+### 修复 1(`app/service/guardrail.py`)
+
+1. `_to_float` 新增 `Decimal` 分支。
+2. `extract_numbers` 先屏蔽编号 token(`CUST-/TRD-/PROD-/STAFF-`,不加 `\b`)与日期/时间 token(`YYYY-MM-DD`、`M月D日`、`HH:MM`),再匹配。
+3. `NUMBER_RE` 加左边界 `(? 踩坑备注:① 8000 端口跑的是 `customer-service-agent` 分支(auth/chat),非数据分析 Agent,勿按端口混淆分支;② 旧服务由 node 守护进程自动重启,非管理员 shell 杀 python 无效。
+
+## 7. 上一轮迭代详情(2026-09-10)
+
+### 问题:数字护栏误拦截(4 题 degrade)
+
+- **Q1/Q20** 根因:结果列是**比率**(`0.1515`),解读写**百分比**(`15.15%`),护栏未做「比率 ×100」换算即判错。
+- **Q19** 根因:解读「近 30 天」的时间窗数字 `30` 被正则当成数据数字,与结果 `0` 比对失败。
+- 附带:Q9 依赖 LLM 措辞(是否用「万」)存在波动,与护栏策略耦合。
+
+### 修复(`app/service/guardrail.py`)
+
+1. 新增 `_WINDOW_TOKEN`:数字提取前遮挡「近30天/近 30 月」等时间窗口径,防窗口数字污染。
+2. `check_numbers()`:当结果集合含 0~1 的比率时,追加 `0.01` 比对刻度(识别 `n×0.01 ≈ 结果`,即百分比=比率×100)。
+
+### 验证
+
+- 离线确定性用例:Q1/Q19/Q20 通过;编造数字负例(`999999.99`)仍被拦截(护栏未削弱)。
+- 既有 14 项 guardrail 单测均 OK,无回归。
+- 端到端真实接口(重启后端加载新码后):Q1/Q9/Q19/Q20 全部 `degrade → success`,数字逐一核对正确。
+
+> 踩坑备注:后端 `--reload` 对已改模块未实时生效,须重启进程验证代码变更。
+
+## 8. 已知问题与下一步
+
+| # | 问题 | 影响 | 建议 |
+| --- | --- | --- | --- |
+| 1 | Q17:SQL 白名单把 `create` 当危险词,字段/列名命中即 deny | 一次误拦 | `create` 仅拦截语句级(`CREATE TABLE`),字段/列名放行 |
+| 2 | Q7:库无「城市」字段时,Agent 静默改按「职业」统计而非澄清/拒绝 | 答非所问 | NL2SQL 对库结构不匹配加「澄清/拒绝」兜底 |
+| 3 | 数字护栏对"聚合比率/多位小数"依赖 LLM 措辞 | flaky | 可考虑允许集合追加百分比别名校验以进一步收敛 |
+| 4 | guardrail 修复尚无回归用例 | 防复发缺口 | 将 4 题误报场景补入 `tests/test_guardrail.py` |
+| 5 | RAG / 业务记忆仍为空壳 | 能力边界 | 待后续迭代按架构说明书接入 |
+
+## 9. 验证方式
+
+```text
+运行:uvicorn app.main:app --reload → POST /api/analyst/chat(Bearer 开发 token)
+探活:GET /health
+单测:tests/(guardrail、sql_guard、agent、api 等)
+实测:scripts/dev/run_query_battery.py(20 题基线)
+配置:.env(DEEPSEEK_API_KEY 必填;缺失则 LLM 报错)
+```
\ No newline at end of file
diff --git a/scripts/demo/prepare_all.ps1 b/scripts/demo/prepare_all.ps1
new file mode 100644
index 0000000..78238d1
--- /dev/null
+++ b/scripts/demo/prepare_all.ps1
@@ -0,0 +1,62 @@
+#Requires -Version 5.1
+<#
+.SYNOPSIS
+ 风控演示一键灌库(对齐 演示SOP-风控模块.md §2 脚本化方式)
+
+.DESCRIPTION
+ 双库 DROP → Core 种子 → Agent 共用底座 + 专用表 → AML 名单 → 风控演示测评 → 顾问归属同步。
+ reset.ps1 只重建 jinrong_core;本脚本覆盖走查所需的 agent 侧数据。
+
+.EXAMPLE
+ .\scripts\demo\prepare_all.ps1
+ .\scripts\demo\prepare_all.ps1 -MysqlHost 127.0.0.1 -SkipAdvisorSync
+#>
+param(
+ [string]$MysqlUser = "root",
+ [string]$MysqlHost = "127.0.0.1",
+ [switch]$SkipAdvisorSync
+)
+
+$ErrorActionPreference = "Stop"
+$Root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
+Set-Location $Root
+
+function Invoke-SqlFile($path) {
+ $full = Join-Path $Root $path
+ if (-not (Test-Path $full)) {
+ throw "SQL file not found: $full"
+ }
+ Write-Host ">> $path"
+ python (Join-Path $Root "scripts/dev/run_sql_file.py") $full
+}
+
+Write-Host "=== Drop jinrong_core + jinrong_agent ==="
+mysql -h $MysqlHost -u $MysqlUser -p -e "DROP DATABASE IF EXISTS jinrong_core; DROP DATABASE IF EXISTS jinrong_agent;"
+
+$sqlFiles = @(
+ "scripts/core/00-create-database.sql",
+ "scripts/core/01-ddl.sql",
+ "scripts/core/02-seed-base.sql",
+ "scripts/core/03-seed-customers.sql",
+ "scripts/core/04-seed-holdings.sql",
+ "scripts/core/05-seed-trades.sql",
+ "scripts/core/06-seed-nav.sql",
+ "docs/项目框架设计/表设计/01-mysql-共用底座.sql",
+ "docs/项目框架设计/表设计/02-mysql-agent专用.sql",
+ "scripts/agent/seed-aml-list.sql",
+ "scripts/demo/prepare_risk_demo.sql"
+)
+
+foreach ($f in $sqlFiles) {
+ Invoke-SqlFile $f
+}
+
+if (-not $SkipAdvisorSync) {
+ Write-Host "=== Sync advisor rel ==="
+ python (Join-Path $Root "scripts/sync/sync_advisor_rel.py")
+}
+
+Write-Host ""
+Write-Host "=== prepare_all 完成 ==="
+Write-Host "预期:AML 名单 8 条 · prepare_risk_demo 测评就位 · sync_advisor_rel upsert 行数输出"
+Write-Host "下一步:uvicorn app.main:app --reload · 走查见 docs/项目框架设计/演示SOP-风控模块.md"
diff --git a/scripts/dev/run_query_battery.py b/scripts/dev/run_query_battery.py
new file mode 100644
index 0000000..f8a1865
--- /dev/null
+++ b/scripts/dev/run_query_battery.py
@@ -0,0 +1,95 @@
+"""20 题批量问答测试:调 /api/analyst/chat,记录 status/SQL/rows/answer。
+
+需本地 uvicorn + MySQL + DeepSeek Key(真实 LLM)。
+用法:python scripts/dev/run_query_battery.py
+"""
+from __future__ import annotations
+
+import json
+import os
+import sys
+import urllib.request
+
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, ROOT)
+
+from app.service.auth_service import issue_dev_token
+
+URL = "http://127.0.0.1:8000/api/analyst/chat"
+
+QUERIES = [
+ ("Q01", "各风险等级(C1~C5)客户各有多少?占比多少?"),
+ ("Q02", "C3 客户大概占多少?"),
+ ("Q03", "高净值 vs 非高净值客户各多少、占比多少?"),
+ ("Q04", "客户总体规模(总客户数)怎样?"),
+ ("Q05", "近 30 天业务活跃度和再往前 30 天比怎样?按交易笔数"),
+ ("Q06", "各服务等级(普通/VIP/钻石)客户分布?"),
+ ("Q07", "各城市/地区客户大概怎么分布?汇总"),
+ ("Q08", "各风险等级产品(R1~R5)各有多少只?"),
+ ("Q09", "钱主要集中在哪类产品?按持仓市值"),
+ ("Q10", "最近 30 天申购金额和笔数,和再往前 30 天比怎样?"),
+ ("Q11", "近 30 天赎回大概多少?"),
+ ("Q12", "哪几类产品申购最猛?按产品类型汇总,不要列单个客户"),
+ ("Q13", "还有多少条预警没处理(待审核)?"),
+ ("Q14", "预警按状态怎么分布?"),
+ ("Q15", "高等级预警(反洗钱/适当性)大概多少?"),
+ ("Q16", "大额交易类预警大概多少?"),
+ ("Q17", "近一段时间适当性不匹配/被拦大概多少笔?汇总"),
+ ("Q18", "理财顾问名下客户规模对比"),
+ ("Q19", "近 30 天新开户大概多少?"),
+ ("Q20", "AML 风险等级(高/中/低)客户占比?"),
+]
+
+
+def call(question: str, idx: int, token: str) -> dict:
+ payload = json.dumps({
+ "question": question,
+ "session_id": f"battery-{idx}",
+ "trace_id": f"trace-{idx}",
+ }).encode("utf-8")
+ req = urllib.request.Request(
+ URL,
+ data=payload,
+ headers={
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json; charset=utf-8",
+ },
+ method="POST",
+ )
+ with urllib.request.urlopen(req, timeout=180) as resp:
+ return json.loads(resp.read().decode("utf-8"))
+
+
+def main() -> None:
+ token = issue_dev_token(sub="STAFF-20001", roles=["analyst"])
+ out = []
+ for idx, (tag, q) in enumerate(QUERIES, 1):
+ rec: dict = {"tag": tag, "question": q}
+ try:
+ r = call(q, idx, token)
+ except Exception as exc:
+ rec.update({"status": "EXC", "error": str(exc)})
+ out.append(rec)
+ print(json.dumps(rec, ensure_ascii=False))
+ continue
+ t = r.get("table") or {}
+ rec["status"] = r.get("status")
+ rec["answer"] = r.get("answer")
+ rec["sql"] = (r.get("sql") or "").replace("\n", " ")
+ rec["columns"] = t.get("columns")
+ rec["rows"] = (t.get("rows") or [])[:6]
+ rec["row_count"] = len(t.get("rows") or [])
+ rec["error_code"] = r.get("error_code")
+ out.append(rec)
+ print(json.dumps(rec, ensure_ascii=False))
+ sys.stdout.flush()
+
+ report_path = os.path.join(ROOT, "scripts", "dev", "battery_report.json")
+ with open(report_path, "w", encoding="utf-8") as f:
+ json.dump(out, f, ensure_ascii=False, indent=2)
+ print(f"\nWrote {report_path}")
+
+
+if __name__ == "__main__":
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+ main()
diff --git a/tests/test_wave6_dict_service.py b/tests/test_wave6_dict_service.py
index 7f727f6..3014e9b 100644
--- a/tests/test_wave6_dict_service.py
+++ b/tests/test_wave6_dict_service.py
@@ -26,6 +26,23 @@ class TestDictService(unittest.TestCase):
self.assertIsInstance(m, Metric)
self.assertEqual(m.key, "holding_scale")
+ def test_trade_flow_ambiguous(self):
+ m = self.reg.resolve("交易流水")
+ self.assertIsInstance(m, Ambiguity)
+ self.assertEqual(len(m.candidates), 2)
+ keys = sorted(c.key for c in m.candidates)
+ self.assertEqual(keys, ["trade_flow_amount", "trade_flow_count"])
+
+ def test_trade_flow_amount_unambiguous(self):
+ m = self.reg.resolve("流水金额")
+ self.assertIsInstance(m, Metric)
+ self.assertEqual(m.key, "trade_flow_amount")
+
+ def test_trade_flow_count_unambiguous(self):
+ m = self.reg.resolve("流水笔数")
+ self.assertIsInstance(m, Metric)
+ self.assertEqual(m.key, "trade_flow_count")
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_wave6_guardrail.py b/tests/test_wave6_guardrail.py
index fe84dc8..aab1604 100644
--- a/tests/test_wave6_guardrail.py
+++ b/tests/test_wave6_guardrail.py
@@ -1,5 +1,6 @@
"""guardrail 数字护栏单元测试。"""
import unittest
+from decimal import Decimal
from app.model.analyst_schemas import TableData
from app.service.guardrail import check_numbers, extract_numbers, result_numbers, verify
@@ -46,6 +47,31 @@ class TestGuardrail(unittest.TestCase):
t = TableData(columns=["v"], rows=[[1234567]])
self.assertEqual(check_numbers("约 123 万元", t), [])
+ def test_decimal_result_numbers(self):
+ t = TableData(columns=["customer_id", "holding_scale"], rows=[["CUST-A", Decimal("3150000.00")]])
+ nums = result_numbers(t)
+ self.assertIn(3150000.0, nums)
+
+ def test_decimal_wan_answer_valid(self):
+ t = TableData(columns=["v"], rows=[[Decimal("3150000.00")]])
+ self.assertEqual(check_numbers("CUST-DEMO-B 持仓 315.00 万元", t), [])
+
+ def test_customer_id_not_extracted(self):
+ self.assertEqual(extract_numbers("客户 CUST-3001 交易流水 5 笔"), [5.0])
+
+ def test_customer_id_adjacent_chinese_not_extracted(self):
+ self.assertEqual(extract_numbers("客户CUST-3001 交易流水 5 笔"), [5.0])
+ self.assertNotIn(510300.0, extract_numbers("产品PROD-510300 申购 520000.00 元"))
+
+ def test_date_time_not_extracted(self):
+ self.assertEqual(extract_numbers("520000.00元(9月3日10:00)"), [520000.0])
+ self.assertEqual(extract_numbers("2026-09-03 10:00 成交 500000 元"), [500000.0])
+
+ def test_product_name_digits_not_extracted(self):
+ nums = extract_numbers("沪深300指数 1838000.00 元")
+ self.assertNotIn(300.0, nums)
+ self.assertIn(1838000.0, nums)
+
if __name__ == "__main__":
unittest.main()
diff --git a/web/src/App.tsx b/web/src/App.tsx
index e7d34a2..72478f9 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -18,6 +18,8 @@ import { MarketQuotesPage } from './pages/market/MarketQuotesPage'
import { RiskAlertsPage } from './pages/risk/RiskAlertsPage'
import { RiskChatPage } from './pages/risk/RiskChatPage'
import { RiskSimulatePage } from './pages/risk/RiskSimulatePage'
+import { RiskSuitabilityPage } from './pages/risk/RiskSuitabilityPage'
+import { RiskAmlScanPage } from './pages/risk/RiskAmlScanPage'
import { ChatPanel } from './components/chat'
import { PageShell } from './components/PageShell'
import { useAppAuth } from './layouts/AppLayout'
@@ -103,6 +105,8 @@ export default function AppRoutes() {
} />
} />
+ } />
+ } />
} />
} />
diff --git a/web/src/api/risk.ts b/web/src/api/risk.ts
index 4916fdb..f140cba 100644
--- a/web/src/api/risk.ts
+++ b/web/src/api/risk.ts
@@ -41,6 +41,8 @@ export type ListAlertsParams = {
status?: string
alert_type?: string
customer_id?: string
+ start_date?: string
+ end_date?: string
page?: number
page_size?: number
}
@@ -50,6 +52,8 @@ export async function listAlerts(token: string, params: ListAlertsParams = {}) {
if (params.status) q.set('status', params.status)
if (params.alert_type) q.set('alert_type', params.alert_type)
if (params.customer_id) q.set('customer_id', params.customer_id)
+ if (params.start_date) q.set('start_date', params.start_date)
+ if (params.end_date) q.set('end_date', params.end_date)
q.set('page', String(params.page ?? 1))
q.set('page_size', String(params.page_size ?? 20))
const { data } = await apiFetch(`/api/risk/alerts?${q.toString()}`, {
@@ -70,3 +74,48 @@ export async function handleAlert(token: string, alertId: string, payload: Handl
)
return data
}
+
+export type SuitabilityCheckPayload = {
+ customer_id: string
+ product_id: string
+}
+
+export type SuitabilityCheckResponse = {
+ match_result: string
+ mismatch_type: string
+ is_matched: boolean
+ blocked: boolean
+ block_reason?: string
+ block_response_code?: string
+ customer_level?: string
+ product_level?: string
+ advice?: string
+ notice?: string
+ rule_refs?: string[]
+}
+
+export async function checkSuitability(token: string, payload: SuitabilityCheckPayload) {
+ const { data } = await apiFetch('/api/risk/suitability/check', {
+ method: 'POST',
+ headers: riskHeaders(token),
+ body: JSON.stringify(payload),
+ })
+ return data
+}
+
+export type AmlScanResponse = {
+ scanned: number
+ hit_customers: number
+ alerts: string[]
+ skipped_existing: string[]
+ disclaimer: string
+}
+
+export async function scanAml(token: string) {
+ const { data } = await apiFetch('/api/risk/aml/scan', {
+ method: 'POST',
+ headers: riskHeaders(token),
+ body: JSON.stringify({}),
+ })
+ return data
+}
diff --git a/web/src/pages/risk/RiskAlertsPage.tsx b/web/src/pages/risk/RiskAlertsPage.tsx
index 10d7759..457ea4e 100644
--- a/web/src/pages/risk/RiskAlertsPage.tsx
+++ b/web/src/pages/risk/RiskAlertsPage.tsx
@@ -1,4 +1,4 @@
-import { Alert, Modal, Select, Table, Tag, Typography } from 'antd'
+import { Alert, Input, Modal, Select, Table, Tag, Typography } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useCallback, useEffect, useState } from 'react'
import { ApiErrorResult } from '../../components/ApiErrorResult'
@@ -20,6 +20,10 @@ export function RiskAlertsPage() {
const [rows, setRows] = useState([])
const [total, setTotal] = useState(0)
const [status, setStatus] = useState('pending_review')
+ const [alertType, setAlertType] = useState()
+ const [customerId, setCustomerId] = useState('')
+ const [startDate, setStartDate] = useState('')
+ const [endDate, setEndDate] = useState('')
const [page, setPage] = useState(1)
const [disclaimer, setDisclaimer] = useState('')
const [handleTarget, setHandleTarget] = useState(null)
@@ -35,6 +39,10 @@ export function RiskAlertsPage() {
try {
const data = await listAlerts(auth.accessToken, {
status,
+ alert_type: alertType,
+ customer_id: customerId.trim() || undefined,
+ start_date: startDate ? `${startDate}T00:00:00` : undefined,
+ end_date: endDate ? `${endDate}T23:59:59` : undefined,
page,
page_size: 20,
})
@@ -46,7 +54,7 @@ export function RiskAlertsPage() {
} finally {
setLoading(false)
}
- }, [auth.accessToken, page, status])
+ }, [auth.accessToken, alertType, customerId, endDate, page, startDate, status])
useEffect(() => {
void load()
@@ -116,22 +124,94 @@ export function RiskAlertsPage() {
}
>
{error && }
-
-
筛选状态
-