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) +
# query_overdue_alerts — 只读 FR-9 入口
+# query_agent_behavior — 只读 FR-10 入口
+# 无 Tool 封装 escalation_scan / agent_behavior_scan
+
+
+ 白话 +
+

想让超期单「升级」→ 运维跑 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 与台账

+
+
+ + + + +
+ +
+ + + + +
+
+
+ +
+
+
+ L3 防降级 · profile_l3.py 口径 +
# monitor_tier: normal < watch < high
+# 合并时取 max(tier),禁止写低档覆盖高档
+# risk_score 一期 NULL,归评分模型首写
+
+
+ 白话 +
+

指挥 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 示例 +
# alert_service:同日同客户事件类预警
+run_locked(
+  f"agg:event:{customer_id}:{today}",
+  lambda locked: _merge_or_insert(...),
+)
+
+# profile_l3:同一客户 L3 upsert
+run_locked(f"l3:{customer_id}", _write_with_optimistic_retry)
+
+
+ 白话 +

想象收银台叫号器:同一客户、同一天的「首单聚合」必须排队进窗口,窗口里再查「今天是否已有单」——两笔并发进来也不会开出两张重复首单。

+

L3 则是贴便签前先对表上的时间戳:若别人刚改过,你的 UPDATE 打不中,就重读再合并(只升不降监测档)。

+
+
+
+ +
+

群聊:并发两笔 · 聚合与 L3

+
+
+ + + + +
+ +
+ + + + +
+
+
+ +
+

数据流:写侧三条线

+
+
+
🔒聚合锁
+
📋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) +
# query_overdue_alerts — 只读 FR-9 入口
+# query_agent_behavior — 只读 FR-10 入口
+# 无 Tool 封装 escalation_scan / agent_behavior_scan
+
+
+ 白话 +
+

想让超期单「升级」→ 运维跑 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 与台账

+
+
+ + + + +
+ +
+ + + + +
+
+
+ +
+
+
+ L3 防降级 · profile_l3.py 口径 +
# monitor_tier: normal < watch < high
+# 合并时取 max(tier),禁止写低档覆盖高档
+# risk_score 一期 NULL,归评分模型首写
+
+
+ 白话 +
+

指挥 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 示例 +
# alert_service:同日同客户事件类预警
+run_locked(
+  f"agg:event:{customer_id}:{today}",
+  lambda locked: _merge_or_insert(...),
+)
+
+# profile_l3:同一客户 L3 upsert
+run_locked(f"l3:{customer_id}", _write_with_optimistic_retry)
+
+
+ 白话 +

想象收银台叫号器:同一客户、同一天的「首单聚合」必须排队进窗口,窗口里再查「今天是否已有单」——两笔并发进来也不会开出两张重复首单。

+

L3 则是贴便签前先对表上的时间戳:若别人刚改过,你的 UPDATE 打不中,就重读再合并(只升不降监测档)。

+
+
+
+ +
+

群聊:并发两笔 · 聚合与 L3

+
+
+ + + + +
+ +
+ + + + +
+
+
+ +
+

数据流:写侧三条线

+
+
+
🔒聚合锁
+
📋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 && } -
- 筛选状态 - { + setPage(1) + setStatus(v) + }} + options={[ + { value: 'pending_review', label: '待审核' }, + { value: 'handled', label: '已处置' }, + ]} + /> +
+
+ 预警类型 + { + setPage(1) + setCustomerId(e.target.value) + }} + /> +
+
+ 开始日期 + { + setPage(1) + setStartDate(e.target.value) + }} + /> +
+
+ 结束日期 + { + setPage(1) + setEndDate(e.target.value) + }} + /> +
+ (null) + const [result, setResult] = useState(null) + + const runScan = async () => { + setLoading(true) + setError(null) + setResult(null) + try { + const data = await scanAml(auth.accessToken) + setResult(data) + } catch (e) { + setError(e instanceof Error ? e : new Error('scan failed')) + } finally { + setLoading(false) + } + } + + return ( + void runScan()}> + 执行扫描 + + } + > + + 本页调用 POST /api/risk/aml/scan,仅{' '} + risk_officer 可执行。扫描 Core 活跃客户展示名,命中{' '} + risk_aml_list 后出 aml 预警并写 L3;同日幂等跳过已有单。 + 走查前请先跑 prepare_all.ps1。 + + } + /> + + {error ? : null} + + {result ? ( + +

+ 扫描客户 {result.scanned} 人 · 命中{' '} + {result.hit_customers} 人 · 新发预警{' '} + {result.alerts.length} 条 · 跳过已有{' '} + {result.skipped_existing.length} 人 +

+ {result.alerts.length > 0 ? ( +

+ 新预警编号: + {result.alerts.join(', ')} +

+ ) : null} +
{JSON.stringify(result, null, 2)}
+ + } + /> + ) : ( + + 点击右上角「执行扫描」开始;A-5 演示可先跑扫描再查台账 aml 单。 + + )} +
+ ) +} diff --git a/web/src/pages/risk/RiskSuitabilityPage.tsx b/web/src/pages/risk/RiskSuitabilityPage.tsx new file mode 100644 index 0000000..3932e9f --- /dev/null +++ b/web/src/pages/risk/RiskSuitabilityPage.tsx @@ -0,0 +1,117 @@ +import { Alert, Form, Input, Select, Typography } from 'antd' +import { useState } from 'react' +import { ApiErrorResult } from '../../components/ApiErrorResult' +import { PageShell } from '../../components/PageShell' +import { Button } from '../../components/ui' +import { checkSuitability, type SuitabilityCheckResponse } from '../../api/risk' +import { ApiError } from '../../api/client' +import { useAppAuth } from '../../layouts/AppLayout' + +const PRESETS = [ + { + key: 'a1', + label: 'A-1 阻断(CUST-1001 · PROD-161725)', + values: { customer_id: 'CUST-1001', product_id: 'PROD-161725' }, + }, + { + key: 'a2', + label: 'A-2 过期测评(CUST-4001 · 任意产品)', + values: { customer_id: 'CUST-4001', product_id: 'PROD-161725' }, + }, +] + +export function RiskSuitabilityPage() { + const auth = useAppAuth() + const [form] = Form.useForm<{ customer_id: string; product_id: string }>() + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + + const onFinish = async (values: { customer_id: string; product_id: string }) => { + setLoading(true) + setError(null) + setResult(null) + try { + const data = await checkSuitability(auth.accessToken, values) + setResult(data) + } catch (e) { + setError(e instanceof Error ? e : new Error('check failed')) + } finally { + setLoading(false) + } + } + + return ( + + + 本页调用 POST /api/risk/suitability/check,走 Core + 只读适当性矩阵(C×R),与模拟交易网关阻断口径一致;每次校验写审计留痕。 + + } + /> + +
+ + + + + + + + + + {error ? ( +
+ form.submit()} /> +
+ ) : null} + + {result ? ( + + {result.block_reason ?

{result.block_reason}

: null} + {result.advice ?

{result.advice}

: null} + {result.notice ?

{result.notice}

: null} +
{JSON.stringify(result, null, 2)}
+ + } + /> + ) : null} +
+ ) +} diff --git a/web/src/routes/menus.tsx b/web/src/routes/menus.tsx index fc6757c..7ab2030 100644 --- a/web/src/routes/menus.tsx +++ b/web/src/routes/menus.tsx @@ -1,9 +1,11 @@ import { AlertOutlined, BarChartOutlined, + CheckCircleOutlined, CommentOutlined, HomeOutlined, LineChartOutlined, + SafetyCertificateOutlined, SwapOutlined, TeamOutlined, UserOutlined, @@ -98,6 +100,8 @@ export function buildMenuGroups(roleLabel: string): RouteMenuGroup[] { children: [ { key: '/app/risk/home', icon: , label: '首页', path: '/app/risk/home' }, { key: '/app/risk/alerts', icon: , label: '预警台账', path: '/app/risk/alerts' }, + { key: '/app/risk/suitability', icon: , label: '适当性校验', path: '/app/risk/suitability' }, + { key: '/app/risk/aml-scan', icon: , label: 'AML 扫描', path: '/app/risk/aml-scan' }, { key: '/app/risk/simulate', icon: , label: '模拟交易', path: '/app/risk/simulate' }, ], },