lzf_0626
|
552055ee2d
|
fix(portal): 修风控预警列表 422(limit 上限是 5,改为不传);运营邮件改用 page/page_size;审计 limit 钳制到 1-100
- risk/alerts 传 limit=20 会 422 query.limit<=5,导致整张表格渲染不出来、
行内确认/升级/解决按钮随之消失 —— 这正是验收清单 3-3/3-6~3-9 看不到功能的原因
- 实测列表返回 2 条预警;处置接口有状态前置条件(409 = 当前状态不允许)
- 清单 3-3/3-6 更新为实测结果并写明 limit 上限与状态机约束
|
2026-09-12 16:03:22 +08:00 |
|
lzf_0626
|
49ebda4cb7
|
docs: 新增前端验收清单(64 项,逐项给出预期结果,区分实测与按契约)
|
2026-09-12 15:48:31 +08:00 |
|
lzf_0626
|
2525fbdad9
|
feat(portal): 推广材料补成完整流程向导,并改为按 body.code 判成败
- 投顾视图:创建 -> 补结构化输入(含七项费率) -> 生成 -> 投递 -> 查询
生成后自动把 material_version_id 填进投递框
- 管理员视图:新增推广材料审核(promotion:review 只给管理员,职责分离)
- 统一结果渲染改看 body.code:本平台业务失败也返回 HTTP 200
(生成失败是 HTTP 200 + code=422 + '材料内容未通过合规校验')
- 输入骨架刻意不预填业绩(performance_info 有 show_* 开关可关),
费率填占位文本以便跑通流程;真实材料必须换成真实值
|
2026-09-12 15:45:09 +08:00 |
|
lzf_0626
|
615032ab00
|
fix: 修 POST /api/v1/conversations 的 MissingGreenlet(会话建不出来导致转人工 404)
- public_platform_service: 创建 ConversationSession 时显式赋值四个 server_default 时间列,
否则 flush() 后需回读数据库生成值,在 async session 里以同步属性访问触发
MissingGreenlet,接口 500,连带转人工一直报会话不存在
- portal: 客服改用 C001 真实建会话;转人工改传 reason_code/reason_detail(原 reason 属额外字段 422)
- portal: 投顾查客户投资目标走 customers/{id} 变体
- seed/grant: 补 investment-goal:{read,write,confirm}:customer 三个动态拼出的权限码
(data_scope=own_customers,投顾只看名下客户)
|
2026-09-12 15:37:36 +08:00 |
|
lzf_0626
|
14f5078491
|
Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop
|
2026-09-12 15:21:36 +08:00 |
|
lzf_0626
|
bfd622b592
|
feat: 统一登录门户(按角色分流五套工作台)+ 补齐权限覆盖缺口
- tools/portal.py:客户/风控/运营/管理员/投顾五个工作台,走真实登录与真实接口
- 新增 6 个此前从未建过的权限码(promotion:* 4 个 / financial:nl2sql:read / probe:read),
它们让推广材料与金融 NL2SQL 两条线对所有角色都是 403
- 投顾权限 10 → 25 项:补投资目标创建确认、agent:run、行情、知识检索、客户画像、推广材料
- 运营补 financial:nl2sql:read;create_test_user.py 不再硬编码角色 id(改按 role_code 查库)
- portal 的写请求补 Idempotency-Key 头(漏了会被 AGENT_INPUT_INVALID 拒)
- 新增 tools/check_permission_coverage.py 做权限对账
|
2026-09-12 15:21:34 +08:00 |
|
lzf_0626
|
4c00db5636
|
docs: docs/32 工具索引补上功能测试台与两个新守卫,修正已过期的权限号段口径
|
2026-09-12 14:57:56 +08:00 |
|
lzf_0626
|
c8d5fe11dc
|
feat: 新增平台功能测试台(自动覆盖 docs/05 §19 全部端点 + 一键只读冒烟 + 按身份测权限)
|
2026-09-12 14:57:17 +08:00 |
|
lzf_0626
|
c200a61cd5
|
docs: docs/32 更新到 2026-09-12 状态,补功能性测试的触发条件与外部依赖前置
|
2026-09-12 14:22:34 +08:00 |
|
lzf_0626
|
c8cdc06e80
|
Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop
|
2026-09-12 14:18:06 +08:00 |
|
lzf_0626
|
ffbcc229c2
|
fix: 删掉 NL 合并后残留的未使用变量 role_ids(ruff F841)
|
2026-09-12 14:17:42 +08:00 |
|
lzf_0626
|
4d8edb4b7f
|
chore: 号段一致性自检固化为 tools 脚本并纳入门禁;AGENTS.md 更新主分支口径、Agent/工具清单与测试基线
|
2026-09-12 12:29:20 +08:00 |
|
lzf_0626
|
4cc0ecdea9
|
fix: 种子改用 UPSERT 建用户且不覆盖密码,解除投顾 FK 导致的跑不通;记录 RBAC 对齐最终状态
|
2026-09-12 12:22:32 +08:00 |
|
lzf_0626
|
39f7b81200
|
docs: 记录本机 RBAC 对齐的实际执行结果,以及 seed_test_rbac.py 被投顾 FK 挡住这一既有破坏
|
2026-09-12 12:16:41 +08:00 |
|
lzf_0626
|
e0468a9d9f
|
docs: 记录 PR #7 合并门禁结果、两处顺手修正与权限号段冲突的来龙去脉
|
2026-09-12 12:10:03 +08:00 |
|
lzf_0626
|
cdb2de3e45
|
chore: 权限号段根治——9041-9046 并进种子;修正 grant 脚本与种子 9020-9034 的 id 映射冲突
|
2026-09-12 12:09:55 +08:00 |
|
lzf_0626
|
2bb056e516
|
fix: 修正合并进来的 test_security.py 两处密钥路径漂移(漏 dev/,标准布局下必红 2 个用例)
|
2026-09-12 12:09:55 +08:00 |
|
lzf_0626
|
441364437c
|
merge: 合并 ZSY 的客服 Agent 接入(访客身份、画像候选、转人工工单)—— PR #7
|
2026-09-12 12:05:25 +08:00 |
|
lzf_0626
|
c7226d0b69
|
回复 ZSY 的处置回执 v3:核实两处修正;更正端点编号计数(62 非 55);预检合并树并定位 test_security.py 两处密钥路径漂移
|
2026-09-12 12:04:18 +08:00 |
|
lzf_0626
|
ade5e0c085
|
回复 ZSY 的底座扩展确认 v2:逐行核验两句确认;指出 docs/05 端点编号撞车、三个新权限未随代码合并;补权限种子脚本
|
2026-09-12 11:46:47 +08:00 |
|
lzf_0626
|
244f03917b
|
回复 ZSY 的底座扩展确认;登记 docs/00 的一处已知偏差;更新 AGENTS.md 过期口径
## 对 ZSY 三件事的核实与裁决(docs/33-ZSY底座扩展确认-回复.md)
1. **访客身份**:`data_scope="public"` 我扫了全平台 26 个消费点,对未知值的行为一致
fail closed(6 处 `== "all"` 判假、1 处 `!= "all"` 会要求 customer_ids 非空否则 403、
1 处显式白名单直接拒),**安全,不用改**。但有一条硬约束必须先确认:全平台有
**72 处 `int(context.user_id)`,只有 3 处做了防御**——若访客的 sub 不是纯数字,
其中"权限被拒时要写审计"的路径会把本该 403 的情况变成 500。已要求他确认
sub 为纯十进制数字、且 visitor 分支复用同一套 `jwt.decode`(含 isdecimal 校验),
而不是另写第二个鉴权入口。
2. **客服 Agent**:`allowed_roles` 扩集合与确定性安全路由可接受;`recalls_customer_memory`
是新声明字段,已要求**默认值必须是 True**(否则会静默关掉所有既有 Agent 的记忆召回,
界面看不出来、只表现为回答变差)。品牌名(南方科技 → 奶龙基金责任有限公司)属业务
口径,已上报项目方定,不由技术侧拍板。
3. **current_customer_id**:**他的判断正确**。我独立实测 information_schema:
`EXTRA=''` 且 `GENERATION_EXPRESSION=''`,配合 baseline_generated.sql 无 GENERATED
子句、seed_profile_demo.py 的注释、profile_repository.py 用原生 SQL 显式写入,
四处一致 ⇒ `docs/00` 第 783 行"生成列"的描述是错的。处置:按真实 schema 映射成
普通可空列、**不改 docs/00**(规则 1)、**不补迁移**(DDL 本身正确,为让文档成真而加
生成列会改变既有列语义,违反规则 4)、但**必须登记这处偏差**。
另:他删了两个死代码文件(含一处硬编码 Milvus 字段名,违反 AGENTS.md §E),方向认同,
但要求他在 PR 里附上两条 git grep 的实际输出以证明"全仓唯一引用是它自己的单测"。
## 落实我在回复里承诺的两件事
- `docs/08-数据库结构审计基线.md` 新增"六、已知文档偏差",逐条登记上述偏差(含四处
证据与处置口径),并注明发现方式;
- `AGENTS.md` 环境口径新增 `MILVUS_LOCAL_URI` 的坑:配了会让健康检查与部分检索指向
本地 Milvus Lite 文件,出现"健康检查正常、实际查的是另一个库";并注明 `milvus-lite`
属本地开发依赖,应放 `optional-dependencies` 而非主依赖。
## 顺带更新 AGENTS.md 的过期口径
- 表数 68 → **89**(场内 51 + 场外/推广 17 + 投顾 21),并注明投顾那 21 张的登记文档待补;
- 测试基线从"1034 passed / 1 failed"改为实测值:ruff 干净 / mypy 228 文件 0 错 /
1207 passed 0 failed / integration 99 passed,并指向 docs/32;
- mypy 那条从"本机 181 错、双方不可比"改成可操作的判据:先对版本,根因是某一侧虚拟环境
没满足 pyproject 的 sqlalchemy>=2.0,<3 / mypy>=1.14,<2;并写明**不要装 sqlalchemy2-stubs**
(那是给 1.4 的,2.0 自带 py.typed,装了反而按 1.4 API 报一批新错)。
文档守卫:42 份无编号冲突。
|
2026-09-12 11:27:28 +08:00 |
|
lzf_0626
|
39f261da14
|
新增 docs/32-平台侧交接与联调准备.md
把当前状态整理成可交接的一份:分支与门禁、三条线合进来的内容、平台侧补齐的登录与
RBAC 只读接口、修掉的真缺陷、演示账号与环境口径、新增工具索引、联调清单、悬置决策项。
其中三条是这一轮实际踩出来的,值得留档:
1. 环境数据不随代码合并(config_release / sys_role / sys_permission / Milvus schema /
advisor_product_*)—— 三条组员线各自都在这上面栽过一次;
2. 合并后必须**单独**跑 alembic upgrade head 与 tools/check_authoritative_docs.py:
三家合并里两次带进文档重号、两次库与代码版本不一致(一次"版本号跑了表没建"、
一次"表没建版本号也没跑"),两者都靠 upgrade 收敛,重号只有守卫能发现;
3. 投顾演示数据里"方案"那步配不出来,缺的是**证据来源**(source_url / document_sha256),
必须来自真实的销售适当性披露或基金合同文件;不伪造这两个字段是关键红线。
另记下环境口径:mypy 与测试数必须带环境读,NL 那边 184 错与本机 0 错的差异根因是
对方的 .venv 没满足 pyproject 的 sqlalchemy>=2.0,<3 / mypy>=1.14,<2,不是代码质量问题。
文档守卫:41 份无编号冲突。
|
2026-09-12 11:23:46 +08:00 |
|
lzf_0626
|
0358ea519f
|
投顾演示数据准备脚本:配好测评与归属,方案那步如实报告卡在证据来源
投顾线合并后 21 张 advisor_* 表全空,导致 published 返回 []、投顾 customer_ids 为空、
客户画像因缺测评返回 403。本脚本按依赖顺序补能自己造的那几样:
1. 产品目录:调 tools/import_hq_test_products.py 取真实行情(实测 19 个 ETF/LOF);
2. 客户风险测评:给 9001 补一条 C5、有效期一年(answers 里注明是演示数据);
3. 客户-投顾归属:9001 → 9020(assigned_at 往前留 5 秒,避开 DATETIME(0) 舍入陷阱)。
**方案那步不配** —— 缺的是证据来源不是技术:authoritative_tradable_products 是
fail closed 的(Missing or unverified evidence excludes a product),而造
advisor_product_suitability_reference 必须带 source_url 与 document_sha256,也就是真实的
销售适当性披露 / 基金合同文件(import_product_governance_reference.py 的两个 CSV,
不在仓库里,属环境数据)。脚本**不伪造这两个字段**(证据链红线),只检查并打印真正的解法。
实测:cust_t 的 /users/me/memory-profile 由 403 变 200(测评生效);admin 视角看 9020 的
customer_ids = ["9001"](归属生效);advisor 的 published 仍为 [](符合预期,尚无方案)。
⚠️ 顺带发现一处口径不一致:画像 fin_customer_profile.investor_type 是 C2,而新补的测评是
C5 —— 前者决定画像展示、后者决定适当性裁决,两者会同时出现在界面上。演示前需要统一
(要么把测评改成 C2,要么把画像也改成 C5)。
|
2026-09-12 10:34:28 +08:00 |
|
lzf_0626
|
306a514316
|
修正登录测试台的探针:投顾那个按钮其实该是客户视角
/api/v1/advisor/recommendations/published 的语义是"**我(客户)自己**已发布的方案" ——
服务端按 `customer_id == 调用者 user_id` 过滤(权限码 `product-recommendation:read:self`
的 `:self` 正对应这一点),所以**投顾调它必然为空**:筛的是 customer_id = 投顾自己的 id。
把它标成 customer 角色,免得再用投顾账号点它、然后怀疑权限。
查证过程中确认了三处数据空缺(投顾合并后那 21 张 advisor_* 表是新建的):
- client_facing_content = 0 行(没有任何投顾方案)
- sys_customer_assignment = 0 行(投顾没绑定任何客户,故 customer_ids 为空)
- fin_risk_assessment 里没有 9001(客户画像的前置"完成开户风险测评问卷"未满足)
|
2026-09-11 22:28:32 +08:00 |
|
lzf_0626
|
636892c8b1
|
登录测试台支持投顾:快捷按钮 + 只读探针
- 快捷填充加 advisor_t / abc12345;
- 探针加「已发布投顾方案」(GET /api/v1/advisor/recommendations/published),按 advisor 角色启用。
实测同一接口的对照:投顾令牌 200(data: []),客户令牌 403 —— 权限边界正确。
|
2026-09-11 21:50:39 +08:00 |
|
lzf_0626
|
5018f11843
|
增加投顾(advisor)角色;修正登录测试的错误假设;修投顾带入的 2 处文档重号
## 投顾角色
投顾线合并后,bootstrap.py 有 10 处 allowed_roles 引用 advisor,
financial_nl2sql_service.py:272 还硬编码检查 {"advisor","operator","admin","super_admin"},
promotion_material_service.py:164 按 "advisor" in context.roles 走业务分支 ——
但 sys_role 里没有这个角色、sys_permission 里也没有投顾那 16 个权限码(种子只建到 9019)。
表现是所有投顾接口 403,而报错看起来像"权限配错了",实际是角色根本不存在。
- tools/grant_advisor_role.py:建 advisor 角色(id=9004,避开种子的 9001-9003 重建范围)
+ 16 个投顾权限(id 9020-9035)+ 授权(advisor 拿 10 项工作流、admin 补齐 16 项)。
只增不删、可重复执行、带 --dry-run。
- tools/create_test_user.py:ROLE_IDS 加 advisor。
- 先跑 alembic upgrade head:补 21 张 advisor_* 表,业务表 68 → 89,审计通过。
权限划分:投顾工作流 10 项(read:self / generate:self / review / publish)给 advisor;
治理类 6 项(product-governance:*、profile-governance:*、asset-allocation:backtest)只给 admin。
review/publish 也给 advisor,与既有决策一致(此前已裁定不做双人复核)。
验证:advisor_t 登录 200,roles=['advisor'] data_scope=all 权限 10 项;
用它查 RBAC 清单得 403(没有 audit:read),边界正确。
⚠️ 与种子的冲突:seed_test_rbac.py 是 DELETE 重建语义,其
DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099 会清掉本脚本建的权限。
要把投顾权限固化,应并进 seed_test_rbac.py 的 PERMISSIONS 常量。
## 修正登录测试的一个错误假设
test_issued_token_actually_works_on_a_real_endpoint 原本用客户的
/users/me/memory-profile 验证令牌可用,投顾合并后它返回 403。追下去发现**与令牌无关**:
那个接口对客户有业务前置"请先完成开户风险测评问卷",而演示客户 9001 没有测评记录。
是我的测试选错了验证端点,把"业务前置未满足"误判成"令牌坏了"。
- 改用管理员令牌调 /api/v1/admin/roles(需要 audit:read,走完整鉴权链路),
并补一条反向对照:不带令牌必须 401,否则那个 200 说明不了令牌有效。
- 把那个业务前置单独写成一个用例,让后来者一眼看到条件,而不是反复怀疑令牌。
过程里我先按控制台乱码猜了两次失败原因,都不对;最后把响应抓成 UTF-8 文件才看到真实
消息。教训记下:不要读乱码猜消息。
## 修投顾带入的 2 处文档重号
21-投顾Agent迁移TODO.md → 30-…、22-投顾Agent灰度与回滚操作手册.md → 31-…
(沿用 NL 那次让号的先例:既有文档更早、引用更多;且这两份新文档没有被任何地方引用。)
文档守卫:40 份无编号冲突。
验证:ruff 干净 / mypy 228 文件 0 错 / 文档守卫 40 份无冲突 /
unit+contract 1207 passed(0 failed)/ integration 99 passed / 业务表 89 张。
|
2026-09-11 21:49:46 +08:00 |
|
lzf_0626
|
c4a73b735e
|
Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop
# Conflicts:
# app/main.py
|
2026-09-11 21:41:04 +08:00 |
|
lzf_0626
|
e29fce4012
|
新增登录测试台:一个记事本级别的前端,用来在浏览器里验证登录
tools/login_console.py —— 浏览器打开 http://127.0.0.1:8099 即可测。
与 chat_console.py 的关键差别:**它走真实登录接口**(POST /api/v1/auth/tokens)拿令牌,
而不是自己签。chat_console 是当初还没有登录接口时的权宜做法,这个测的是真链路。
页面能做的:
- 三个快捷填充按钮(客户 cust_t/123456、员工 risk_t/666666、管理员 admin_t/88888888);
- 登录后显示用户、角色标签、data_scope、令牌有效期(令牌只显示前 24 字符);
- 按角色列出可调的**只读**接口按钮(我的画像 / 风控概览 / 预警列表 / 角色清单 /
管理视角看某人的身份),一眼核对"登录给的 roles"与"接口实际放行"是否一致;
- 把真实状态码与响应 JSON 原样摊在页面上,并写明 401 涵盖三种原因、429 是登录限流。
实现上不动 app/ 一个字节:内层用 create_app(),本进程只做两件事 —— 提供静态页、
把 /api/* 同源转发(httpx.ASGITransport 进程内调用,不起第二个服务)。同源转发避开 CORS,
也免得浏览器直连主服务;只监听 127.0.0.1,私钥始终留在服务端。
实测:页面 200;通过代理用 admin_t 登录拿到真实 JWT(sub=9003);错密码 401。
顺带说明一条边界变化:docs/24 当初拒绝"给底座加 dev token 端点",理由是那种端点等于把
**任意身份**开放给任何能访问服务的人。现在有了密码校验,浏览器拿令牌不再等于
"谁都能冒充任何人",那条顾虑已消除 —— 所以这个页面不是绕过安全设计,而是设计补齐后的正常用法。
|
2026-09-11 21:26:45 +08:00 |
|
lzf_0626
|
6812fbe317
|
B1:RBAC 只读查询接口(4 个)+ docs/05 登记 A035-A038
在此之前,权限只能靠脚本改,平台里**没有任何地方能看"谁能访问什么"**。
B1 先把"看得见"做出来:
- GET /api/v1/admin/roles 角色清单 + 权限数 + 在用人数
- GET /api/v1/admin/roles/{role_code} 角色详情
- GET /api/v1/admin/roles/{role_code}/permissions 权限清单(按权限码排序,
便于与各 Service 的 require() 对照)
- GET /api/v1/admin/users/{user_id}/roles 某人**实际解析出来**的角色/权限/数据范围
几个刻意的决定:
1. 权限码复用 `audit:read` 而不新增 `rbac:read`:这份清单本身就是审计材料,且复用是零数据
改动、立刻可用(新增权限码要先改 sys_permission,而它目前由 seed_test_rbac.py 以
DELETE 重建语义管理)。将来要细分再加,不冲突。
2. `/users/{id}/roles` 直接复用 IdentityService.resolve,不自己拼 SQL —— 那正是请求进来时
走的链路(status 检查、assigned_at/expires_at 时间窗、data_scope 取最高、客户分配)。
自己写一遍必然漂移,而"这里查出的权限"与"实际能用的权限"不一致比没有这个接口更糟。
测试里加了一条交叉验证:该接口的 roles/data_scope 必须与登录响应完全一致。
3. 停用账号返回**空权限集**而不是 404 —— 用户存在但拿不到权限,如实呈现比 404 更利于排障。
4. 角色详情与权限清单分开:权限为空的角色不该被误判成"角色不存在"。
5. 全部只读、不写审计(它们返回的就是审计材料本身),docs/05 §19 登记时审计列标"否"。
权限变更(提权/降权)仍无接口 —— docs/05 §19 已注明那不是遗漏,而是需要单独评审
(审计留痕 + 禁止自我提权 + 保护内置角色三条红线)。
另:确认了 load_context 对 sys_user_role.expires_at 是有过滤的(assigned_at<=now AND
(expires_at IS NULL OR expires_at>now)),我上一轮只读了半段 SQL 差点误报。
验证:ruff 干净 / mypy 185 文件 0 错 / 文档守卫 38 份无编号冲突 /
unit+contract 1140 passed / integration 98 passed(本批新增 8 个)。
|
2026-09-11 21:22:56 +08:00 |
|
lzf_0626
|
edd8c53c3a
|
修正 create_test_user 里写反的理由:sys_user_role 有唯一键,先清后插是为了支持改角色
|
2026-09-11 21:17:49 +08:00 |
|
lzf_0626
|
48513f3b97
|
docs/29 补第 0 步:转交前先确认对方环境有角色与密码(环境数据不随代码合并)
|
2026-09-11 20:50:59 +08:00 |
|
lzf_0626
|
b1429aa364
|
登录接口配套:加人工具 + 给组员的转交文档(docs/29)
- tools/create_test_user.py:一条命令建"可登录的测试账号"(用户 + 角色 + bcrypt 密码),
并用 IdentityService.resolve 打印**真实解析结果**。sys_user / sys_user_role 没有 ORM
模型、全靠裸 SQL,手写容易漏必填字段;更要紧的是 assigned_at 那个静默陷阱(见下)。
重复执行同一 --id 是覆盖语义,改角色也用它。
- tools/set_user_password.py:hash_password 改从 auth_service 取,消除第二份实现。
- app/service/auth_service.py:新增 hash_password,与 verify_password 放在一起,
让"写密码"和"校验密码"永远同一套算法。
- docs/29-Agent组员登录接口使用说明.md:给组员的转交文档(接口契约、加人步骤、
前端接入示例、常见问题、当前边界)。
文档里专门写清三条最容易踩的:
1. 登录用 username 而不是用户 id —— 演示账号是 cust_t / risk_t / admin_t,
不是 9001/9002/9003。这条不写明,联调时一定有人按 id 试。
2. sys_user_role.assigned_at 的 DATETIME(0) 毫秒舍入陷阱:落在未来会让账号
"登录成功但 roles=()",**不报错**。create_test_user 统一往前留 5 秒。
3. roles / data_scope 只用于前端分流界面,不是权限凭证 —— 鉴权每次请求查库解析,
所以权限变更立即生效,前端也不该拿它们做安全判断。
另:create_test_user 的 ON DUPLICATE KEY UPDATE 用 MySQL 8.0.19+ 的 `AS new` 别名语法,
避开已弃用的 VALUES()(实测本机 8.0.27 会打弃用警告)。
验证:文档守卫 38 份无编号冲突 / ruff 干净 / mypy 183 文件 0 错 /
登录集成测试 10 passed。
|
2026-09-11 20:50:41 +08:00 |
|
lzf_0626
|
01ee68034d
|
新增登录接口:账号密码换访问令牌(POST /api/v1/auth/tokens)
背景:客户 / 员工 / 管理员三种身份此前无法区分。但区分逻辑其实早就完备 ——
bootstrap.py 里各 Agent 的 allowed_roles 一直是分开的(CustomerServiceAgent 只要
customer、RiskAgent 要 risk_operator/admin、PlatformProbeAgent 只要 admin),
唯独缺"怎么证明你是谁";sys_user.password_hash 字段也一直存在,只是全是占位符
(种子写 'x'、worker 身份写 !worker-only-no-password-login!),从没写过真实密码。
实现:
- app/service/auth_service.py:bcrypt 校验 + 签发只含 sub 的 JWT + 审计。令牌里只放 sub
是刻意的:角色/权限/数据范围由 IdentityService 每次请求查库解析
(identity_repository.load_context),权限变更因此立即生效,现有鉴权链路一行未改。
- app/api/controllers/auth.py + app/api/schemas/auth.py:POST /api/v1/auth/tokens,
响应含 roles/data_scope 供前端决定进哪个界面(鉴权仍以库里实时数据为准)。
- tools/set_user_password.py:设密码(客户 123456 / 员工 666666 / 管理员 88888888)。
⚠️ 脚本与文档均标注"仅限演示环境",这三种弱口令上线前必须更换。
- pyproject / requirements 加 bcrypt(cryptography 只用于 JWT,不提供密码哈希)。
安全约定(逐条有实现与测试):失败不区分原因 —— 用户不存在/密码错/账号停用返回同一条
401,否则接口就成了账号枚举器;用户不存在时也跑一次 bcrypt 以抹掉时序差异;
成功与失败都写 interaction_audit(actor_id 可空正是为失败场景准备的);绝不记录密码。
过程中踩到一个自己挖的坑:给登录路由挂了通用的 enforce_rate_limit,而它声明依赖
build_request_context ⇒ 变成"要登录先登录",所有登录都 401。改为新增
enforce_login_rate_limit:按客户端 IP 独立限流(60 秒 10 次)、不依赖认证上下文。
集成测试据此调整:注入恒放行替身隔离跨用例的计数累积,同时保留一个恒超限用例验证闸门
确实会拦 —— 不能因为加了替身就把这道防线测丢。
接口登记:docs/05 §19 加 A034;并更新 §11 —— 那里原写"JWT 签发、刷新、注销由统一身份
认证模块负责,Agent 平台不重复实现",现注明平台只做登录这一步,刷新/注销仍归该模块。
验证:ruff 干净 / mypy 183 文件 0 错 / 文档守卫 37 份无重号(此前因 docs/21 重号失败)/
unit+contract 1140 passed / integration 90 passed。
|
2026-09-11 20:43:21 +08:00 |
|
lzf_0626
|
62501747a6
|
新增 sys_user 认证现状探查(判断"账号密码登录"可行性)
tools/probe_auth_state.py:只读统计 sys_user 的 status / user_type 分布与 password_hash
的格式特征(只输出前缀与长度,不输出完整值)。
结论(docs/evidence/auth-state.json):库里 5 个用户,password_hash 全是占位符 ——
4 个是 'x'(tools/seed_test_rbac.py 写入),1 个是 '!worker-only-no-password-login!'
(alembic 迁移写入的哨兵值,名字本身就表明"不用于密码登录")。
has_real_password_hash = false。
⇒ 在本项目做"账号密码换令牌"不是"加个端点"的事:没有密码可校验,等于要新建一套密码体系
(选算法 + 加依赖 + 定义写入流程 + 改上游数据),而 password_hash 是基线已有字段
(规则 4:不得改变既有业务含义)。docs/05 §11 也已明确该职责属统一身份认证模块。
顺带记录一个数据质量问题:user_type 取值不统一(employee / 员工 两种写法并存)。
|
2026-09-11 20:30:27 +08:00 |
|
lzf_0626
|
76e87a33a7
|
更新 AGENTS.md 的表数口径:51 → 68(场内 51 + 场外/推广 17)
合并袁聪那 17 张表后,本文件仍写"52 张含 alembic_version = 51 张业务表",与
tools/audit_schema.py 实测的 68 张对不上。改为写明构成,并指向
docs/28-场外与推广域数据表登记.md —— 否则下一个人看到 68 会误以为是有人绕过基线
偷偷建表(audit_schema 的期望值是从迁移动态推导的,不是手写清单)。
核验命令里的 .\.venv\Scripts\python.exe 一并改成 python,与本节"各用本机可用的那个"
的口径一致(架构师环境是 conda)。
|
2026-09-11 20:24:39 +08:00 |
|
lzf_0626
|
3029d0ca9f
|
更新 release-state 证据:画像工具白名单已补发(release 254 active)
|
2026-09-11 20:24:07 +08:00 |
|
lzf_0626
|
f09ea9e988
|
Merge origin/NL_develop:客服画像出口、知识管理三端点、合规语境与知识向量链路
NL 线(含其并入的袁聪场外/推广域)。唯一冲突是 .gitignore —— 双方都往同一区域加了
.workdir/,取对方版本(他的更完整,含 .tmp/ 与说明),顺带修掉我之前用
Add-Content -Encoding utf8 造成的编码混合(read 工具当时报 invalid UTF-8)。
合并后修的问题 —— 都不是"改别人业务逻辑",是让门禁能绿:
1. 缺运行依赖 python-docx。document_parser.py 解析 .docx 用它,但 requirements.txt 与
pyproject.toml 都没声明 —— 别人环境跑知识入库会直接
ModuleNotFoundError: No module named 'docx'。已补声明。
2. ruff 7 项:其中 tests/conftest.py 的 F821 Undefined name 'Path'(他的 tmp_path 修复
写了字符串注解 "Path" 却漏 import,运行时不求值所以没炸,但 mypy/ruff 会抓)、
tools/publish_customer_service_config.py 的 F841 inherited_keys 死变量(他改同 key
覆盖、换成 inherited_only 后忘删旧的)、3 处 E501,另 2 项 ruff --fix 自动修复。
3. 合规基线种子未跑:integration 的 test_compliance_seed_mysql 4 个用例要求
agent_negative_word 有 7 条 active 且已复核、agent_reply_template 覆盖 6 场景。
跑 tools/seed_compliance_baseline.py(11 条 active 规则 / 6 个场景模板)后 80 passed。
验证:ruff 干净 / mypy 180 文件 0 错 / unit+contract 1140 passed /
integration 80 passed / 表数 68(alembic 已在 20260911_merge_risk_heads)。
唯一失败 tests/unit/repository/test_fund_readonly_contract.py 是双方一致的既有缺陷:
它断言 Base.metadata 里的 fin_* 表集合,而实测为空集 —— 即该测试依赖别的测试先导入模型的
副作用,单独跑必失败。NL 方也明确"不修不报",此处照办,仅记录。
|
2026-09-11 20:22:59 +08:00 |
|
lzf_0626
|
b5b068065f
|
修掉合并带入的 3 个 mypy 错(全在袁聪的场外邮件模块,各一行、不动逻辑)
1. offsite_document_recognition_adapter.py:788 —— 冗余 cast。
`value in ("summary", ...)` 已经把类型收窄到那个字面量联合,cast 多余,删掉即可。
该文件另有 13 处 cast,删这一个不影响 import。
2. offsite_fund_service.py:1203 —— dict 不变型。
字面量里只有一个 bool,mypy 推断成 dict[str, bool | None],而 dict 是**不变型**,
不是 dict[str, object] 的子类型。运行时本来就是合法值,加一行显式标注即可。
3. offsite_fund_service.py:1240 —— 标注宽于实际。
`get_content()` 只定义在 EmailMessage 上,而调用方传的是
BytesParser(policy=policy.default).parsebytes(...) 的返回值(本来就是 EmailMessage)。
形参从基类 Message 改成 EmailMessage;import 同步替换(Message 仅此一处使用)。
三条都不影响运行:tests/unit/worker/test_offsite_mail_worker.py 的 6 个用例覆盖的正是
这条路径,修前修后都通过。修的意义在于——strict=true 下留着这 3 个错,mypy 在这个文件上
就失去价值,而 offsite_* 是刚合并进来、最需要类型检查兜底的新域。
验证:mypy 163 文件 0 错 / ruff 干净 / unit+contract 810 passed / integration 76 passed。
(另:本机 pytest 默认 basetemp 被权限占住的问题仍在,用重定向 TEMP 绕开,注意先建目录;
NL_develop 的 conftest 修复合并后可根治。)
|
2026-09-11 20:15:17 +08:00 |
|
lzf_0626
|
f60915b70b
|
补声明测试依赖 aiosqlite(合并袁聪线后暴露)
合并后 tests/unit/worker/test_offsite_mail_worker.py 的 6 个用例直接
ModuleNotFoundError: No module named 'aiosqlite' —— 它们走 SQLAlchemy 的 SQLite
内存库,而 aiosqlite 既不在 requirements.txt、也不在 pyproject 的 dev 依赖里
(dev 原先只有 pytest / pytest-asyncio / ruff / mypy)。
后果不是"本机缺个包":任何人 clone 下来、按文档装完依赖、跑测试,这 6 个用例必然
失败,而且报错指向 SQLAlchemy 内部(aiosqlite.py:480),看不出是缺依赖。
已加入 dev 依赖并注明用途。
同时记录一个本机环境问题(未改代码):pytest 的默认 basetemp
%TEMP%\pytest-of-Windows 被权限更高的会话占住,make_numbered_dir 扫不动 ⇒ 18 个
setup ERROR。NL_develop 的 conftest 修复(tmp_path 落到仓库内 .workdir/)能根治,
等合并;本次用重定向 $env:TEMP 临时绕开(--basetemp 无效,因为 pytest 仍会扫默认 root)。
另需注意 .workdir 目前不在 .gitignore 里,那套修复合并进来前要补上。
合并后现状:unit+contract 810 passed / integration 76 passed / ruff 干净 /
表数 68(alembic 已 upgrade 到位)/ mypy 3 个错(全在袁聪的 offsite_fund_service.py,
属真实类型问题,未擅自改别人的业务代码)。
|
2026-09-11 20:08:13 +08:00 |
|
lzf_0626
|
9957ddc191
|
Merge pull request 'NL_develop 评审答复与画像白名单补发脚本' (#6) from qyqy_develop_1 into qyqy_develop
合并 qyqy_develop_1 第四轮工作:NL_develop 评审答复、画像工具白名单补发脚本、docs/08 §8。
|
2026-09-11 19:58:29 +08:00 |
|
lzf_0626
|
8268646b0c
|
答复文档补充 §8:两个既有发布脚本会丢提示词(写补发脚本时发现)
publish_customer_service_config.py 与 publish_risk_agent_config.py 的
active_config_items() 都自己写 SQL、只查 platform_config_item,而
ConfigReleaseService.effective_snapshot() 要读全部三张受管表。用它们发版会把生效版本里的
提示词一起清掉 —— 且没有任何声音:_warn_dropped_items() 只告警不阻断激活,Agent 侧
_chitchat_prompt 有兜底会回落代码默认值,功能看着正常(与 release 174 那次事故同型)。
已在答复文档里建议改用 effective_snapshot(),并列出三个细节:同 key 覆盖(对方已修)、
JSON 列必须归一化(SELECT * 读出来可能是字符串,不解析会静默走进"没有该 key"分支)、
提示词搬家要重分配 version 且按 PromptPayload 字段挑(漏 input_schema/output_schema 即丢)。
另提醒时序:admin 端校验「配置 ⊆ 代码 allowed_tools」,画像工具那一版必须等代码合并后才能发,
否则 422 且报错只有"配置超出 Agent 工具上限"。
|
2026-09-11 19:52:08 +08:00 |
|
lzf_0626
|
6c09cdecbd
|
新增补发画像工具白名单的脚本(dry-run + 合并前防呆)
tools/publish_profile_tool_whitelist.py:把 query_customer_profile 加进
customer_service:faq,供 NL_develop 合并后在本环境补发配置。
关键设计都来自这轮评审核实到的事实:
1. 继承走 ConfigReleaseService.effective_snapshot(),一次拿全三张受管表
(platform_config_item / prompt_template_version / model_routing_rule)。
既有的两个 agent_tools 发布脚本都绕开它、自己写 SQL 只查第一张 —— 用它们发版
会把 201 里那条客服闲聊提示词一起清掉,而 Agent 侧有兜底、功能看着正常、没有告警
(这个事故在本项目真实发生过一次)。
2. 同 key 覆盖:继承项里的 customer_service:faq 必须被本次新值覆盖,否则旧值
["search_knowledge"] 会被 admin 端校验拦下、整次发布失败。
3. 提示词搬运重分配 version(唯一键含 version),并按 PromptPayload 的 9 个字段挑字段、
归一化 input_schema/output_schema —— 漏带就是静默丢失。
4. 前置防呆:admin 端校验「配置 ⊆ 代码 allowed_tools」(admin_service.py:219-220),
合并前跑必然 422,而报错只有"配置超出 Agent 工具上限"。脚本先查代码上限:
dry-run 给警告后继续预览,正式跑直接中止。
5. JSON 列归一化:SELECT * 读出来的 JSON 列可能是字符串,不解析会静默走进
"生效版本里没有该 key"的分支(本脚本第一版就是这么错的,诊断打印才定位到)。
实测:--dry-run 打印 9 条配置项 + 1 条提示词(v2→v3 重分配)与唯一变化;
正式跑因代码尚未合并而中止,未产生任何写入。
|
2026-09-11 19:51:44 +08:00 |
|
lzf_0626
|
d28ccdd88c
|
答复 NL_develop 第二轮:批准整改,纠正 mypy 归因,裁定 docs/00 不动
产出 docs/NL_develop评审回复-架构师答复.md(可直接转给对方)。三条核实结论:
1. docs/21 在我这边本来就重号(21-JWT密钥管理与轮换.md + 21-风控业务第二版迁移
清单.md),tools/check_authoritative_docs.py 当前就是失败的 —— 我此前只跑
ruff/mypy/pytest,没跑到文档守卫。对方的让号顺手修好了这个既有故障,
故批准 docs/26 与 docs/27 两处让号。
2. 对方的 mypy 归因需要纠正。他说主因是缺 SQLAlchemy 2.0 类型信息;我这边
mypy 1.20.2 + SQLAlchemy 2.0.52 + 未装 sqlalchemy2-stubs → 0 错,而
pyproject.toml 约束是 sqlalchemy>=2.0,<3 / mypy>=1.14,<2。SQLAlchemy 2.0 自带
py.typed,sqlalchemy2-stubs 是给 1.4 用的,装了反而按 1.4 的 API 报错(他自己也
观察到"还会换一批新错")。真实根因是他的 .venv 没满足 pyproject 约束。
3. 他的迁移警告对我不适用。我这边 alembic current = heads = 20260911_risk_rule_index
(单一 head,只有我自己的迁移),schema audit 报 51 business tables —— 即"表没建、
版本号也没跑",与他那边"版本号跑了、表没建"是两种不同的坏状态。合并后我必须补跑
alembic upgrade heads。
裁定:
- 补发配置(customer_service:faq 加 query_customer_profile)归我做;
- memory_sync_outbox / GraphProjectionWorker 消费端归我(生产者在他那边);
- 两处让号批准;
- docs/00 不动,另立文档登记场外/推广域 17 张表,并更新 docs/08 审计基线口径
(依据 AGENTS.md 规则 8:场外基金运营流程独立,不得写入场内交易表)。
|
2026-09-11 19:40:17 +08:00 |
|
lzf_0626
|
927a6fecc0
|
评审 NL_develop 交付说明:三个环境前提必须先对齐
产出 docs/NL_develop交付说明-评审意见.md(可直接转给组员),以及一个只读探查工具
tools/probe_knowledge_collections.py(Milvus 集合 schema 与行数)。
核查中发现的硬矛盾,都有可复核的证据:
1. 配置库不是同一个。对方说 active=216、合并前生效版本是 186;我这边实测 active=201,
最近 5 条 id 就是 201/198/197/196/195 —— release.id 自增,同库不可能一边 216 一边 201。
所以他以为已发布的 customer_service:faq=[search_knowledge, query_customer_profile]
在这边并不存在,而他的画像出口复用的正是这个 key ⇒ 合并后被 ToolExecutor 失败关闭。
同理他说的 fund_query_demo:fund_quote 缺失,我这边是存在的。
2. Milvus 也不是同一个。对方说现库字段是 knowledge_id/snippet、无 visibility、行数
106/177/73;我这边实测是 doc_id/content/chapter/section/.../visibility 共 15 个字段、
行数 125/297/214,且与 knowledge_search_service.py 的 OUTPUT_FIELDS 逐字一致。
他这次的字段映射改动(doc_id→knowledge_id)在这边会直接报 field knowledge_id not
exist,把客服知识检索整条打挂 —— 比他自述的"关闭 visibility 隔离"严重得多。
建议不是 A/B/C 三选一,而是第四种:运行时探测字段名,两套 schema 都能跑。
3. 解释器不同。他用 .venv(项目里不存在,那是他机器上的 gitignore 目录),约定是
D:\conda\envs\jr_py313。所以"mypy 151→181"跑不到本基线 —— 这边是 138 文件 0 错。
另指出:docs/26-JWT密钥管理与轮换.md 会与已存在的 docs/21-JWT密钥管理与轮换.md 重复,
建议并入 21;驳回删除 docs/04/06/10/13/99。
四项待裁决的答复:同意 agent_type 方案(要求补审计);字段映射改为运行时探测;
驳回删除编号文档;26 并入 21。
|
2026-09-11 16:51:52 +08:00 |
|
lzf_0626
|
5bbf8481ef
|
Merge pull request '信封补齐、适当性矩阵修正与 Worker 失败原因落库' (#5) from qyqy_develop_1 into qyqy_develop
合并 qyqy_develop_1 第三轮工作:meta 信封补齐与公共化、适当性按第十二条匹配矩阵、Worker 失败原因区分固定文案与异常消息。
|
2026-09-11 16:42:29 +08:00 |
|
lzf_0626
|
fcd3eaadd6
|
Merge origin/qyqy_develop:风控时间口径补齐 + Agent 截断提示
组员的 4 个提交(相对 c11e56c):
- 4b0c148 把我这边的风控 P3 收尾与适当性豁免额度合并进 qyqy_develop(PR #4)
- 2ebe438 docs: 增加风控 Agent 工具白名单与意图配置
- 5102eaa Merge origin/qyqy_develop into RM2_develop
- d2cdbba 修复风控时间口径与 Agent 截断提示
自动合并零冲突,两侧的改动是互补而非重叠:
- 时区:我在 list_alerts 做了 from_local,他补的是 list_evidence 与通知列表;
- 截断:我加了 data_truncated / evidence_truncated,他在 risk_agent 的 system prompt
里加了第 10 条 —— 这些标记出现时不得按全量证据下结论。方向是一致的。
验证:ruff 干净 / mypy 138 文件 / 703 unit+contract(+6,组员新增的用例)/
33 integration,均在合并后的工作树上跑过。
|
2026-09-11 16:33:55 +08:00 |
|
lzf_0626
|
8b8883ccca
|
Worker 失败原因不再只留类名:区分"可落库的固定文案"与"异常消息"
起因是查 Worker 运行状态时发现库里 373 条死信的 last_error 全是裸的 "ValueError"
(工具 tools/probe_worker_state.py,证据 docs/evidence/worker-state.json)。
dispatch(run not found)、dispatch_run_completed、dispatch_memory_extraction、
dispatch_profile_rebuild 抛的都是 ValueError,只记类名等于把"哪一处失败"也一起丢了。
但"直接存 str(exc)"是错的:tests/unit/worker/test_outbox_worker.py 那条
RuntimeError("credential=do-not-log") 断言异常消息不得落库 —— 它可能含凭据、SQL 或
客户标识。第一版改动就是这么写的,被这个测试当场拦下(这测试写得值)。
折中:
- 新增 OutboxHandlerError(继承 ValueError,这些失败本就是 ValueError 语义,保持
继承关系才不会改动既有的 except ValueError 行为与断言)。它的 reason 由代码写死、
不含任何请求数据,因此可以落库;
- safe_error_text:OutboxHandlerError → "类名: 固定文案"(截断 500 字符),
其余异常 → 仍只记类名;
- runtime.py 的 5 处 handler 失败改抛 OutboxHandlerError。
测试:新增"固定文案落库"用例;并把既有用例的断言收紧为 last_error == "RuntimeError"
(原先只断言"不含 do-not-log",太松,漏掉的情况测不出来)。
顺带产出 tools/probe_worker_state.py(只读):outbox / agent_run 各状态计数、按事件
类型分组、死信原因聚合。当前环境实测 pending 347、dead 373、published 410、
agent_run 无 queued/running。
门禁:ruff 干净 / mypy 138 文件 / 697 unit+contract / 33 integration。
|
2026-09-11 16:28:19 +08:00 |
|
lzf_0626
|
8d79bd9767
|
补齐三个成功响应缺失的 meta 信封;信封实现抽为公共(docs/05 §3.3)
"缺 meta"很容易被误判成"有":X-Trace-ID 是**响应头**(中间件加),和 body 里的
meta.trace_id 是两件事;错误响应一直有 meta(异常处理器统一加),漏的只有成功路径。
核到三个端点在把 service 的内部结构直接当响应体返回:
- GET /conversations/{session_id}/messages → 裸 {"data": [...]},meta 整个缺失,
游标也没地方放(§3.3 要求列表的 data 为纯数组、next_cursor/has_more 进 meta)
- POST /conversation-messages/{id}/feedback → 同样没有 meta
- GET /knowledge-references/{token} → 直接返回资源对象
改动:
- 新增 app/api/views/envelope.py,把 envelope / list_envelope 抽成一份公共实现,
风控链路改为复用它 —— 同一份契约写两遍的结果就是其中一处漏了 meta。
- ConversationService.messages 改为返回内部结构 {items, next_cursor, has_more},
用 limit + 1 判断 has_more:只看"取满没取满"会把恰好等于 limit 的最后一页说成
还有下一页。next_cursor 取本页最后一条的 message_id —— 游标语义是"取更旧的一页",
天然可续,集成测试本来就是这么翻页的。
- Controller 统一套信封,data 仍是数组、字段名不变,前端不需要改。
测试:新增 tests/unit/api/test_response_envelope.py,断言 set(body) == {"data","meta"}
(多或少一个顶层字段都会红),并覆盖 has_more / next_cursor / trace_id;
另更新两处既有断言(limit 20→21、feedback 返回裸对象)。
门禁:ruff 干净 / mypy 138 文件 / 696 unit+contract / 33 integration。
|
2026-09-11 15:21:36 +08:00 |
|
lzf_0626
|
575b4c2baa
|
客服适当性改按第十二条匹配矩阵(更正我上一轮的复核结论)
你说得对:C ≥ R 是风控的口径,客服要走政策原文的矩阵。
上一轮我复核后说"两边一致、无需改动"是错的 —— 我只对比了政策文本与
SuitabilityService,漏了第三个东西:客服自己的知识库。POL-AST-012 就是第十二条矩阵
原文,而客服为 C1-C5 补的「能买什么产品」问答答案直接取自该矩阵(docs/24 第二节)。
于是同一个客服 Agent 对同一个问题给出相反答案:
问"C1 能买什么产品" → 知识检索答 R1、R2 可买(矩阵口径)
问"C1 能买这只 R2 吗" → 适当性出口答不能购买(严格 C ≥ R)
政策冲突的精确位置也不是"矩阵 vs 硬匹配",而是第十四条**第 1 款与它自己的第 2、3 款**
矛盾:第 2 款说"低于一个等级以上"才拒绝(C1→R2 只低 1 级,够不上"以上"),第 3 款禁止
C1 买 R3+、C2 买 R4+(R2 不在禁止列表)。矩阵与第 2、3 款三方一致,孤立的是第 1 款。
改动:
- suitability_service.py 新增 MATRIX_ALLOWED / MATRIX_NEEDS_DISCLOSURE,_decide 由
"C < R 即拒绝"改为按矩阵:C1→R2、C2→R3 直接可买;C3→R4、C4→R5 走第十五条豁免档
(reason_code=SUITABLE_WITH_DISCLOSURE,强制揭示 + 确认 + 录音);低两级及以上仍拒绝。
- 客服话术分档:越级档不再说"在您的风险承受能力范围内" —— 那句只对 C ≥ R 成立,
用在 C1 买 R2 上会让客户以为自己的测评本来就覆盖这只产品。
- 风控侧不动,保留 C ≥ R:它要发现的是"越级成交且留痕不全",这个差异是有意保留的。
测试:新增逐格对照政策原文的 25 格矩阵用例、豁免档用例、话术分档用例(+29)。
docs/25 第七节 #1 与 docs/24 第七节同步更正,包括写明我上一轮那个结论错在哪。
门禁:ruff 干净 / mypy 137 文件 / 693 unit+contract / 33 integration。
|
2026-09-11 14:47:08 +08:00 |
|
lzf_0626
|
4b0c148ae8
|
Merge pull request '风控 P3 收尾与适当性豁免额度校验' (#4) from qyqy_develop_1 into qyqy_develop
合并 qyqy_develop_1 第二轮工作:第十五条豁免额度校验、风控接口对齐信封/游标/幂等/SSE、trigger_rule_codes JSON 多值索引、docs/24 状态同步。
|
2026-09-11 14:38:49 +08:00 |
|
lzf_0626
|
c11e56c7aa
|
同步 docs/24 的过期状态;登录端点不改(docs/05 明确平台不实现)
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 风控侧已补齐,客服侧仍待办。
|
2026-09-11 14:26:17 +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
|
df96275ab5
|
给 trigger_rule_codes 加 JSON 多值索引;登记 P3 处理结果(docs/25 P3 #21)
实测确认 #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 前半段不成立,其余已修。
|
2026-09-11 14:19:05 +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
|
ccf353901a
|
docs(25): 更正我自己的上一条判断——behavior_score 是上游数据前提,不是本项目缺陷
上一轮我把"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 的客户错误地抬回满分。
|
2026-09-11 13:54:27 +08:00 |
|
lzf_0626
|
9005bd9a46
|
docs(25): 第 16 条复核——两项不成立,另有一项是真缺陷(原报告没写出来)
原描述把三件事混成一条,逐项复核:
**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。
影响:行为分是"预警结案 → 客户行为评分下降"这条链路的落点,现在这条链路**产出为零**。
修复点在**画像创建侧**(初始值应为满分),不在风控的扣分逻辑里。
|
2026-09-11 13:52:57 +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
|
d4e7e2672c
|
docs(25): 更正 P1 #2 的定性——模型自建客户端是基座能力缺口,不是违规
复核时发现原修复建议("改走 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 零影响;
但涉及基座核心链路,是否做需要项目方定,**本轮未实施**。
|
2026-09-11 13:43:46 +08:00 |
|
lzf_0626
|
3593a68ad1
|
docs(25): 撤回一条误报——ai_analysis 的并发控制一直存在
复核 P1 #5 时发现 .with_for_update() 就在
isk_analysis_service.py:121-125,
而初次报告只读到 :130-160、恰好漏看了锁所在的那几行,却把这条标成了"✅ 已验证"。
实测推翻(并发发起三种分析,各自独立 session —— 复用同一个 session 会天然串行、
测不出竞争):
- 预警研判 1809 字 / 回访话术 2030 字 / 工单摘要 1088 字,三个请求全部成功
- 落库 ai_analysis 的键:['回访话术', '工单摘要', '预警研判'] —— **三个都在,未发生覆盖**
所以 P1 #5 只剩前半条(审计不经基座统一链路)仍然成立,并发那条撤回。
教训一并写进文档:标注"已验证"之前必须确认自己真的读到了关键那段代码,
否则标注本身就是误导 —— 这与 _topic_of 那几轮踩的是同一个坑。
|
2026-09-11 13:42:32 +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
|
deeee9bb01
|
fix(risk): 补齐风控缺失的三个 RBAC 权限——写操作与扫描原本全是死的
**发现**(由"给邮件端点加权限"这个任务引出来的):风控 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 里已记的一项。
|
2026-09-11 13:37:30 +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
|
8283f6ab69
|
feat(customer-service): 把闲聊提示词补回生效版本,并修掉发布脚本的两个坑
**背景**:提示词配过(挂在 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 全绿。
|
2026-09-11 13:26:43 +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
|
cdbd85b27c
|
test(platform): 端到端验证配置项丢失告警,并把它收成可复用的回归工具
上一轮加了"激活时点名将被丢掉的配置项"之后,我只用 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 条配置项,环境已复原。
|
2026-09-11 13:05:33 +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 |
|
lzf_0626
|
39c7ab51f4
|
feat(risk): 发布风控配置并补齐缺失的 RBAC 权限
修 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: 缺少工具权限。
|
2026-09-11 12:18:34 +08:00 |
|
lzf_0626
|
d1a24b84b3
|
docs(25): 风控模块代码评审报告
评审方式: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,第十四条却
要求投资者等级 >= 产品等级,两者对同一情形结论相反(已核对原文)。
|
2026-09-11 12:08:17 +08:00 |
|
lzf_0626
|
d3bb05c217
|
Merge pull request '客服 Agent:知识检索、会话记忆、图投影与适当性裁决' (#3) from qyqy_develop_1 into qyqy_develop
合并 qyqy_develop_1 的客服 Agent 阶段性工作:知识检索(行级拆分+字面兜底)、短期会话记忆、记忆→画像→图投影、适当性裁决、客服控制台及配套测试。冲突已在 qyqy_develop_1 侧解决(bootstrap.py 同时保留客服与风控的工具/Agent 注册)。
|
2026-09-11 10:43:39 +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 |
|
lzf_0626
|
d40d808dd0
|
docs(24): 客服 Agent 阶段性总结与下阶段计划
覆盖区间:从"客服能答常见问题但换个说法就时灵时不灵"到"能对客户给出适当性结论"。
只写已经跑通并验证过的事实(附实测数据)与明确还没做的事,不含计划外推测。
内容:知识检索质量(字面兜底/行级切分/粒度选择,含两个我自己引入的回归)、
多轮上下文(换话题串味 / 只补主语不补内容)、适当性裁决接入、知识库内容补充、
调试前端;三个教训(_topic_of 反解文本咬了三次、"接口留好了线没接上"的模式、
阈值必须和数据规模与形状一起看);当前状态;下阶段计划与待决策事项;遗留缺口清单。
同时更正此前口述的两处错误:领先远程是 12 个提交(不是 16),
闲聊提示词配置是"从未落库"(不是"某次发布弄丢")。
|
2026-09-11 09:33:48 +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
|
958fd62d64
|
test(customer-service): 锁住适当性出口的每条分支
这个出口会给出"能不能买"的结论,写错一条就是误导客户,所以把话术与取数逻辑都用
单测钉住(14 条):
- 话术:允许购买时报出产品等级与客户等级;需签揭示书/双录时明说;**拒绝时不下断言**
(原因可能是等级不匹配、测评过期或未测评,统一说成"超出承受能力"是错的);
档案里没有测评结果时写"您目前没有在有效期内的风险测评结果"而不是"您的等级为未记录";
有效期截成日期。
- 产品名来源:只从上一轮回答的主语取,客户问句不算数,上一轮是兜底话术时返回空
(调用方据此转人工,而不是瞎猜一个产品)。
- 产品风险等级:只认"风险等级"那一行;命中别的产品的等级行必须放弃(否则拿别人的
等级给客户做裁决);C1 的 FAQ 里"可购买 R1、R2"那种列举必须拒绝;检索降级或工具
抛异常时返回 None 而不是一个等级——那会让裁决建立在"其实没查到"之上。
|
2026-09-10 22:43:18 +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
|
7b3a72860c
|
feat(knowledge): 为 C1-C5 各补一条「能买什么产品」的问答
客户实测反馈:「C1 客户能买什么」被引导到人工客服,而知识库里其实有答案。
实测分数:这条问句 top1 只有 0.5633、与次优差 0.0236(低于 0.07 门槛)→ 转人工;
而「C1 保守型客户可以买哪些风险等级的产品」是 0.7794,过了 0.75 硬门槛、能答。
根因是客户与知识库的用词鸿沟:客户说「C1 客户」,知识块标题写的是「C1 保守型」。
短问法少了"保守型"这个锚点就差 0.19 分——而客户不知道 C1 就等于保守型,这正是他要问的。
按 A 方案(数据问题用数据解决)为 C1-C5 各补一条 FAQ,答案全部取自
《个人投资者适当性管理指南》原文,不自行编写:
- 第十二条投资者与产品匹配矩阵(各级别可购买的产品风险等级)
- 第十四条硬匹配规则的跨级禁止要求
- 第十五条豁免规则(C3 买 R4、C4 买 R5 的签署揭示书与持仓上限)
知识块 631 → 636。同时修正 load 脚本自检里过时的期望:这句话现在命中 FAQ 而非
POL-AST(两者是同一份内容,只是 FAQ 的问句措辞更接近客户口语)。
验证:C1-C5 六个等级的「能买什么」问法全部直接回答、无一转人工;
ruff / mypy / 468 unit+contract 全绿。
|
2026-09-10 22:34:20 +08:00 |
|
lzf_0626
|
a7135d2549
|
test(customer-service): 补交检索问句的断言更新
把检索问句从"拼上一轮原话"改成"只补产品名"时同步更新了本文件,但漏了提交。
新增的断言锁住两件事:上一轮的问句原话一个字都不能进检索词(拼整句会让检索词变宽、
只能命中粗粒度的整节块),以及上一轮是兜底话术时取不出主语应退化为只查当前这一句。
|
2026-09-10 22:29:28 +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
|
07a922fa36
|
feat(tools): 新增本地客服控制台(可聊天的调试前端)
为什么做成独立进程而不是给底座加接口:底座目前没有登录接口(按计划推迟)。
任何让浏览器直接拿到令牌的做法——无论是一个 dev token 端点还是把私钥下发前端——
都等于把"任意身份"开放给任何能访问服务的人。控制台把令牌签发与调用全部留在
服务端进程内(私钥不出进程),底座代码零改动、也没有新增任何后门路由。
- GET /:返回聊天页;POST /api/chat:受理 agent-run 并驱动本进程执行
- 页面展示识别出的意图与是否引导人工,便于观察路由结果
- 同一 session_id 连续对话,用于验证短期记忆(多轮指代)生效
|
2026-09-10 22:06:43 +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 |
|