qyqy_develop_1
qyqy_develop
合并 qyqy_develop_1 的第二轮工作:
评审方式:4 个并行评审(架构接入 / 数据层与数据库基线 / 业务逻辑正确性 / API 规范), 关键结论由本人逐条核对代码或实测数据库复核。报告用三级标记区分可信度: ✅ 已亲自核对、🔁 两位评审独立发现同一问题、⚠️ 评审提出但未逐条复核。 总体结论:骨架合规(继承 BaseAgent、只实现 handle、工具统一走 call_tool、ORM 与数据库基线 逐列吻合且未改动任何已有表、全仓无字符串拼 SQL、权限默认拒绝、repository 严格只读), 问题集中在三条断线和一批业务正确性缺陷。 P0:意图配置与工具白名单一条未发布(实测 DB 确认)。白名单失败关闭 ⇒ 风控当前跑不起来。 P1:模型调用绕过基座的 generate_with_model;risk chat 的 task_type 未注册 capability, 当前能用只因 deepseek-flash 恰好排在端点表第一行;邮件端点无权限校验(默认关闭, 但开启 SMTP 后即为未授权邮件发送器);Agent 在 handle 里直写 ai_analysis 且读-改-写 无并发控制;时区口径不统一——定时规则把 UTC 0-6 点当"凌晨",实际判的是北京时间 08:00-14:00,会持续误报。 P2:日报误报原因恒为"未填写"、研判漏阈值条件、通知失败被静默吞掉、定时扫描重启后不再 执行、一条脏数据中断整批等 10 项。 P3:分页元数据层级、游标未绑定用户与查询条件、无 limit 全量查询、索引失效、写操作无幂等、 错误码超表、SSE 未协商 Accept、接口未登记 docs/05 §19。 另记录一项需要业务方裁定的制度冲突:适当性指南第十二条矩阵允许 C1 购买 R2,第十四条却 要求投资者等级 >= 产品等级,两者对同一情形结论相反(已核对原文)。
修 docs/25 里的 P0:风控的意图配置与工具白名单一条都没发布,而白名单是失败关闭的, 导致任何工具调用都被拒。按组员交付的《20-Agent工具白名单与意图配置》补齐。 1. tools/publish_risk_agent_config.py:导入 4 条 risk 意图配置(id=61-64,active)与 4 条 agent_tools 白名单(risk_overview / risk_search / risk_evidence / general)。 发布版本 id=188 active,并且**继承了现有 5 条配置项**——config_release 是整版本替换 语义,不继承会把客服的 4 条白名单和示例 Agent 的 fund_query_demo:fund_quote 静默清空。 2. tools/grant_risk_alert_read_permission.py:补齐 risk:alert:read 权限。 实测发现这条权限在 sys_permission 里**根本不存在**,连 risk_operator 角色也没有, 所以任何身份调用风控工具都会拿到"缺少工具权限"。交付文档第 116 行正把这一项列为 接入前置条件。权限匹配实际用 permission_code 全串(identity_repository.py:25-37), resource/action 只是元数据(照 fund:quote:read 的拆法);data_scope 取 all,因为 risk_query_service.py:194、risk_analysis_service.py:126 等按 context.data_scope == "all" 决定是否放行全量数据。只授权 risk_operator,不动 admin(交付文档只要求前者)。 验证(以 9002 risk_operator 身份实测): - "查看当前风险概览" → status=succeeded、意图 risk_overview、工具真实返回数据 - "查询高风险预警" → status=succeeded、意图 risk_search 修复前两者均为 failed + ForbiddenAgentError: 缺少工具权限。
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 全绿。
实测发现: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 身份行为不变。
接 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 全绿。
基座层面的两处缺陷,都属于"静默失败"——排查成本高,且本项目已经各踩过一次。 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 全绿。
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 全绿。
上一轮加了"激活时点名将被丢掉的配置项"之后,我只用 caplog 验证了方法本身, **没有跑过真实激活**——这一步把缺口补上。 tools/verify_config_drop_warning.py 走完整的"创建 → 加配置项 → 提交复核 → 审核 → 激活" 状态机,发三个版本: - A:把当前生效配置项原样复制 → 一条不少,无告警(不产生噪音) - B:去掉 agent_tools/risk:general → 告警 1 条并**精确点名**该配置项 - C:把完整的那份再发一次 → 恢复原状,无告警 跑完校验生效配置项与起点一致,避免把环境留在"少一条"的状态。 实测结果:A 告警 0 条 / B 告警 1 条且点名 agent_tools/risk:general / C 告警 0 条; 起点与终点均为 9 条配置项,环境已复原。
**为什么造探针**: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 全绿。
接上一轮(分支 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 的更窄 —— 探针目前两者的角色集合相同,触发不到。
分支 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。
**问题**:上一轮加的"配置丢失告警"只比对 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 全绿。
**背景**:提示词配过(挂在 release 174),但 174 已被取代,而 load_active_prompt 是先定位 active 版本、再按 release_id 查的 —— 于是读不到,Agent 回落到代码默认值。功能看着正常 (_chitchat_prompt 有逐字段兜底),所以一直没人发现,也没有任何告警。 发布脚本原先有两个坑,这次一并修掉: 1. **version 写死为 1**。prompt_template_version 的唯一键是 (prompt_code, version), 客服那条已经占了 v1,照搬旧行会主键冲突。改为取现有最大值 +1(本次自动分到 v2)。 2. **继承只读 platform_config_item**。改用 ConfigReleaseService.effective_snapshot(), 它覆盖全部三张受管表;并且做**字段名映射**(库的 config_key → API 的 item_key、 value_json 归一化)—— 快照行是库的形状,直接 POST 会 422。 实测: - 发布走路径二(路径一如预期被状态机拒:409 RUN_NOT_CANCELLABLE「只能修改草稿发布版本」) - 新版本 201 继承 9 条配置项、一条没丢;提示词 v2 随之生效 - load_active_prompt 现在返回 release_id=201 / version=2(此前为 None) - 闲聊链路:status=succeeded、intent=chitchat,回答「您好,我是南方科技智能客服, 想了解基金、理财还是账户服务?」—— 简洁、自然引导到业务,符合提示词要求 提示词正文与代码默认值**刻意保持一致**:发布前后行为不变,变的只是"能不能改" (改话术从此要经审核并留痕)。Agent 侧仍保留代码默认值作为兜底。 ruff / mypy(136 文件) / 612 unit+contract / 29 integration 全绿。
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 全绿。
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 全绿。
**发现**(由"给邮件端点加权限"这个任务引出来的):风控 service 层声明了四个权限码, 而 sys_permission 表里只有一个。 | 权限码 | 用途 | 原先状态 | |---|---|---| | risk:alert:read | 查询 / 研判 / 日报 / 通知 | 已存在,正常 | | risk:alert:write | 处置 / 证据归档 | **不存在** → 6 处调用全被拒 | | risk:alert:scan | 规则扫描 | **不存在** → 扫描端点调不了 | | risk:report:mail | 日报邮件 | **不存在**(本次新增) | 失效表现是 ForbiddenAgentError,而**只读查询一切正常** —— 所以很容易以为"风控能用", 直到去点处置按钮才发现。这与 risk:alert:read 当初缺失是同一类问题,只是面更广。 **改动**:把早先那个只建一条权限的脚本改造成覆盖四个,并授权给 risk_operator 与 admin (风控 Agent 的 allowed_roles 两个都声明了,只给其中一个会让另一个"声明了却用不了")。 data_scope 取 all —— 风控要处理全部客户的预警,且多个 service 按 context.data_scope == "all" 决定是否放行全量。脚本更名为 grant_risk_permissions.py。 **实测**(9002 risk_operator): - POST /risk/alerts/scan → **200**「规则扫描完成」(原先必被拒) - POST /risk/alerts/ALDEMO0002/acknowledgements → **409**「只有待处理预警可以确认接收」 —— 不是 403,说明**权限已通过**、卡在业务状态(该预警是"调查中"),属正当拒绝 - 对照组:customer 调扫描 → **403 缺少操作权限**,边界未放松 顺带发现:这个 409 复用了错误码 RUN_NOT_CANCELLABLE,语义不符 —— 属 docs/25 里已记的一项。
**问题**: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 全绿。
复核 P1 #5 时发现 .with_for_update() 就在 isk_analysis_service.py:121-125, 而初次报告只读到 :130-160、恰好漏看了锁所在的那几行,却把这条标成了"✅ 已验证"。 实测推翻(并发发起三种分析,各自独立 session —— 复用同一个 session 会天然串行、 测不出竞争): - 预警研判 1809 字 / 回访话术 2030 字 / 工单摘要 1088 字,三个请求全部成功 - 落库 ai_analysis 的键:['回访话术', '工单摘要', '预警研判'] —— **三个都在,未发生覆盖** 所以 P1 #5 只剩前半条(审计不经基座统一链路)仍然成立,并发那条撤回。 教训一并写进文档:标注"已验证"之前必须确认自己真的读到了关键那段代码, 否则标注本身就是误导 —— 这与 _topic_of 那几轮踩的是同一个坑。
复核时发现原修复建议("改走 ModelGenerationService,或用 self.generate_with_model") **前提不成立**: - OpenAICompatibleGateway.generate(endpoint_code, prompt, timeout_ms) -> str (model_gateway.py:57)与 ModelDispatchService.generate(endpoints, prompt) -> ModelExecution (:248)都是"给一段 prompt、拿一段文本";ModelExecution 的字段只有 (endpoint_code, text, attempts, degraded)(:226-231)。 - 而风控需要的是 **messages 数组 + tools(function calling)+ 解析 tool_calls**, 基座完全没有这种形态的入口。 所以这不是"绕过",是基座缺能力时的补位,而且补得规矩:端点选择直接用基座的 DatabaseModelEndpointResolver;降级策略与 ModelDispatchService.generate 一致 (顺序尝试 + max_attempts 默认 2);错误映射也复用基座同一套 UpstreamTimeoutError / DependencyUnavailableError。 仍成立的两点:① bootstrap.py:262-265 的 lambda 让 model_client 注入点形同虚设, 无论走哪条路都该改;② HTTP 调用逻辑与 gateway 重复了一份,gateway 将来的限流/成本统计 风控享受不到。 **正确的修法比原建议大**:给基座**新增** chat(messages, tools) 形态的入口 (ModelGateway → OpenAICompatibleGateway → ModelDispatchService → ModelGenerationService → BaseAgent),再让风控改用。因为只是新增方法、不改现有行为,对已有 Agent 零影响; 但涉及基座核心链路,是否做需要项目方定,**本轮未实施**。
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 全绿。
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 全绿。
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 全绿。
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 全绿。
原描述把三件事混成一条,逐项复核: **a. exclude 不要求"调查中"** —— 这是**业务规则**,不是技术缺陷。exclude 要求 "已确认 + 未闭环"(:63-66),resolve 额外要求"调查中"(:90);不对称是事实,但 "误报关闭是否必须先经调查"属于业务裁定,代码里那两道门是有意设置的,看不出实现偏差。 需业务方定,不由技术侧单方面加门禁。 **b. min(20, score_before)** —— **误报**。BEHAVIOR_SCORE_INITIAL = 20 是**满分**(:18), 扣分表 {"低":3, "中":5, "高":20}(:19)与之自洽(高危扣满归零),所以这个 min 是把越界 数据拉回合法上限的数据清洗;而且它**不是静默的** —— 审计里同时记了 behavior_score_before / deduction / after(:118-120)。原描述"静默改写"不成立。 **c. 真缺陷(原报告没写)**:ehavior_score 的初始值是 0 而不是满分 20, **导致扣分机制整体失效**。实测:fin_customer_profile.behavior_score 定义为 int NOT NULL(无默认值),现有画像 customer_id=9001 的值是 **0**。 于是 :103 的计算恒为 min(20, 0) - deduction = -deduction → max(0, ...) = 0: **扣分永远扣不动,行为分恒为 0**。且全仓只有 :109 一处给 behavior_score 赋值 (写入方只有风控结案),说明初始 0 来自插入画像时的显式赋值,没有任何地方初始化成 20。 影响:行为分是"预警结案 → 客户行为评分下降"这条链路的落点,现在这条链路**产出为零**。 修复点在**画像创建侧**(初始值应为满分),不在风控的扣分逻辑里。
上一轮我把"behavior_score 初始值为 0"记为**真缺陷**,并说"修复点在画像创建侧"。 继续查后发现前提不成立:**本项目不创建 fin_customer_profile 行**。 证据:全仓搜索 FundCustomerProfile( 只命中类定义(app/model/fund.py:296)与测试文件; 没有任何 INSERT;grep in_customer_profile 在 app/ 下只有类定义与一处注释; profile_assembly_service 走的是 ORM 读取 + 属性更新,行不存在时返回 profile_row_not_opened(说明它也不创建行)。所以画像行由**本项目之外的流程**写入, 是它把 behavior_score 设成了 0。 本项目的扣分逻辑本身是对的:min(满分, before) - deduction 再 max(0, ...) —— 给定 0, 任何扣分都只能得 0,这是算术必然,不是判断错误。 因此改为"上游数据前提缺失",并给出处置建议:① 上游创建画像时按满分初始化; ② 若上游改不了,则需要一个能区分"未初始化"与"扣光了"的标记(例如新增列), 但仅为这个目的加列的成本收益需要权衡。 **在拿到上游口径之前,本项目不做任何"见 0 就补 20"的处理** —— 0 同样是合法的扣分结果, 那样会把真正扣到 0 的客户错误地抬回满分。
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 全绿。
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 全绿。
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 仍有效、 不同证据类型游标不互通。
#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 例)。
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 只调用一次处置逻辑"。
预警详情里的资金流水/持仓/登录记录、以及日报的四组预警都是"一次性取全量"。某个客户 的历史数据一旦异常膨胀,单次响应就能把内存和连接拖垮;登录记录此前更是连时间窗都 没有。 处理原则是"封顶但**不静默**":详情证据每类最多 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 同步登记两个新字段与写接口的幂等要求。
实测确认 #21 描述准确:fin_risk_alert 只有 11 个普通 BTREE 索引 + 主键 + alert_no 唯一键,规则命中的 JSON_CONTAINS 查询 EXPLAIN 为 type=ALL、possible_keys=NULL, 即全表扫。MySQL 8.0.27 支持多值索引,故新增迁移 20260911_risk_rule_index: ADD INDEX idx_fin_risk_alert_trigger_rule_codes ((CAST(`trigger_rule_codes` AS CHAR(16) ARRAY))) 迁移幂等(先查 information_schema.STATISTICS),upgrade/downgrade 往返已验证。 生效后 EXPLAIN 变为 access_type=range 且 key 命中该索引,原始证据留档在 docs/evidence/risk-index-probe.json(由 tools/probe_risk_index.py 生成,只读探查)。 只解决一半,另一半如实记为限制:若干 like(f"%{keyword}%") 全表扫无法用 B-tree 索引, 根治需全文索引 + 中文分词组件(部署依赖),本轮不做。 revision 名刻意压到 32 字符以内 —— alembic_version.version_num 是 VARCHAR(32), 超长会在写版本号时报 1406,而 DDL 是非事务的,那时索引已经建好了。 docs/25 追加"P3 处理结果"表,逐条登记 17-25 的状态:#19 是协议级重做(keyset 分页) 不单方面改,#21 部分修复,#25 前半段不成立,其余已修。
一、第十五条豁免规则(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 的豁免额度条件与研判口径。
docs/24 是客服阶段的总结,正文保留为当时的事实,本次做两件事: 1. 刷新"当前状态"表的数字:mypy 113→137 文件、unit+contract 497→664、 integration 29→33、active 发布 181(5 项)→201(10 项:9 条 agent_tools + 1 条客服 闲聊提示词)。数字由 tools/probe_release_state.py 只读查库得到,证据留档 docs/evidence/release-state.json。 2. 追加第七节"后续更新",记录阶段总结写完之后的变化:两件待决策事项已落地(闲聊 提示词已按继承语义发布、适当性两侧口径复核通过)、风控合并后的处理要点、以及 三个仍然开着的口子(热线占位符、like %% 全表扫、offset 游标并发跳行)。 登录端点:**不改**。docs/05 §11 明确"JWT 签发、刷新、注销由统一身份认证模块负责, Agent 平台不重复实现",本平台只做校验(app/core/security.py)。第五节第 8 条据此改写, 同时把第 7 条"风控尚未启动"标记为已完成。 顺带修正"已知缺口"里的一条:列表接口的 next_cursor/has_more 风控侧已补齐,客服侧仍待办。
No dependencies set.
The note is not visible to the blocked user.
合并 qyqy_develop_1 的第二轮工作:
基座层面的两处缺陷,都属于"静默失败"——排查成本高,且本项目已经各踩过一次。 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 全绿。**为什么造探针**: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 全绿。分支 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。**问题**: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 全绿。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 全绿。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 全绿。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 全绿。原描述把三件事混成一条,逐项复核: **a. exclude 不要求"调查中"** —— 这是**业务规则**,不是技术缺陷。exclude 要求 "已确认 + 未闭环"(:63-66),resolve 额外要求"调查中"(:90);不对称是事实,但 "误报关闭是否必须先经调查"属于业务裁定,代码里那两道门是有意设置的,看不出实现偏差。 需业务方定,不由技术侧单方面加门禁。 **b. min(20, score_before)** —— **误报**。BEHAVIOR_SCORE_INITIAL = 20 是**满分**(:18), 扣分表 {"低":3, "中":5, "高":20}(:19)与之自洽(高危扣满归零),所以这个 min 是把越界 数据拉回合法上限的数据清洗;而且它**不是静默的** —— 审计里同时记了 behavior_score_before / deduction / after(:118-120)。原描述"静默改写"不成立。 **c. 真缺陷(原报告没写)**:ehavior_score 的初始值是 0 而不是满分 20, **导致扣分机制整体失效**。实测:fin_customer_profile.behavior_score 定义为 int NOT NULL(无默认值),现有画像 customer_id=9001 的值是 **0**。 于是 :103 的计算恒为 min(20, 0) - deduction = -deduction → max(0, ...) = 0: **扣分永远扣不动,行为分恒为 0**。且全仓只有 :109 一处给 behavior_score 赋值 (写入方只有风控结案),说明初始 0 来自插入画像时的显式赋值,没有任何地方初始化成 20。 影响:行为分是"预警结案 → 客户行为评分下降"这条链路的落点,现在这条链路**产出为零**。 修复点在**画像创建侧**(初始值应为满分),不在风控的扣分逻辑里。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 全绿。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 仍有效、 不同证据类型游标不互通。#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 例)。