zhangshy
|
d2cdbbac01
|
修复风控时间口径与Agent截断提示
|
2026-09-11 15:25:45 +08:00 |
|
lzf_0626
|
ff681df143
|
落地第十五条豁免额度校验;登记三处业务裁定
一、第十五条豁免规则(docs/25 第七节 #2,业务裁定:实现)
政策原文:C3→R4 签署风险揭示书后**可买**,但单只 R4 持仓不超过总资产 20%;
C4→R5 同理,上限 10%。越级购买本身不是违规,超出额度才是 —— 原先扫描侧只看
"留痕是否齐全",于是"签了字但买超额度"这种明确违规没有预警;研判侧也把豁免的
前提条件(留痕齐全)当成了结论,直接判"疑似误报"。
- 扫描侧:新增 EXEMPTION_LIMITS 与 RiskRuleEngine._exemption_state,核算
"单只持仓 / 总资产"并写进证据快照;触发条件改为
gap > 0 and (missing_trace or 超出额度)。
- 研判侧:_assess_rw007 先判额度再判留痕。超限 → 证据支持风险;留痕齐全且在额度
内 → 疑似误报;留痕齐全但快照缺总资产/持仓 → 继续复核。
数据前提(tools/probe_exemption_data.py,证据见 docs/evidence/exemption-data-probe.json):
库内 fin_customer_profile 仅 1 行且 total_asset = 0.00、fin_holding 0 行、无任何申购
交易 —— 这条规则当前不会被触发,与 behavior_score 同源(画像与持仓由本项目之外的
流程写入)。因此刻意不把"算不出来"当成"超限":拿 0 去算会让每一笔 C3→R4 都变成违规,
豁免规则反倒成了误报源。上游把数据写入后无需再改代码即可生效。
二、三处业务裁定(此前挂在"待裁定")
- 模型网关 chat + tools 入口:本轮不补,按基座能力缺口记录。它要贯穿
ModelGateway → … → BaseAgent 整条链路,属公共契约变更,演示联调期影响面大于收益。
- exclude(关闭误报)是否必须先"调查中":保持现状,不加门禁。
- 政策冲突:以第十四条 C ≥ R 为准;客服侧 check_suitability 复核后确认本来就按
C ≥ R 实现,无需改动。
三、其他
- 新增 tests/unit/service/test_risk_judgement_rw007.py(6 例)与扫描侧 4 例。
- 风控文档 03/05 同步 RW-007 的豁免额度条件与研判口径。
|
2026-09-11 14:24:45 +08:00 |
|
lzf_0626
|
661b17c244
|
风控全量查询封顶并在响应里标注截断(docs/25 P3 #20)
预警详情里的资金流水/持仓/登录记录、以及日报的四组预警都是"一次性取全量"。某个客户
的历史数据一旦异常膨胀,单次响应就能把内存和连接拖垮;登录记录此前更是连时间窗都
没有。
处理原则是"封顶但**不静默**":详情证据每类最多 200 条、日报每组最多 5000 条,用
`limit + 1` 多取一行判断是否真被截断(只看"取满没取满"会把恰好等于上限的正常数据
误报成截断),被截断时:
- 详情新增 `evidence_truncated: string[]`,列出被截断的证据类型;
- 日报新增 `data_truncated: bool` —— 日报的 daily_alert_count 等数字直接来自行数,
静默截断等于给出一份看起来正常、实际少统计的日报,那比报错更难发现。
`scalars(...)` 统一用 `list(...)` 而不是 `.all()`,实现不再依赖 ScalarResult 的专有方法。
新增 tests/unit/repository/test_risk_repository_limits.py(3 例,用 literal_binds
编译 SQL,确保断言看到的是 201 而不只是"有 LIMIT"),并补日报与详情的标记用例;
风控文档 06 同步登记两个新字段与写接口的幂等要求。
|
2026-09-11 14:16:07 +08:00 |
|
lzf_0626
|
15866d4564
|
风控写接口接入平台幂等(docs/25 P3 #22)
docs/05 §5.1 把"业务写接口"列为必须携带 Idempotency-Key 的接口,风控 6 个 POST
(手工扫描、确认接收、进入调查、关闭误报、完成结案、升级处理)此前一个都没带,
重复提交会二次驱动状态机。
复用平台的 api_request_receipt 与 ApiTransactionService,但新增 execute_in:原来的
execute 自己开 SessionFactory() 和 session.begin(),而 RiskActionService._finish
会在内部 commit,套进去就成了"内层提交外层事务"。execute_in 改为在调用方传入的
session 上读写幂等记录,幂等记录因此与业务写入同处一个事务(§5.2)。
scope 用实际路径(含 alert_no)而不是路由模板:§5.1 的幂等范围是
user_id + method + normalized_path + idempotency_key,把路径参数折成模板会让同一个键
在不同预警之间互相回放 —— 那是把两次不同资源的操作当成一次。
测试:单测加幂等透传替身与"缺键即 422"用例;新增
tests/integration/test_risk_idempotency_mysql.py,覆盖同键回放不重复执行、同键不同
正文 409、缺键/非 ASCII 拒绝,以及端到端"重复 POST 只调用一次处置逻辑"。
|
2026-09-11 14:12:56 +08:00 |
|
lzf_0626
|
790518114b
|
风控 SSE 内容协商与鉴权时序;docs/05 补齐 413 与风控入口(docs/25 P3 #23 #24 #25)
#23:413 是上传超限的标准语义,前端文档(风控业务演示文档 17)也已按 413 做提示
映射,所以不把代码降成 422,而是在 docs/05 §3.5 状态码表补登 413 —— 契约以"补齐"
而不是"改动"的方式对齐。
#24:/api/v1/risk/daily-report/stream 此前既不校验 Accept,又把鉴权留在 async
generator 内部。后者更隐蔽:StreamingResponse 已经返回、响应头已经发出,403 只能
变成"200 + 半截流"。现在 controller 先 await service.authorize(context) 再判定
Accept,顺序与 §6.4 一致(鉴权先行,不用状态码差异做探测)。SSE 协商逻辑抽到
app/api/dependencies/negotiation.py,与 /agent-runs/{run_id}/events 共用同一口径,
避免同一种客户端在一个端点上 200、另一个端点上 406。
#25:复核后确认前半段不成立 —— §19 末尾写明业务域接口由各自业务文档登记,风控 15 条
端点已在 06-模块接口与字段映射.md 逐条登记。真问题是 §12 表里写的
/api/v1/risk-scans/**、/api/v1/risk-alerts/** 与实际实现 /api/v1/risk/** 不符,
按实际实现更新 §12 并加说明;顺带把风控文档里 /daily-report/mail 的权限从
"按主项目邮件策略执行"改为实际的 risk:report:mail。
新增 tests/unit/api/test_risk_stream_negotiation.py(7 例)。
|
2026-09-11 14:08:55 +08:00 |
|
lzf_0626
|
a572c09a5c
|
风控列表游标绑定用户与查询条件(docs/25 遗留 P3)
docs/05 §3.8 要求游标绑定用户、查询条件、排序字段和方向,此前实现只把 offset
用 base64 包了一层:任何登录用户拿到别人的游标都能继续翻,换个筛选条件也能继续翻
(偏移量对不上就静默返回错页)。现在游标里携带 SHA-256 指纹:
- 指纹口径 = user_id + data_scope/customer_ids + 查询条件(排除 limit/cursor)
- 刻意排除 limit:它是分页参数、不是查询条件,算进去只会让翻页时改页大小失效
- /evidence/{source} 的 source 是路径参数,单独并入指纹,否则 customers 的
游标能直接拿去翻 products
- 指纹不符一律 InvalidCursorError -> 400 INVALID_CURSOR(docs/05 §3.6)
新增 2 个单测:换用户/换筛选/换 data_scope 失效、改 limit 仍有效、
不同证据类型游标不互通。
|
2026-09-11 14:05:37 +08:00 |
|
lzf_0626
|
e8b075bc7f
|
fix(risk): 列表接口的信封对齐 docs/05 §3.3——游标从 data 挪到 meta
docs/05 §3.3 的列表样例是 data 为**纯数组**、
ext_cursor 与 has_more 放在 meta 里,
并明确「业务接口不得增加其他顶层字段」。而 RiskQueryService._page 返回的
{items, next_cursor, has_more} 被**整体塞进 data** —— 游标因此出现在**业务数据**里,
meta 只剩 trace_id,两处都不符合契约。
改动:
- 新增 _list_envelope(page, context):把 _page 的结构拆成 data = items、
meta = {trace_id, next_cursor, has_more}。**service 侧不动** —— 它继续返回那个内部
结构,只是不再直接当 data 用。
- 3 个列表端点改用它:/alerts、/evidence/{source}、/notifications。
非列表端点(overview、详情、各类写操作)保持原样,不带游标。
**影响调用方**:这是接口形状变更。组员若写了前端读 data.items,需要改成读 data、
并从 meta 取分页元数据。改动依据是 docs/05 这个唯一权威接口文档(§20 也要求实现与
文档同步)。**合并时要提醒组员。**
**实测**(9002 身份):
- /alerts?limit=2 → 顶层键 ['data','meta']、data 是 list(2 条真实数据)、
meta 键 ['has_more','next_cursor','trace_id']
- /evidence/customers 与 /notifications 同形状
- 对照 /overview(非列表)→ meta 只有 trace_id、不带游标
**测试**:3 处断言从 data["items"] 改为 data;/notifications 那处补上对 meta 的断言;
新增 test_list_endpoints_follow_the_documented_envelope,直接断言"data 是纯数组、
meta 恰好三个键、顶层恰好 data/meta",把 §3.3 的契约钉住。
过程里踩了两个自己的坑:① 忘了 rom typing import Any(与 customer_service.py 同一失误,
被 ruff/mypy 当场抓住);② 漏了 /evidence/{source} 这个列表端点,是测试先失败才发现的。
ruff / mypy(136 文件) / 640 unit+contract / 29 integration 全绿。
|
2026-09-11 14:01:16 +08:00 |
|
lzf_0626
|
d896a906cd
|
fix(risk): 规则扫描加跨进程锁——手工触发与定时扫描此前可以同时跑
docs/25 P2 最后一项。核实后分清了两层,报告没区分:
- **定时扫描是安全的**:risk_scan_scheduler.py 已有 MySQL 连接级咨询锁
(GET_LOCK,锁名 jr_risk_scan_schedule),跨进程互斥。
- **HTTP 端点不安全**:POST /api/v1/risk/alerts/scan → RiskScanService.scan() 只用了
**进程内** asyncio.Lock。多 Web worker、或 Worker 与 API 同时运行时形同虚设。
而扫描的幂等只有应用层的 _exists 查重 —— fin_risk_alert 的 trigger_rule_codes 是 JSON
数组,**无法建唯一索引兜底**(同一交易可命中多条规则,唯一键本应是"交易+规则",而规则
埋在 JSON 里)。所以两条路径并发时会同时查不到、同时插入,产生重复预警。
**改动**:
1. 把 mysql_scan_lock 与锁名移到 pp/infrastructure/db.py —— 端点与调度器**必须共用
同一把锁**,放在基础设施层两个入口才都能引用(service 不该反向依赖 worker)。
调度器改为从那里 import。
2. **端点层加锁**(controllers/risk.py 的 scan 端点):取不到锁就抛 RiskScanBusyError
(与 service 内部那把进程内锁用同一错误类型与文案)。
**为什么不加在 RiskScanService.scan() 内部**:GET_LOCK 是**连接级**的,而调度器已经在
它自己的 session 上持锁;被两个入口共用的服务方法若再取同一把锁,取锁的连接并不是持锁的
那一个、必然返回 0 —— 会**把定时扫描自己挡死**。所以锁加在入口层,每个入口只取一次。
**实测**:
- 无人持锁时扫描 → **200**「规则扫描完成」
- 本进程先取得跨进程锁后再调端点 → **409**「规则扫描正在执行,请稍后重试」
(同一进程内不同 session 也互斥,说明它是连接级的,正是跨进程所需)
- 释放后再调 → **200**,恢复正常
顺带第 4 次遇到 409 复用错误码 RUN_NOT_CANCELLABLE,语义不符;属 P3 待处理项。
ruff / mypy(136 文件) / 639 unit+contract 全绿。
|
2026-09-11 13:57:22 +08:00 |
|
lzf_0626
|
3ab7f33d64
|
fix(risk): 预警行带出 close_reason,日报的"误报原因"不再是空
docs/25 P2。核实确认报告准确,而且这一处缺口造成两个症状:
_alert_row 是**列表 / 详情 / 日报共用**的行构造器
(risk_repository.py:174 / :276 / :321),而它的字段列表里没有 close_reason。于是:
- 日报的"误报原因"分布恒为"未填写"
(risk_daily_report_service.py:139 用 item.get("close_reason") or "未填写");
- :243 的明细里同一字段同样拿不到值。
断的是"关闭误报时写入原因"这条链路的**下半段**:risk_action_service.py:70 把原因赋给
lert.close_reason、也确实存进了库(app/model/fund.py:368 有该字段),只是读取时没带出来。
修法是一行:在 _alert_row 里补上 close_reason。三个调用点同时受益;读取方都是风控侧
接口(需要 risk:alert:read),不涉及客户可见面。
新增 tests/unit/repository/test_risk_alert_row_close_reason.py(2 条):关闭原因出现在行里;
未关闭时为 None 但**键必须在** —— 读取方靠 or "未填写" 兜底,键一旦缺失就永远只能走
兜底分支,那正是修复前的状态。
ruff / mypy(136 文件) / 639 unit+contract / 29 integration 全绿。
|
2026-09-11 13:51:37 +08:00 |
|
lzf_0626
|
b241059c2a
|
fix(risk): 合并预警的嵌套证据在研判入口摊平,RW-003 不再一律降级
docs/25 P2。核实后比报告描述的更麻烦一点:合并后的 evidence_snapshot 只留
product_id 与 merged_alerts,**各条规则原有的证据键被塞进了嵌套结构**
(risk_scan_service._merge_same_transaction_alerts:398-404)。而各研判函数读的是
**顶层键**(snapshot.get("ratio") 之类),于是合并过的预警一律读不到证据、降级成
"缺证据无法复核" —— 合并本来是为了少几条噪音,结果把这些预警的研判全废了。
修法:在**研判入口统一摊平**,而不是让每条规则各自去认嵌套结构。
- 新增 _flatten_merged_evidence(detail):顶层已有的键优先(来自 priority_score 最高的
主预警),再按顺序补入各子条目 evidence 里的键;merged_alerts 本身保留,
可追溯性不受影响;非合并结构原样返回。
- 列表级(_assess_list_rule)与详情级(_assess_detail_rule)两个入口都调用它,
所有规则(RW-003/007/012/015/018)一并受益。
新增 tests/unit/service/test_risk_judgement_merged_evidence.py(5 条):非合并结构原样返回、
嵌套键被抬到顶层、冲突时顶层优先、多子条目全部抬平,以及**报告症状的回归** ——
对比"没有 ratio"与"ratio 藏在 merged_alerts 里"两种输入,摊平后 RW-003 的研判结果
不再相同(原先两者都会降级成同一句话)。
ruff / mypy(136 文件) / 637 unit+contract 全绿。
|
2026-09-11 13:49:57 +08:00 |
|
lzf_0626
|
c1f043684c
|
fix(risk): RW-012 研判补上"≥3 倍历史均值"复核,顺带修一处除零
docs/25 P2。核实后发现**根因不在研判侧偷懒,而在快照缺数据**:
- 扫描侧生成 RW-012 要求 verage > 0 且 amount >= average * 3(risk_scan_service.py:224);
- 研判侧 _assess_rw012 只看了年龄、金额、非常用设备,**完全没核均值** —— 于是达不到
3 倍的交易也会被判"证据支持风险"。它自己的建议文本写着"继续核实一年期历史交易均值":
作者知道该看,只是当时确实没有数据可看。
- 原因:扫描侧写进快照的只有 product_id / age / device_id,**没有均值**
(对比 RW-003 在 :124 就写了 ratio)。
**两侧一起改**:
1. 扫描侧补写 verage_amount 与
atio(用 ratio 与 RW-003 的快照风格保持一致)。
2. 研判侧据此复核:
atio 缺失 → CONTINUE_REVIEW("无法复核 3 倍门槛",
不再顺着"非常用设备"定案);
atio < 3 → SUSPECTED_FALSE_POSITIVE;否则走原有逻辑。
3. **顺带修一处除零**:verage == 0 时 mount < 0*3 恒为 False,会一路走到
mount / average 直接崩。改为同时挡住 verage_amount <= 0。
新增 tests/unit/service/test_risk_judgement_rw012.py(3 条):ratio 缺失时不落到风险成立、
ratio < 3 判误报并给出倍数、ratio >= 3 继续往下走。三个用例都停在登录记录判断之前,
不必构造复杂的登录证据。
ruff / mypy(136 文件) / 632 unit+contract / 29 integration 全绿。
|
2026-09-11 13:48:08 +08:00 |
|
lzf_0626
|
85a1b33eff
|
fix(risk): RW-018 研判的两个口径缺陷——列表级不再无条件放行,详情级与扫描侧对齐
docs/25 P2。核实后发现这两条其实是**同一处的两个表现**,所以一起修。
**① 列表级无条件放行**
_assess_list_rule 收到 item 参数**却完全不看它**,对 RW-018 一律返回"可考虑放行",
连理由文本都是硬编码的"现有摘要显示交易来自有效定投工单"。于是渠道不匹配、或证据里
根本没有工单信息的预警,在列表层就被标成可放行 —— 而列表正是风控专员最先看到的一屏
(详情层另有判断,但那要等人点进去)。
现在按 item["evidence_snapshot"]["channel"] 判断。快照里确实有渠道:扫描侧写入了它
(risk_scan_service.py:308),列表行也带出了整个 snapshot(risk_repository.py:955),
所以这个校验是可行的,此前只是没做。
**② 详情级与扫描侧口径不一致**
扫描按 work_order.channel in {"定投", "自动定投"} 生成预警(risk_scan_service.py:294),
详情级却只认 "定投"。于是"自动定投"的预警会出现**"扫描认为有效、详情认为未确认"**的
自相矛盾。
抽出 DIRECT_INVESTMENT_CHANNELS = frozenset({"定投", "自动定投"}) 作为唯一口径,
列表级与详情级都改用它;改这份常量即同时影响两侧。
新增 tests/unit/service/test_risk_judgement_rw018.py(6 条):常量覆盖扫描侧全部渠道、
列表级在无渠道 / 渠道不符时**不得**放行、两个合法渠道在列表级与详情级都放行、
详情级对无关渠道不放行。
ruff / mypy(136 文件) / 629 unit+contract 全绿。
|
2026-09-11 13:45:51 +08:00 |
|
lzf_0626
|
0d3447bde4
|
fix(risk): 日报邮件端点补上授权校验——它此前是风控里唯一没有校验的端点
**问题**:POST /api/v1/risk/daily-report/mail 原先只取 context 做 401 判定,
**没有任何授权校验**;RiskDailyReportMailService.send 既拿不到 context、也不调用
AuthorizationService。收件人、标题、正文**全部由客户端决定** —— 一旦运维开启 SMTP
(RISK_DAILY_REPORT_MAIL_ENABLED),它就是一个未授权的邮件发送器。
默认关闭(ENABLED 默认 false + DRY_RUN 默认 true)让它至今没出事,但那不是可依赖的保护。
**改动**:
- send 改为 async 并接收 context,入口处 wait AuthorizationService.require(
context, "risk:report:mail")。校验放在 **service 层**而不是 controller —— 本项目风控
端点的授权一律落在 service(risk_query / risk_action / risk_scan 等都是这样),
controller 只负责取 context;这个端点是唯一的例外,现在补齐。
- controller 相应改为 wait ...send(..., context=context)。
- 权限
isk:report:mail 已在上一轮随另外三个一起创建并授予 risk_operator 与 admin。
**实测**:
- 9002(risk_operator) → **200** + {"status":"disabled","recipient_count":1}(默认关闭)
- 9001(customer) → **403** AGENT_PERMISSION_DENIED「缺少操作权限」
**测试**:3 个既有用例改为 async 并传入带权限的 context;**新增**
est_mail_service_requires_the_permission,断言无权限身份必须被拒 —— 钉住本次修复。
ruff / mypy(136 文件) / 623 unit+contract / 29 integration 全绿。
|
2026-09-11 13:39:50 +08:00 |
|
lzf_0626
|
24de6c34f7
|
fix(risk): 扫描健壮性——脏数据不再中断整批,调度器重启后仍会执行
docs/25 P0 的后两项,都属于"风控看起来在工作、其实没在跑"那一类。
**② 一条脏数据中断整批**
_level_value 原先直接 int(level.replace(prefix, "")):等级字段只要有一条不是
R1-R5 / C1-C5(例如写了「中风险」),就抛 ValueError 并冒到 scan() 的兜底 → **整批
rollback**,本次扫描前面已经生成的预警全部作废。数据脏属于运维问题,不该升级成
"整个风控停摆"。
改为返回 int | None;调用点跳过该条并记 warning(带上 transaction_id 与两个原始值,
便于运维直接定位)。顺带把
eplace 换成
emoveprefix:原先 "R2R" 会被错当成 2,
现在只去掉开头那一个前缀字符。
**③ 调度器重启后永不执行**
last_run_at 只存在内存里,重启后为 None,而 _is_due 此时返回
config.run_immediately(默认 False)⇒ 重启后 _is_due 恒为假,**调度器形同虚设,
而且没有任何告警**。
改为"从未跑过即视为 due":多跑一次的最坏后果是重复扫描,而扫描每条规则都先 _exists
查重、外层还有 MySQL 级锁;反过来"不跑"的后果可能是永远不跑。
config.run_immediately 不再承担"首次是否执行"的语义(它原本想表达"启动后别马上跑",
但那与"永远不跑"在实现上无法区分),字段保留以免破坏既有配置。
新增 tests/unit/service/test_risk_scan_robustness.py(6 条):脏数据返回 None 而不抛异常、
R2R 不被过度剥离、首次必 due(哪怕 run_immediately=False)、以及间隔前后的判定。
ruff / mypy(136 文件) / 622 unit+contract / 29 integration 全绿。
|
2026-09-11 13:35:58 +08:00 |
|
lzf_0626
|
d331c817ac
|
fix(risk): 通知创建失败必须能被区分出来,不再静默返回 0
docs/25 P2「通知失败被吞」。核实后要把定性说得更准一点:它**有** logger.exception,
不是完全静默;问题在于 _create_notifications 失败时
eturn 0,而"无需通知"
(没有高风险预警、或通知功能关闭)也返回 0 —— 调用方只拿到一个 notification_count,
**分不清"本来就不用通知"和"高风险通知创建失败"**。
风控里这个区别很要紧:通知没发出去等于处置链路的第一环断了,而扫描依旧报"完成"。
这与同一批缺陷里的"定时扫描重启后不再执行"是同一性质 —— 看起来在工作、其实有一环没跑。
改动:
- _create_notifications 返回 (条数, 失败原因),失败原因非空即代表出了问题。
- scan() 在有失败时于返回体里带上
otification_failure,并把 message 改成
"规则扫描完成,但高风险通知创建失败",让上游与运维都能直接看到。
- **失败仍然不回滚预警**:预警已经生成、比通知重要,不该因为通知写失败就丢掉。
新增 tests/unit/service/test_risk_scan_notification.py(4 条),其中一条专门断言
"两种 0 必须可区分" —— 那是本次修复的全部意义。
ruff / mypy(136 文件) / 616 unit+contract 全绿。
|
2026-09-11 13:33:41 +08:00 |
|
lzf_0626
|
e017bcc9bb
|
fix(platform): 配置告警覆盖全部受 release 约束的表,并提供生效快照能力
**问题**:上一轮加的"配置丢失告警"只比对 platform_config_item,而受 config_release
整版本替换影响的表有**三张**(按 information_schema 核对):platform_config_item /
prompt_template_version / model_routing_rule。这个盲区造成过真实后果 —— 客服闲聊提示词
挂在 release 174,active 变成 181 后 load_active_prompt 读不到,而 Agent 侧有逐字段
兜底、回落到代码默认值,于是功能看着正常、没人发现、**一行告警都没有**。
**改动**(均在 app/service/config_release_service.py):
1. 新增 RELEASE_SCOPED_TABLES:三张表 + 各自的**逻辑键**。逻辑键不含 release_id、
不含自增 id、也**不含 version** —— 同名提示词在不同版本里可以用不同 version,
那仍是同一份配置。清单是穷举的,并注明漏掉任何一张的后果都是静默失效。
2. 新增 effective_snapshot():读当前生效版本在**全部三张表**里的内容,每行已剥掉
id /
elease_id(见 NOT_PORTABLE_COLUMNS),可直接作为新版本的写入载荷。
发布脚本应先取它、再追加本次变更,这样"漏继承"就从"每次靠人记得"变成结构上不容易漏。
3. _warn_dropped_items 改为逐张表比对,告警里带上表名。
model_routing_rule 没有 ORM 映射,用原生 SQL 处理;它当前 0 行,但纳进来才不会将来
配了又漏。
测试:新增一条专门锁住"提示词被丢掉时也要点名"(那正是这次的盲区),并把 fake session
改成按表 + release 返回行 —— 第一版 fake 不分表,查提示词表时会拿到配置项的行、
报 KeyError,虽然真实代码是按表查的,但 fake 不真实就盖不住问题。
ruff / mypy(136 文件) / 612 unit+contract 全绿。
|
2026-09-11 13:25:00 +08:00 |
|
lzf_0626
|
4e2e42c896
|
test(base): 验证最后一种工具拒绝(角色不符),四种分支全部实测通过
分支 4 是最难构造的一种,两个前提缺一不可:
1. **工具的角色集合必须比 Agent 的更窄**。Agent 层的 validate_access(base.py:101)会先按
AgentDefinition.allowed_roles 拦截,两者一致时永远进不到工具层的角色校验。所以把
probe_alt 收窄为 ("risk_operator",),而 Agent 仍允许 admin。
2. **调用者必须有工具要求的权限**,否则会先命中权限分支。所以脚本临时给 admin 授
probe:read,验证后撤销。
过程中又修掉一处自己写错的地方:探针的 handle 原先硬编码调用 PROBE_TOOL,导致分支 4
(需要调 probe_alt)与分支 2(需要调白名单之外的那一个)互相干扰——第一次跑出来的结果
是"工具不在当前意图白名单"。改为按消息里的 "alt" 选择要调的工具。
四种分支的实测结果,message 各自独立、指向不同处置动作:
- 白名单为空 → 该意图未配置工具白名单
- 工具不在白名单 → 工具不在当前意图白名单
- 缺少工具权限 → 缺少工具权限
- 角色不符 → 角色不能使用工具
目标的另一半也验证了:审计里是完整细节(reason = "角色 ['admin'] 与工具允许的角色
['risk_operator'] 无交集",并带 tool_name / intent / trace_id),而异常 message 只有
"角色不能使用工具"、不含角色集合。**内部配置只进审计,不进客户可见响应。**
环境复原:生效配置 9 条(与起点一致);sys_permission / sys_role_permission 中
probe:read 的行数为 0。
|
2026-09-11 13:12:56 +08:00 |
|
lzf_0626
|
1eb8946552
|
test(base): 端到端验证工具拒绝的分支 2 与 3,探针加第二个工具
接上一轮(分支 1 已验证)。本轮用只读探针触发另外两种拒绝:
- 分支 2「工具不在当前意图白名单」:发布白名单 ["probe_alt"] 但 Agent 调 probe_echo。
这一步能做,正是因为给探针加了第二个工具 —— governance.resolve 取的是
「发布白名单 ∩ 代码声明的 allowed_tools」,配置**只能缩小不能放大**,所以单个工具的
Agent 永远构造不出"有白名单但不含该工具"的场景。这是做端到端时才撞到的结构性约束,
platform_probe.py 里已注明。
- 分支 3「缺少工具权限」:发布白名单 ["probe_echo"],工具可用了,但 admin 角色并没有
probe:read 这条权限,天然命中权限分支,不需要动 RBAC。
实测:两种拒绝的 stderr 分别为「工具不在当前意图白名单」与「缺少工具权限」,
运行状态均为 failed / AGENT_PERMISSION_DENIED;生效配置已恢复(终点 9 条,与起点一致)。
顺带发现分支 4 的结构性障碍(下一步处理):Agent 层的 validate_access 会先按
AgentDefinition.allowed_roles 拦截,所以要在**工具层**触发"角色不能使用工具",
必须让工具的角色集合比 Agent 的更窄 —— 探针目前两者的角色集合相同,触发不到。
|
2026-09-11 13:11:30 +08:00 |
|
lzf_0626
|
edc0c43245
|
test(base): 造只读探针端到端验证工具拒绝,并修掉它暴露的一个死分支
**为什么造探针**:ToolExecutor 的四种拒绝在真实链路上很难安全触发——要么改客服、风控的
生效配置,要么动 RBAC,两条路都会影响正在工作的 Agent。platform_probe 是个只读、无副作用
的探针:它只声明 probe 一个意图(所以意图分类只可能返回它)、没有发布工具白名单
(天然处于"未配置"状态)、工具只回显参数不碰业务数据。
**它立刻查出一个死分支**:探针报的是「工具不在当前意图白名单」,而不是我新加的
「该意图未配置工具白名单」。原因是 governance.resolve 会为每个 supported_intents
**预填条目**(governance.py:55-61),未配置时得到的是**空元组**——所以
intent not in configured_tools 在运行期**永远不成立**,那个分支是死代码。
单元测试没能发现它,因为我在测试里手工构造了 configured={},而真实链路不产生这个形状。
**这正是端到端测试的价值**:单元测试验证的是我设想的形状,端到端验证的是真实形状。
修法:改判"白名单为空"而非"缺键",文案改为"该意图的工具白名单为空",并注明经过 governance
装配后"完全没配"与"配了空列表"无法区分、也不假装能区分(两者运维动作相同)。新增一条按
**真实形状**({"faq": ()})构造的用例把它锁住。
实测:探针调用 → failed / AGENT_PERMISSION_DENIED,stderr 为
ForbiddenAgentError: 该意图未配置工具白名单(tool_executor.py:108)。
ruff / mypy(136 文件) / 611 unit+contract 全绿。
|
2026-09-11 13:10:04 +08:00 |
|
lzf_0626
|
38a9bc8285
|
fix(platform): 配置发布激活时点名"将被丢掉的配置项"
config_release 是**整版本替换**语义:激活新版本后,旧版本的配置项全部不再生效。
所以新版本只要漏了某项,它就是**无声消失**的——agent_tools 里的工具白名单一少,
相关 Agent 的工具就被 fail-closed 拒掉,而现场表现只是"客服/风控什么都答不了",
没人会想到是发布配置少了一条。
本项目已经两次靠"发布前手工继承"规避(客服与风控的发布脚本里各写了一遍继承逻辑),
说明这个风险真实且反复出现。
改为在 activate 时先比对"被取代版本的配置项"与"新版本的配置项",把将被丢掉的逐条
写进 warning 日志。**不阻断激活**——有时确实是要主动撤下某项配置,拒绝会让正常运维
做不了事;这里要的是"事后能查到是谁把它弄没的"。
新增 tests/unit/service/test_config_release_dropped_items.py(3 条):丢项时点名、
完整继承时无噪音(否则运维会习惯性忽略这条日志)、首个版本不报丢项。
ruff / mypy(135 文件) / 610 unit+contract 全绿。
|
2026-09-11 13:01:58 +08:00 |
|
lzf_0626
|
a7ac1b6a1c
|
fix(platform): 让"工具用不了"的三种原因可区分,并消掉选端点的隐式顺序依赖
基座层面的两处缺陷,都属于"静默失败"——排查成本高,且本项目已经各踩过一次。
1. tool_executor.py 的拒绝原因原先无法区分:
- "意图压根没发布白名单"与"白名单里没这个工具"共用一句「工具不在当前意图白名单」,
运维不知道该去补发布配置、还是改白名单内容(客服与风控的意图码都要求三处对齐,
两次都因此多花排查时间);
- 权限与角色两处只说「缺少工具权限」,不说是哪一个。
现在四种情况各有独立 message,各自指向不同的处置动作。
同时把**审计与异常分离**:白名单内容、权限码、角色集属于内部配置,只写进审计;
异常 message 会随 API 响应返回给调用方,保持通用、不泄漏配置。
2. model_gateway.py 的 TASK_CAPABILITY 补齐风控的几处 task_type
(risk_agent_chat / risk_analysis / risk_script / risk_summary / daily_report_suggestion)。
它们要的都是文本生成端点;不登记就会落到"未映射 → 返回全部 active 端点"的分支,
而能否选对端点取决于 model_endpoint_config 的**行顺序**——实测风控能跑通,仅仅因为
deepseek-flash(id=3) 恰好排在 qwen-embedding(id=5) 前面。这个隐式依赖现在消掉了。
未登记的 task_type 仍退回全部端点(保持原有保守策略:让故障表现为调用失败而不是
解析为空),但会记 warning,不再静默。
新增 tests/unit/service/test_tool_executor_denials.py(4 条),锁住"四种拒绝可区分"
与"内部细节只进审计、不进 message"。
ruff / mypy(135 文件) / 607 unit+contract 全绿。
|
2026-09-11 13:00:23 +08:00 |
|
lzf_0626
|
8ac0b794ff
|
fix(risk): 收敛剩余的时区口径(REST 时间参数、年龄、日报日期字段)
接 b3da1b6。上一条只改了凌晨规则与日报日界,剩下几处一并收掉:
1. risk_query_service.py:77-78:REST 的 start_time/end_time 是**裸 datetime**,
原先原样透传去比库内 UTC 列,而 Agent 路径本来就带时区
(risk_natural_language.py:117)——同一条筛选条件在界面与对话里会查出不同结果。
timeutil 新增 from_local:裸值按**北京时间**解释(面向中国客户的业务系统,
填表人的预期就是本地时间),带时区的按其自身时区处理。它与 to_utc_naive 的区别
正在裸值上:取库里的值用后者,接客户端输入用这个。
2. risk_scan_service.py 与 risk_judgement_service.py 的 _age:一处用 UTC 日期、
一处用服务器 date.today(),生日边界上同一客户会差一岁、65 岁阈值可能翻面。
统一走 local_date(北京时间)。
3. risk_daily_report_service.py:122 的 report_date 与 :244 的 created_today:
原先取 UTC 日期,北京 08:00 之前会把"今天新增的预警"算成昨天。
ruff / mypy(135 文件) / 603 unit+contract 全绿。
|
2026-09-11 12:55:00 +08:00 |
|
lzf_0626
|
554fbbcaab
|
fix(identity): data_scope 不能写死 self——它让所有"看全量"的路径失效
实测发现:9002(risk_operator) 与 9003(admin) 都持有 all 级权限
(permission_scopes 里 audit:read='all'、risk:alert:read='all' 等),
但 context.data_scope **永远是 'self'**——identity_repository.py 第 47 行把它写死了,
而上面第 34-39 行刚算出每个权限的 scope 并取了最高(rank 表都写好了)。
后果:凡按 context.data_scope == "all" 判断能否看全量的路径全部走不通
(risk_query_service.py:198、risk_analysis_service.py:126、
risk_evidence_archive_service.py:218、risk_action_service.py:212),
风控专员拿着全量权限却查不到任何预警——这是上一轮三个工具"都返回空"的真正原因。
改为取该身份所有权限里的最高范围。没有 all 权限的角色行为不变
(实测 9001 customer 的 data_scope 仍是 self),因此不放松任何既有边界。
另加 tools/seed_risk_alert_demo_data.py:造 3 条演示预警覆盖三个只读工具的读路径
(高危待处理 / 中危调查中 / 低危已闭环),字段取值照 risk_scan_service.py:312-333 的
_build_alert 抄、状态用 OPEN_STATUSES,时间按库内约定存 UTC。注意该表 id 非自增,
所以脚本手工生成 id。
实测(9002 身份):
- 查看当前风险概览 → 未闭环 2 条、高危 2 条、待处理 1 条,含高优先级清单与证据摘要
- 查询高风险预警 → 2 条明细,命中 RW-002/003/007/012/015,附只读复核草案
- 查询 ALDEMO0001 的证据 → 完整快照事实 + 客户维度,并主动指出证据缺口
客服回归:9001 身份行为不变。
|
2026-09-11 12:54:51 +08:00 |
|
lzf_0626
|
b3da1b65ed
|
fix(risk): 修正风控的时区缺陷——凌晨规则与日报日界
docs/25 P1 #6。根因是"库内存 UTC naive"这个约定在**业务判断层**没被遵守,
而展示层其实已经是对的(risk_daily_report_service.py:418 按配置时区转换)。
1. 新增 app/core/timeutil.py 作为统一换算入口:约定库内 UTC naive,提供
local_hour / local_date / local_day_bounds(后两者用于查库时必须返回 UTC naive,
否则区间与库内值整体错开 8 小时),带时区的入参按其自身时区解释。
2. risk_scan_service.py:248:0 <= confirmed_at.hour < 6 → local_hour(...)。
原先 [0,6) UTC 被当成"凌晨",实际是北京时间 08:00-14:00,整条
「凌晨时段小额操作」规则判的是上午。
3. risk_judgement_service.py:238/243:判断**与展示**都换算。展示不改的话,
风控专员看到的时刻与直觉差 8 小时,无法与客户核对。
4. risk_daily_report_service.py:89:日界改用 local_day_bounds(按北京时间自然日,
再折回 UTC naive)。原先按 UTC 日期切日,北京 08:00 前生成的日报统计窗口跨零点。
测试:
- 新增 tests/unit/core/test_timeutil.py(8 条),含"UTC 凌晨 0-6 点不是北京凌晨"
这一缺陷复现,以及"日界必须返回 UTC naive"。
- 改写 test_risk_scan_service.py::test_night_small_trade_boundaries:它原本就拿
UTC 小时构造数据(写 0 点/6 点),等于在测北京 08:00/14:00;语义一并修正为
北京 00:00(含)与 06:00(不含)两个边界,三个边界场景保持不变。
ruff / mypy(135 文件) / 603 unit+contract 全绿。
|
2026-09-11 12:50:52 +08:00 |
|
zhangshy
|
870fd0d44e
|
Merge branch 'RM2_develop' into qyqy_develop
|
2026-09-11 11:44:51 +08:00 |
|
zhangshy
|
6911dd98a7
|
fix: 修复证据上传审计并补充前端操作提示要求
|
2026-09-11 11:42:53 +08:00 |
|
zhangshy
|
aad3005830
|
Merge branch 'RM2_develop' into qyqy_develop
|
2026-09-11 11:25:24 +08:00 |
|
zhangshy
|
47056792eb
|
fix: 规范预警详情客户编号映射
|
2026-09-11 11:24:22 +08:00 |
|
zhangshy
|
1673816c4f
|
Merge branch 'RM2_develop' into qyqy_develop
|
2026-09-11 11:12:43 +08:00 |
|
zhangshy
|
c7c6736c54
|
fix: 修复日报模型装配并统一本地时间格式
|
2026-09-11 11:10:33 +08:00 |
|
lzf_0626
|
478b64e4d7
|
Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop_1
# Conflicts:
# app/service/agent/bootstrap.py
|
2026-09-11 10:43:08 +08:00 |
|
zhangshy
|
ecd0627396
|
feat: 调整通知接口字段并补齐风控配置清单
|
2026-09-11 10:35:24 +08:00 |
|
zhangshy
|
c2178a985d
|
feat: 增加风控定时扫描 Worker 与环境配置
|
2026-09-11 09:49:21 +08:00 |
|
lzf_0626
|
c369a919c6
|
test(customer-service): 用格式矩阵把 _topic_of 的第四、五口咬痕也堵上
方案 A:把每种出口的**真实产出**都过一遍 _topic_of。谁再改回答格式或加出口,测试立刻红,
而不是等客户实测先撞上——它已经咬了三次,三次都是客户先发现的。
写这个矩阵当场又抓出两个真 bug:
1. 整节块的主语带着章节号("2.1 南方季季盈90天")。下游 _product_risk_level 要求产品名
是该块标题或正文的**子串**,带编号就永远匹配不上——适当性查询会静默失败、退化成转人工。
这是真故障,不是洁癖。
2. 政策条款的主语是"第十二条 投资者与产品匹配矩阵";整节块去掉标题行后剩下的表格行
("| C1保守型 | ✅ 可购买 |")也会被当成主语。
修法:剥掉开头的章节号;排除以"|"开头的表格行;排除"第X条"开头的条款标题。
矩阵覆盖全部四个出口:知识直返(行级块/整节块/FAQ/政策四种形状)、适当性裁决(四种话术,
真调 _suitability_text 而非手写样例)、引导人工(兜底话术取不出主语是对的)、
闲聊(无产品实体,取不出也是对的)。
|
2026-09-10 22:53:29 +08:00 |
|
lzf_0626
|
8b1ed70916
|
fix(customer-service): 自述等级的说明行不能挡住产品名
实测连问「季季盈90天起投多少」→「C3 客户能买它吗」→「那我能买它吗」,第三问转人工。
原因是上一步刚加的客户自述等级说明("您提到自己是 C3。…以您在公司留存的测评结果为准")
占了回答首行,而 _topic_of 只看首行,产品名被挤到第二行,追问于是丢掉了指代对象。
这是 _topic_of 第三次因为"只认固定格式"而咬人(前两次:FAQ 的"问"字、适当性回答没有
冒号)。改成遍历回答前四行、逐行尝试解析,并把单行解析抽成 _topic_in。
同时补上自述等级时的依据说明:客户说"我是 C3"而系统答"您没有测评结果",在他眼里是
矛盾的,必须点明判断以档案为准、不以自述为准。自述等级只用于这一句说明,**绝不**参与
裁决。拒绝后的指引也从"联系客户经理或拨打热线"改成"请先完成风险测评"。
新增三条单测:自述等级识别(含小写)、自述说明行占首行时仍能取到主语、
"为 R2"开头说明前面没有产品名时返回空。
|
2026-09-10 22:48:56 +08:00 |
|
lzf_0626
|
c564494f84
|
fix(customer-service): 追问适当性结论时不能丢掉产品名
实测三连问:「季季盈90天起投多少」→「C1 客户能买它吗」→「那我能买它吗」,
第三问转人工。原因是 _topic_of 不认识适当性回答的格式——它那一行是
"南方季季盈90天为 R2(中低风险),与您当前的风险测评结果不匹配…",
既没有冒号也不是标题行,于是被判成"取不出主语",第三问就失去了指代对象。
补上这种格式:产品名在"为 R{1-5}"之前,且必须在冒号判断**之前**匹配(那一行没有冒号)。
以"为 R2"开头的行说明前面没有产品名,返回空串,而不是把等级本身当成主语。
修好后第三问给出与第二问一致的结论,这是对的:裁决是确定性的,同一个客户问同一只
产品,结论就该一样。新增两条单测钉住这两种情形。
|
2026-09-10 22:46:31 +08:00 |
|
lzf_0626
|
33fbb0eb01
|
feat(customer-service): 接入适当性裁决,回答"我这个等级能不能买它"
客户实测反馈:「c1客户能买它吗」答的是 C1 的通用规则,没回答"能不能买季季盈90天"。
根因是这个问题需要**组合两个事实**——产品的风险等级(R2)与客户档案等级能不能匹配——
而检索只能给出"最像的那段原文",给不出结论。基座早就有了 check_suitability 裁决工具,
接口留好了但客服没接线(这个项目里第三次遇到同一类情况)。
改动三处:
1. 新增 suitability_check 意图,意图码三处对齐(AgentDefinition.supported_intents、
agent_intent_config 的 active 行、发布版 agent_tools 白名单)。
2. 新出口 _answer_suitability:产品风险等级**从知识库查出来**(不猜、也不采信问句里
出现的"R2"字样),产品名只取上一轮回答里的主语(来自知识块字段,可信),客户等级
交给 check_suitability 按档案解析——**不采信客户自称**。任何一步拿不到确定值就转人工:
这个出口会给出"能不能买"的结论,宁可答不了也不能答错。
3. 发布脚本加意图注册与工具白名单,并做成幂等(重跑不会因为"已经审过了"而 409)。
实测:意图正确路由到新出口,裁决链路走通。9001 因为在 fin_risk_assessment 里没有测评
记录,系统给出"暂时无法购买 + 您目前没有在有效期内的风险测评结果"——这正是适当性管理
要求的行为,不是故障:不能卖给一个没有有效测评结果的客户。措辞也据此改过,不写
"您的等级为未记录"这种客户看不懂的句子。
顺带修了前端一处误导标记:它用"是否含客服热线"判断"已引导人工",而正常的适当性回答
里也会建议拨打客服热线,于是"已经给出结论"被误报成"已引导人工"。
|
2026-09-10 22:42:17 +08:00 |
|
lzf_0626
|
ee9de1b520
|
fix(customer-service): FAQ 型回答不能把"问"字当成追问的主语
给 C1-C5 补 FAQ 之后暴露的连带问题:FAQ 型回答的首行是"问:C1 客户能买什么产品?",
_topic_of 取冒号前的内容会拿到一个孤零零的"问"字,客户追问时检索词被污染成
"问 那它能买基金吗"。
改为"问"/"答"这类纯标签直接判为取不出主语,退化成只查当前这一句。
另附一条实测结论(**没有改代码**):在 FAQ 型回答之后追问「那它能买基金吗」仍会转人工,
这是安全且合理的——上一轮回答里没有可指代的产品实体,而"C1 能不能买基金"本身取决于
那只基金的风险等级,知识库没有、也不该有这种一概而论的答案。按金融场景的原则,
"答不了"好过"答错"。
|
2026-09-10 22:35:17 +08:00 |
|
lzf_0626
|
4c2b147793
|
feat(knowledge): 产品知识拆到表格行级,并按问句选粒度
问题(客户实测反馈):同一会话里问「季季盈90天起投多少」和「那它风险高吗」,两次回答
**一模一样**——都是整个产品小节的表格。客户问的是风险,收到的是整张说明书,看起来像
客服没听懂问题。
根因是切分粒度:原来"一个叶子标题 = 一块",产品手册里就是整个产品小节(表格 + 说明)
成一块。这既让两个不同的问题命中同一块,也让整节几百字的向量成了"整节的混合语义",
与"起投多少"这种具体小问题相似度天然偏低(实测该问句向量 top1 仅 0.6291,够不到 0.75
硬门槛,只能靠与次优的差值勉强通过)。
改动三处:
1. 切分:Markdown 表格的每一行额外生成一个**自解释**的小块("南方季季盈90天:起投金额
1万元"),挂在父块 doc_id 下(PROD-007-04),父块照旧保留。知识块 160 → 631。
效果:该问句的命中分从 0.6291 升到 0.869,命中的正是"起投金额"那一行。
2. 检索:命中行级子块时把它的整节父块一并带回(分数按 0.9 折算),供调用方按问句选粒度。
整节块保底占最后一个名额,且不参与 top1/top2 判定——实测它挤到第 2 位会把 gap 从
0.090 压到 0.076,几乎跌破 0.07 的转人工门槛。
3. 客服:命中的是行级子块时,看问句与子块标签是否真的对得上——「起投多少」对「起投金额」
对得上,用那一行;「介绍一下」对不上,换成整节。
过程中两次判据写错并已修正(都固化进了测试):用"含连字符"认子块时,整节块自己的编号
PROD-901 被误判成子块;用"不含两位数字后缀"认整节块时,FAQ 块全被误判成整节块排到后面,
把正确答案挤出 top1、害得「基金赎回几天到账」转人工。
验证:起投/管理费等字段问法给出聚焦的单行答案;"介绍一下"给出整节;FAQ 与政策问法不受
影响(换话题、指代追问等此前修好的场景复测通过);
ruff / mypy(113 文件) / 468 unit+contract / 29 integration 全绿。
|
2026-09-10 22:29:23 +08:00 |
|
lzf_0626
|
a6c09fa3c4
|
fix(customer-service): 检索问句只在客户这一句说不清楚时才带上文
实测的答非所问:同一会话先问「季季盈90天的起投金额是多少」,再问「基金赎回几天到账」,
第二问答出的是季季盈的产品介绍。原因是 _search_query 无条件把上一轮客户问题拼进检索词,
客户换话题时旧话题的检索结果被带了回来。在金融场景里这比"引导转人工"糟得多:客户问 A
得到 B 的答案会直接失去对客服的信任,而"答不了"至少是诚实的。
改为只在两种真正需要上文的情况下拼接:句子里有明确指代词("这个产品""该基金"),
或短到不构成完整意图("那它风险高吗"只有 6 个字)。指代词刻意不收单字"它/他"——中文里
"其他产品"会被误判,而这类短句已经由长度规则覆盖。
验证:换话题场景第二问回到 FAQ-0016 的到账时间,指代追问仍命中季季盈产品块;
ruff / mypy(113 文件) / 457 unit+contract 全绿。
|
2026-09-10 22:17:34 +08:00 |
|
lzf_0626
|
570493e71c
|
feat(knowledge): 知识检索增加产品名的字面兜底召回
问题:客户问「季季盈90天的起投金额是多少」会被引导到人工客服,而知识库里明明有答案。
实测根因不是阈值拍错了,而是专有名词在 embedding 空间里不占优势——该问句的向量 top1
只有 0.6291,够不到 0.75 硬门槛,只能靠与次优的差值勉强通过;而同一次查询用
title like "%季季盈%" 是唯一命中 PROD-007。既然客户已经说出了产品名,就不该再赌相似度。
做法(三条边界都是实测逼出来的,不是设想):
1. 只对产品集合做字面匹配。客户问「季季盈90天的起投金额是多少」与通用 FAQ 标题
「基金起投金额是多少?」有 7 个字连续重合;把 FAQ 纳入字面匹配会让它和真正的产品块
一起拿到满分、差距归零,反而又退化成"转人工"。
2. 字面命中只在向量结果不够确定时采用。客户问「基金赎回几天到账」时向量已给出正确答案
(FAQ-0016 得 0.8060),但手册章节标题「5.2 基金赎回流程」与问句也有 4 个字连续重合,
无条件采纳会把"操作步骤"顶掉客户真正问的"到账时间"。
3. 重叠门槛取 6 字而不是 4 字:"基金赎回"这类业务动作词正好 4 字,会骗过 4 字门槛;
产品名("南方季季盈90天")更长,6 字能同时保住产品名、挡住动作词。
未改动任何转人工判定阈值;VECTOR_CONFIDENT_SCORE 与 Agent 的 HIGH_SCORE 由单测锁定一致,
避免两处各自漂移出"谁都答不出来"的死角。
验证:季季盈类问法由"转人工"变为正确答出,基金赎回问法仍答 FAQ-0016;
ruff / mypy(113 文件) / 453 unit+contract / 29 integration 全绿。
|
2026-09-10 22:15:50 +08:00 |
|
lzf_0626
|
dbe7285c1c
|
feat: 短期会话记忆(多轮指代可解析)
一、此前的缺口
方案 §2.2 要求会话短期记忆,但底座**没有任何加载历史消息的代码**:conversation_message
存了全部消息、svc_conversation_session 只在计数,而 run 执行时只拿到当前这一条消息。
后果是客户问"那它风险高吗"时"它"无从对应,向量检索落到无关内容、整条回答走兜底——
多轮对话事实上不可用。
二、实现
1. 契约:AgentRequest 新增 `history: tuple[ConversationTurn, ...] = ()`(默认空元组,
既有构造点无需改动)。ConversationTurn 只保留 role 与正文,不把意图/置信度等内部字段
喂给模型——既减少噪声,也收窄"模型看到不该看的东西"的面。
2. 加载:WorkerRuntime._execute_claimed 构造 AgentRequest 时加载本会话此前的对话
(上限 10 轮,按 id 正序)。`before_message_id` 排除本轮请求消息本身,否则模型会在
上下文里看到自己的问题被重复一遍。
3. 使用:客服 Agent 构造检索查询时,把最近两轮**客户**消息与当前问题拼接。只取客户的
话、不取 Agent 自己的回答——把后者拼进来会让检索偏向自己上一轮的说法,而客户的真实
意图可能已经在下一句里被修正。
三、两个刻意的取舍
· **不引入 Redis 双写**:方案 §2.2 设想用 Redis 列表,但消息在受理时已落库,再同步一份
只会带来不一致与 TTL 管理成本,换来的仅是一次索引查询的节省。这里取等价语义
(同样"最近若干轮、超出即截断")而不复制存储。
· **按条数截断而非 token**:没有与模型一致的分词器,按 token 截断只能估算、边界会随实现
漂移;按条数是确定性的,宁可少给几轮,也不给一个不稳定的边界。
四、实测(同一会话两轮)
· 第 1 轮"南方季季盈90天的起投金额是多少" → 正确返回该产品表格(R2、起投 1 万元等);
· 第 2 轮只说"那它风险高吗"(不含任何产品名)→ 仍正确检索到同一产品并答出风险等级 R2、
业绩比较基准与投资范围;此前这类提问必然走兜底;
· ruff 通过、mypy 113 文件无错、unit+contract 447 passed。
|
2026-09-10 22:01:43 +08:00 |
|
lzf_0626
|
d4836ed4df
|
feat: 装配投影删除客户端(记忆失效/销户清理图与向量),并修复画像字段残留
一、装配 projection_cleaner
新增 app/service/projection_cleanup_service.py,并在**组装层**(app/worker/__main__.py)
注入。此前该客户端一直未装配,清理链路只记录降级(skipped_no_client),实际后果是
**销户后图里仍留着偏好关系**——投顾仍能通过关系网络看到这个人。
清理策略是"以画像为准"而不是按标识直接删边:先删掉该记忆对应的长期事实,再重建画像,
最后用对账修复让图跟着画像收敛。这样即使一条记忆影响多条派生边也能删干净,不依赖
"记得它当初投影成了什么"。Milvus 侧按 memory_uuid 删除;集合不存在(语义召回未启用)
时视为无需清理,客户端不可用则如实报告未清理,绝不伪造成功。
放在组装层而不是 runtime 内部兜底:组件内部给默认实现会把"尚未装配"这一事实悄悄盖住,
而"未注入即显式降级并留痕"是 runtime 刻意保留的语义。最初的改法写成内部兜底,被 5 个
既有单测拦下——那些测试是对的,因此改为在入口处注入。
二、顺带修复:事实消失后画像字段残留
实测:让 preference:horizon 记忆失效并清理后,user_facts 与图边都正确清除,但
fin_customer_profile.investment_horizon 仍是"长期(5年以上)"。根因是 rebuild_profile
只写"本轮有新值"的字段;事实被删后该字段没有新值,旧值就留在画像里——于是记忆已经作废,
投顾还能看到一个客户从未授权继续生效的投资期限。
改为:本轮没有对应事实时**清空**该字段;investor_type 例外——问卷是唯一权威,本轮没有
问卷记录时保持原值(该列 NOT NULL,且清空会在重测空档抹掉开户时的等级)。
三、实测
· 让 preference:horizon 失效后清理:cleaned=True,
detail=profile_rebuilt; graph_cleaned; vector_collection_absent;
· user_facts 2→1;图边由 ['HAS_GOAL','PREFERS'] 变为 ['PREFERS'];
· 画像 investment_horizon 由"长期(5年以上)"变为 None;investor_type 保持 C2、
risk_tags 不受影响(与 horizon 无关);
· 恢复记忆状态后重建,画像与图边均正确复原;
· ruff 通过、mypy 113 文件无错、unit+contract 447 passed、integration 29 passed。
|
2026-09-10 21:58:42 +08:00 |
|
lzf_0626
|
89f889b350
|
feat: 图投影对账(数据层)并补运维工具,第 2 步收尾
与既有 `ProjectionReconciliationService` 的分工(两者互补,不是重复实现):
· 那个服务做**事件层**对账:哪些投影事件还没投递、需要重放;
· 本次新增的是**数据层**对账:投递完成之后,图里的内容与权威画像是否一致
(投影漏投、记忆失效后未清理、图库故障都会造成漂移)。
实现 `ProfileGraphProjectionService.reconcile_customer`:
· 从 user_facts 与持仓算出"应有的边",从图中读出"实际的边",求差集得到 missing
(画像有、图里没有)与 orphaned(图里有、画像已无);
· 图是投影、MySQL 是唯一真相,因此差异一律**以画像为准**:missing 补写、orphaned 删除,
而不是反过来去改画像;
· `repair=True` 时修复,并**修复后重新核对**再回报——不凭"操作没报错"就宣布修好了;
· 删除边前对关系名做白名单校验:关系名读自图中既有边,属外部数据,
必须过白名单才允许拼进 Cypher。
新增 tools/reconcile_graph.py:支持单客户与 `--all`、可选 `--repair`,退出码可直接用于巡检。
顺带修掉一处日志噪音:读边时原用 `coalesce(t.tag_key, t.product_code, ...)`,会引用当前
图中尚不存在的属性名,Neo4j 每次执行都抛 UnknownPropertyKeyWarning,把日志刷成噪音。
改用 `properties(t)` 后在应用侧按键取值,功能不变、日志干净。
实测(人为制造漂移再修复):
· 初始对账 一致=True、应有 2 条 / 实际 2 条;
· 删掉图中的 HAS_GOAL 边后 一致=False,缺失被准确报出;
· repair=True → 已修复=True、一致=True、缺失为空;
· 复验 一致=True,图中恢复 HAS_GOAL 与 PREFERS 两条关系;
· tools/reconcile_graph.py 单客户与 --all 均 EXIT=0,输出无警告;
· ruff 通过、mypy 112 文件无错。
|
2026-09-10 21:55:03 +08:00 |
|
lzf_0626
|
d7f6ef7ddc
|
feat: 记忆→画像→图全自动触发(含修复记忆内容覆盖失败的无符号列 bug)
一、自动触发
在记忆抽取 worker 里,记忆写入成功后**写一条 profile.rebuild_requested 事件**,由 Worker
的事件循环在下一轮消费,完成画像重建与图投影。
为什么不直接在抽取处调用:抽取时那条记忆还在**未提交**的事务里,另开 session 去重建画像
看不到它——实测踩到过:画像重建确实执行了、快照也多了一条,但新事实没进 user_facts、
画像字段没更新、图里也没多出关系。改走事件后,它只可能在本事务提交之后被消费,届时数据
一定可见,且与记忆写入共享事务边界(要么都留痕、要么都不留)。
链路因此变成:客户说话 → 记忆抽取 → 画像更新 → 图投影,全程无需手工介入。
runtime 侧新增 dispatch_profile_rebuild handler 与 relationships 注入点(与既有
projection_cleaner 同一模式);未注入或图库不可用时投影如实降级,不影响画像更新。
二、顺带修复:记忆内容变化会导致整条更新失败
实测触发:同一 memory_key 的内容从"约三年"改成"长期(5年以上)"时,
INSERT INTO memory_conflict 报 `1264 Out of range for column 'right_memory_id'`。
根因:memory_service._conflict_right_id 在"同一行原地更新、没有独立新值行"时返回
`-memory.id` 作为合成标识(注释写明了意图是与恒为正的自增主键不冲突),但库中
right_memory_id 是 `BIGINT UNSIGNED NOT NULL`,写负数被 MySQL 直接拒绝。
后果不是丢一条冲突记录,而是**记忆内容一旦变化、整条更新就失败**,
Worker 反复重试直至事件进入死信。
因基线字段不可变更(AGENTS.md 第 4 条禁止改动已有字段的类型),改为在无符号范围内的
高位取值 `2**63 + memory.id`:真实自增主键从 1 开始且远小于 2^63,因此该值必为正、
且必然不等于任何真实记忆行主键,原设计"左右不相等且不混淆"的意图完整保留。
三、实测结果(全程未运行任何手工脚本)
客户两条消息("投资期限约三年" → 改口"长期,五年以上")之后:
· memory_unit 2 行,horizon 记忆 version=2、conflict_count=1;
· memory_conflict 1 行,合成标识 9223372036854776037(= 2^63+229)合法写入;
· user_facts 2 行(事实自动提升,置信 0.95 过门槛);
· fin_customer_profile.investment_horizon 自动更新为"长期(5年以上)";
· profile_snapshots 4 个版本;
· Neo4j 自动出现 HAS_GOAL 关系;profile.rebuild_requested 事件为 published;
· ruff 通过、mypy 112 文件无错。
|
2026-09-10 21:52:20 +08:00 |
|
lzf_0626
|
7635014d9e
|
feat: 画像→图投影打通(按类型写标签,读写对齐;补删除客户端)
第 2 步(Neo4j)主体,三件事:
1. 新增 app/service/graph_model.py:图模型的**单一来源**。
节点标签与主属性名会被拼进 Cypher(Neo4j 的标签不能用参数占位),因此必须来自受控常量,
绝不能是调用方传入的字符串——否则就是 Cypher 注入;同时读服务与投影侧必须就"客户节点
长什么样"达成一致。此前两边各写各的(投影写 :Entity{entity_id}、读服务查
:Customer{customer_id}),结果写进去的关系永远读不出来。
另用 RELATION_SEMANTICS 补上"谁指向谁"的方向校验:底座已有关系白名单(8 种),
但白名单只约束关系名,不约束方向,这里补上,避免把 TRADED 写成 Customer→Tag。
2. 改 app/worker/graph_projection_worker.py:按 payload 的 source_type/target_type 写具体标签,
投影前校验关系方向。标签与属性名全部取自 graph_model,调用方传不进任意字符串。
3. 新增 app/service/profile_graph_projection_service.py:把画像事实投影成节点与关系,
并补上此前缺失的**投影删除客户端**(销户或记忆失效时 DETACH DELETE,同时清悬挂边)。
只投影 user_facts(与画像同源),不直接读原始记忆——否则同一件事在画像与图里会有两种说法。
图库故障一律返回 degraded 而不抛异常:投顾推荐可以暂时没有图,但不该因为图挂了
导致画像更新失败。
背景(核查发现):MemorySyncOutbox 与 GraphProjectionWorker 此前是**孤儿代码**——表建了、
worker 也实现了(含幂等、重试、死信、MERGE),但没有任何代码往 outbox 写、也没有任何地方
实例化这个 worker,整条图投影链路从未接上过。这正是图中只有 Neo4j 自带 Person/Movie
示例数据的根因。本次把"画像 → 图"这条链路接通;领域事件驱动的投影仍待接入(worker 已修好可用)。
实测验证:
· 投影客户 9001 → relations=1,随后 neighbors(PREFERS) 直接读到
{'tag_key': 'preference:risk_level=稳健型'} —— 写后读通,标签已对齐;
· delete_customer → 关系归零(degraded=False),重新投影恢复为 1,再次投影仍为 1(幂等);
· 图中标签为 Customer/Tag、关系为 PREFERS;早先探针残留的 Entity 节点已清理;
· ruff 通过、mypy 112 文件无错。
|
2026-09-10 21:43:39 +08:00 |
|
lzf_0626
|
75dff088d4
|
feat: 接通 Neo4j 图能力(驱动实现 + 装配),并实测确认图模型标签不一致
第 2 步(图库)的地基部分。
1. 新增 app/infrastructure/graph.py:GraphDriver 协议的 neo4j 实现。
这层此前是空的——relationship_service.py 里的 GraphDriver 只有 Protocol 声明、没有任何
具体实现,组装层也没有装配它,因此图读服务在运行期必然降级(neo4j_unavailable)。
这正是"Neo4j 连读适配器都没有"的根因。
降级取向与 Milvus 侧一致:本层不吞异常(由调用方决定降级方式),但构造失败返回 None
——图库不可用不该让应用起不来,也不该阻塞主链路。
2. bootstrap 新增 get_relationship_service():装配图读服务。
该服务只读,关系类型受 ALLOWED_RELATIONSHIPS 白名单约束;写入走 GraphProjectionWorker
(由领域事件驱动),这里不提供任意写接口——避免出现绕过事件链路的直写路径。
3. 实测确认一处既有缺陷(本次不改,方案待定):投影 worker 写入的节点标签是
:Entity {entity_id}(字符串属性),而 RelationshipService.neighbors 查询的是
:Customer {customer_id}(整数属性),两侧从未对齐,写进去的关系读不出来。
Neo4j 在执行读查询时直接给出三条警告佐证:未知标签 Customer、未知属性 customer_id、
未知关系类型 PREFERS——这也说明业务图从未被真正投影过(库中只有 Neo4j 自带的
Person/Movie 示例数据,共 5 个节点)。
验证:ruff 通过、mypy 110 文件无错;get_relationship_service() 返回 RelationshipService,
neighbors 与 paths 正常执行并返回空集(非降级),非法关系类型被白名单拒绝。
|
2026-09-10 21:40:00 +08:00 |
|
lzf_0626
|
962a0a116f
|
feat: 记忆→画像打通(事实提升 + 画像组装 + 版本快照)
补齐"记忆系统为画像服务"的断链,按 docs/23 的分层设计实现后三层。
1. 新增 app/model/profile.py:user_facts 与 profile_snapshots 的 ORM 映射。此前这两张表
只有结构、没有 Model,实际没有任何代码在用。两处表结构特例在 docstring 里显式标注,
避免后续有人按直觉写入踩坑:
· user_facts.id 无 auto_increment,主键必须由应用提供(本实现用微秒时间戳,单调递增);
· profile_snapshots.current_customer_id 是生成列(IF(is_current=1, customer_id, NULL)),
故意不映射——映射了反而会在写入时与之冲突。
2. 新增 app/service/profile_assembly_service.py,三段职责:
· 事实提升(中期→长期):evidence_count ≥ 2 或 confidence ≥ 0.90 才从 memory_unit
提炼进 user_facts —— 这条门槛就是"客户随口一说不能变成画像结论"的落地方式;
· 画像组装(长期→画像):按白名单映射进 fin_customer_profile,未列入白名单的事实
(如 profile:family)只进 user_facts,保证画像的信噪比;
· 版本留痕:每次重建写一条 profile_snapshots,generation_basis 逐字段记录来源,
用于回答"当时凭什么这么判断"。
3. 新增 tools/rebuild_profile.py:手工触发入口(单客户或 --all)。画像暂无自动触发,
这是目前唯一的重建方式,也便于排查"画像为什么没更新"。
红线由代码保证而非约定:investor_type 只从 fin_risk_assessment 最新一条读取,实现中
不存在任何记忆路径能写它。实测——客户 9001 问卷为 C2、对话自述"稳健型",重建后
investor_type 仍为 C2,自述信息进入 risk_tags 并标注"自述:"前缀。三方不一致保持可见,
但等级判定只认问卷,客户无法靠对话改变自己的可购范围。
另一处由实测修正的设计:fin_customer_profile 的 trade_account/real_name/total_asset/
behavior_score 均为 NOT NULL,说明画像行由开户流程创建(也印证了"注册时填问卷"是开户
前置条件)。原先"首次重建时创建画像行"的做法是错的——会写出一条假的开户记录,而画像
恰恰是风控要读的数据。已改为只更新已存在的画像,未开户时返回 reason=profile_row_not_opened
并如实报告,而不是静默成功。
同时新增 docs/23-记忆分层与画像设计.md:短期/中期/长期/画像四层各自存在哪里、谁写、
提升门槛、是否进画像,以及三条路径(问卷/行为/对话)在画像层汇合的设计。
验证:ruff 通过、mypy 109 文件无错;tools/rebuild_profile.py 对客户 9001 连续两次重建
产生 version=1/2 两条快照且 is_current 正确轮转(旧版本置 0)。
|
2026-09-10 21:36:23 +08:00 |
|
zhangshy
|
a94d5c754d
|
feat: 迁移奶龙风控业务模块与演示文档
|
2026-09-10 21:03:44 +08:00 |
|
lzf_0626
|
1fa5fc7d03
|
refactor: 客服回答正文只保留固定免责声明
业务方要求客户侧只看到一句固定话术(不构成投资建议),因此从回答正文移除:
1. 中置信的「(以上信息可能不完整,具体以产品说明书与公司制度为准)」提示;
2. 「(依据:…)」出处行——原先它显示的是知识块标题,FAQ 的标题就是问题本身,
展示为「(依据:公司什么时候成立的?)」并没有可读价值。
可追溯性不受影响:本次命中哪个知识块仍由审计(agent.tool_executed 的工具调用记录)
与消息表留痕,source_references(tool 类型)也照常返回,只是不再面向客户展示。
取舍已在代码注释中标明:中置信回答此后不再向客户标注不确定性。
若将来要把出处展示给客户,应当走 source_references 的 knowledge 类型
(前提是让 ToolExecutor 把工具返回的 doc_id 登记为本次可引用来源),
而不是继续往正文里拼字符串。
同时移除因此不再使用的 INCOMPLETE_NOTICE 常量、_source_note 方法,
以及提问工具里那句"正文没有依据行"的提示。
验证:ruff 通过、mypy 107 文件无错;customer_service_check 9/9 通过;
ask_customer_service 实测回答正文为「答案 + 免责声明」两行。
|
2026-09-10 20:33:13 +08:00 |
|