Commit Graph
409 Commits
Author SHA1 Message Date
张胜宇 35effab70f feat(W20): 新增 E2c-my 出口 + 展示层净化 + P2 自助流程豁免(金标零回归)
用户口径:既智能又安全,不靠"答不上就转人工";按本人画像测评列出可购买的产品,
但不引导购买。

出口由五个扩为六个:
- 新增 E2c-my:按客户本人权威测评等级给出可购买范围 + 在售清单
  (只列代码/名称/类别/风险等级,按代码升序;出口后置引导词护栏,命中即降级为仅范围)
- 点名档位(「我可以买 R3 的产品吗」)先给一句直接裁决再列范围
  —— 修复前只给清单,"不"字始终没说出来
- 新工具 query_eligible_products(suitability:read)+ 发布 release 220

真实业务缺陷:
- P2_SELF_SERVICE_PATTERNS:问「怎么修改绑定的银行卡」不再建单转人工(FAQ 里就有答案);
  「帮我把绑定银行卡换一下」仍走 P2
- 展示层净化 render_plain / drop_yield_claims / prettify_title 接在 E3/E4/E5b/澄清四处
  (E4 模型会照抄证据包的 markdown 标记)
- 空结果护栏:语料里 15 个纯收益切片被净化清空时回退 E5b,不给空气泡

测试:定向 334 passed;全量回归 1969 passed / 3 skipped / 0 failed(基线 1946);
46 条金标 M-1 46/46=100%、M-4 100%、M-6 5/46=10.9%、M-7~M-10 全 0 —— 与
score_w11b 逐项一致(零回归)。

落档:D1.6 §4.49 / D2.1 v6.36 / D1.1 §30
2026-09-20 18:55:22 +08:00
张胜宇 dc83f4f4a7 docs(W20): 定位「按我的风险等级能买什么」被 PROMOTION_REQUEST_PATTERNS 第4条误拦 + 五条待决登记(D1.6 4.48 / D2.1 v6.35 / D1.1 29) 2026-09-20 18:18:33 +08:00
张胜宇 172806ae36 docs(W19): D2.6 答辩报告同步演示前核验 + D1.6/D2.1 落档;修边界脚本自碰撞
一、D2.6 答辩报告(客服agent\D2.6)
* 新增 §6.4「演示前最后一次全量核验(W19)」:demo.ps1 五项自检全过(退出码 0)/
  pytest 1917 passed / portal_api_check 35 通过 0 失败 / e2e_smoke_test 31/31 /
  http_probe 11/11 / 边界 12/12 / _consistency 失效锚点 0 / check_authoritative_docs 54 无冲突 /
  /internal/health/ready 三依赖全绿;并记两条可当面演示的实证(画像题有数据可依、跨主体会话隔离)。
* §6.3 两行补 W19 复跑值;§9 新增两条教训(误删文件的清理判据、边界用例标识必须每次唯一);
  §10 新增两条已知边界(D-2 重复问句漂移 / D-4 英文落 E5b)并更正 M-2b 口径;
  §12 一页速览的数字更新为 1917;头部新增 W19 状态更新行。

二、脚本修复
* _fe_boundary_http.py:「session_id 恰好 64」原用固定串,第二次跑必然 404
  SESSION_NOT_ACCESSIBLE(访客令牌每次是新主体,会话号与主体绑定)⇒ 改为 64 字且每次唯一;
  该"误撞"同时固化为用例说明(跨主体会话隔离生效)。复跑 12/12。

三、落档
* D1.6 新增 §4.47 的「演示前全量核验」与「画像题真机复验」两行;D2.1 新增 v6.34 第 12/13 条。

四、门禁
* _consistency.py exit 0;tools/check_authoritative_docs.py 54 文档无编号冲突(exit 0)。
2026-09-20 18:00:37 +08:00
张胜宇 fc4351e219 docs(W19): 补登画像题真机复验(fin_customer_profile 6 行,H-03/D-04 由查不到档案变为有数据可依) 2026-09-20 17:54:43 +08:00
张胜宇 d9151ac1b1 style(model-gateway): 修掉守卫注释里的错字(声明朝 -> 声明) 2026-09-20 17:53:49 +08:00
张胜宇 d5e813b726 feat(model-gateway)+docs(W19): embedding 端点唯一性配置守卫 + 三份完整版/收敛版索引口径补注 + 门槛口径更正 + D3.7 难例口径统一
一、代码(2 文件 + 2 工具脚本注记)
* app/service/model_gateway.py:DatabaseModelEndpointResolver.resolve() 加配置守卫
  —— required == "embedding" 且 len(matched) > 1 时 logger.warning(只告警、不改行为)。
  多个 embedding 端点会让索引向量与查询向量可能来自不同模型(维度同为 1024、不报错),
  COSINE 相似度整体失真,表现为"越答越差"的哑故障。顺手删掉重复的 return endpoints(死代码)。
* tests/unit/service/test_model_gateway.py:新增 2 条单测(多端点告警且返回顺序不变 / 单端点静默)。
* tools/configure_embedding_endpoint.py:加「已废弃,勿重跑」标注 —— 它写的是
  qwen-embedding / qwen3.7-text-embedding-flash,与现役端点 knowledge-embedding-qwen-v3 /
  text-embedding-v3 不一致,重跑会凭空多出一个 embedding 端点。
* tools/build_knowledge_chunks.py:删掉与新口径冲突的注释「不泄露档位与门槛」,
  改为「registered 的依据是权益明细而非门槛;门槛属公开宣传口径」。

二、文档(8 份;D-1 选乙 + D-3 统一为 18)
* D2.4 v1.6 → v1.7:§4.4 + 附录B 更正「门槛金额不再单独构成 registered 的理由」
  (public 的 FAQ-0014 已完整给出五档门槛、FAQ-0050 含钻石门槛);
  HNW-004—HNW-007 保持 registered,依据收窄为"各层级权益明细";HNW-* 档位不动(分区键)。
* D3.1 v2.5 → v2.6:§5.3 加索引口径落地注(覆盖 §2.5 决策表 / FR-CS-007 / 排期 T4)
  + 补「字段表同属初稿」(实库 18 字段全 NOT NULL、doc_id 主键、无 metadata JSON)。
* D3.2 v1.2 → v1.6:§4.1 加同口径注 + 版本位追平(顶栏 v1.1 / doc-meta v1.2 落后于自身记录 v1.5)。
* D2.2 v2.6 → v2.7:§1.4.2 域 B 加注(TopK / 阈值 / 度量 / 集合选择均未变 ⇒ 不影响验收)。
* D3.7:§3 难例口径统一 —— 难例 32 条(改写 8 + 口语 16 + 多轮 4 + 禁忌 4)为定义式总数,
  M-2b 分母 = 其中带期望证据家族的 18 条;并补正 §3 初稿表格条数(以 cases_46.json 为准)。
* D1.1 v1.8 → v1.9:新增 §28;四处版本位同步;顺带修正两处历史遗留
  (D2.4 版本位长期停在 v1.3、D2.2 日期列停在 2026-09-17)。
* D1.6:新增 §4.47(含自我失误留痕)。
* D2.1 v6.33 → v6.34:新增本轮修订要点段。

三、实测门口(本机)
* tests/unit/service/test_model_gateway.py:10 passed
* pytest -q -p no:cacheprovider(全量,跑前已停 Worker):1917 passed / 3 skipped / 0 failed
* tools/check_authoritative_docs.py:54 文档无编号冲突(exit 0)
* _consistency.py:失效锚点 0、交叉引用全 ✅(exit 0)
* _fe_boundary_http.py(重建件):12/12 符合预期
* 服务已重启:/internal/health/ready 三依赖全绿(mysql / redis / milvus)

四、如实留痕(自我失误)
本轮清理临时文件时删除判据过宽,误删 _consistency.py(已原样恢复)、
_legacy_customer_service.py(已按 f72a545 逐字节重建,40,554 字节)、
_fe_boundary_http.py(原件不可恢复,已按既有判据重建并实跑 12/12)与若干历史轮次原始日志。
详见 D1.6 §4.47 五。
2026-09-20 17:53:06 +08:00
张胜宇 44b57a56ce docs(D2.9): 补精确 G 组出口语义(P0/P1/P2 与 E5c)+ 两条判分别名说明 2026-09-20 17:32:16 +08:00
张胜宇 eb1822d802 docs(W18): D2.4 索引口径更正为 AUTOINDEX(v1.6)+ D2.9 手动对话测试用例成文 + 空白消息 500 修复
- D2.4 v1.3 -> v1.6:索引统一 AUTOINDEX(设计初稿 HNSW/IVF_FLAT 未落地,实库直查为据)
  更正 9 处 + 新增 §4.1 索引口径落地注;语料 628 -> 675 块并补 fin_basic_collection 说明
  (三集合仍是唯一默认检索面,basic 并入会使 M-1 100% -> 91.3%)
- 新增 客服agent/D2.9-客服Agent手动对话测试用例-2026-09-20.md:
  46 条金标逐条可问 + 11 条边界 Z 组 + 判分四问 + M-1~M-10 手动汇总 + 真 HTTP 核验配方
  实测登记 3 项缺口:空白消息 500(已修)/ 语料档位口径矛盾(待裁定)/ 重复问句漂移(待裁定)
- 修修复:message 纯空白 500 -> 422 AGENT_INPUT_INVALID(入口补判据 + 回归测试)
- tools/chat_console.py 提示语去掉已下线产品名(季季盈)
- 落档:D1.1 §27(60 -> 61 份 / 客服agent 8 -> 9 份 / 注入校验 56 -> 57)、D2.1 v6.33、D1.6 §4.46
- 门禁:check_authoritative_docs 通过、_consistency.py GATE PASS、pytest 1915 passed / 0 failed
2026-09-20 17:31:40 +08:00
张胜宇 921de2cfc3 docs(W17): 知识库 RAG 全链路与选型说明成文(D2.8)—— 按八阶段讲清解析/切片/检索增强 + 4 张流程图
新增 客服agent\D2.8-客服Agent知识库RAG全链路与选型说明-2026-09-20.md(域 2,D2.x 续号,683 行)。

- 按流程逐步:语料与解析 → 切片 → 向量化 → 存储 → 入库七步 → 在线八步 → 检索增强 7 个动作 → 阈值与五出口。
- 4 张 Mermaid 图:端到端全景 / 切片与派生字段 / 检索增强 / 出口决策树。
- 切片写透:叶子标题策略(对照三种真实情况)、表格逐行拆自解释小块(父子块)、表头 bug 与自检守卫。
- 检索增强 7 动作:向量召回 / 字面召回(专有名词,条件触发)/ 父块带回 ×0.9 / doc_id 去重 / 兄弟子块归并 / 整节块保底席位 / 证据包三种形态。
- 阈值取证:0.75/0.55/0.07/0.40/0.5,MIN_GAP 实测标定区间 (0.0649, 0.0759)。
- 选型原因:8 项决策 26 备选,逐项含否决理由与演进触发条件。
- 实测:切片件 675 块,Milvus 四集合 count(*) 150/191/288/46 与切片件逐集合一致,索引全 Finished。
- 新登记 3 项不一致(只登记不修复):① 文档写 HNSW/IVF_FLAT 而实库是 AUTOINDEX;② tools\configure_embedding_endpoint.py 会建出第二个 embedding 端点(重跑可能造成索引与查询不同模型的无声质量崩塌);③ agent_faq_synonym 表检索链路不读(术语归一化未落地)。
- 计数同步:D1.1 v1.6→v1.7,59→60 份(58→59 份编号),客服agent\ 7→8 份;§4.0 总表与 §4.1 明细各新增 D2.8 行;注入校验 55→56 份;新增 §26 轮次段。
- D2.1 标题 v6.31→v6.32;D1.6 新增 §4.45(含一处自我更正:误读旧 JSON 导致"实库缺 family_id/param_class/intent"的错误结论)。
- 本轮不改代码、未跑金标评测;门禁:check_authoritative_docs.py 通过 + _consistency.py GATE PASS。
2026-09-20 16:58:42 +08:00
张胜宇 bcedf71e12 docs(W16): 中长期记忆与画像联动设计成文(D2.7)—— 三问直答 / 五道闸门取证 / INV-M1~M6 / 废弃理由更正
新增 客服agent\D2.7-客服Agent中长期记忆与画像联动设计-2026-09-20.md(域 2,D2.x 续号)。

- 主设计主张:让记忆改变「系统行为」而非「模型输入」——记忆只作确定性信号(检索分区加权 / 澄清候选排序 / E2 计算入参 / 适当性过滤),永不进生成上下文(FR-CS-009 + FR-CS-049)。
- 新增记忆子域不变量 INV-M1~INV-M6(INV-M6:记忆只能收窄、不能放宽)。
- 登记两处过期理由:D2.2 §1.7 第 12 项 + 第 998 行澄清框仍引「投顾已整体清除」,而投顾已于 2026-09-20 恢复(D4.7)。
- 计数同步:D1.1 v1.5→v1.6,58→59 份(57→58 份编号),客服agent\ 6→7 份;§4.0 总表与 §4.1 明细各新增 D2.7 行;注入校验 54→55 份;新增 §25 轮次段。
- D2.1 标题 v6.30→v6.31(新增 v6.31 段);D1.6 新增 §4.44。
- 本轮不改代码、不改行为;门禁:check_authoritative_docs.py 通过(54 份无编号冲突)+ _consistency.py GATE PASS。
2026-09-20 16:31:29 +08:00
张胜宇 e28e1f3900 docs(W16): 落档「Agent 与用户画像关系 / 对话能否更新画像 / 中长期记忆」咨询轮 —— D2.1 v6.30 / D1.6 §4.43 / D1.1 §24 版本位 2026-09-20 16:25:44 +08:00
张胜宇 c3d57fbf3f feat(cs)+docs: 修掉 P1 错分「风险测评结果」—— 画像问答改由受控工具作答(W15)
背景(用户提问触发):
  §1.2.1「客户能看本人的持仓/交易/账户/画像与风评」与 §1.4.5 P1
  「账户与个人数据(含风险测评结果)Agent 无权限读取」读起来互相矛盾。

实测根因(两处):
  1) route_message() 在画像分支之前,且 P1_KEYWORDS 含裸词「风险测评结果」
     ⇒「我的风险等级是多少」走画像作答,
       「我的风险测评结果是什么」被降级成「无法读取本人账户数据」
       —— 同一诉求两种结论,属「能答而不答」(H-03 同类)。
  2) 画像词在前、账户词在后的混问法漏网
     (「我的风险测评结果和持仓一起给我」落 P3 ⇒ 只答画像、静默忽略账户诉求)。

依据(决定性):D2.2 §1.7 第 21 项「画像问答字段直返」;
  D3.1 §0.3 术语表「画像问答属客服能力,与持仓查询严格区分」。

代码:
  - app/core/customer_service_rules.py:P1_KEYWORDS 移除裸词 + 口径说明;
    P1_PATTERNS 新增混问法守卫(第一人称 + 画像词 + 并列连词 + 账户词)。
  - customer_service.py / profile_projection.py:补「投影层白名单 ⊇ 客服对话
    渲染集」口径(total_asset / behavior_score / risk_tags 刻意不陈述,
    渲染它们等于用画像工具绕过 P1)。

守卫:
  - test_customer_service_rules.py:RT-004 → None;新增 RT-004b → P1;
    SAFETY_CASES 由字面区间改显式名单(RT-004b 字母后缀会落区间外被静默漏掉)。
  - test_customer_service_agent.py:画像端到端 3 条 + 混问法反向守卫。

安全不降:答案只来自 query_customer_profile(self 作用域 + 字段白名单 + 工具
  审计),查不到失败关闭、绝不猜等级;P0/P2 未动、P1 其余字面未动;
  混问法仍走 P1;访客问画像仍引导登录。

文档:D2.2 v2.5→v2.6 / D3.1 v2.4→v2.5 / D2.6 更正 / D4.6 追加 §3(不改正文)
  / D1.1 §24 + 版本位 / D1.6 §4.41 修正误记 + §4.42 / D2.1 v6.29。

实测:pytest 1914 passed / 3 skipped / 0 failed(+5);ruff 20(无新债);
  check_authoritative_docs 54 文档无冲突;_consistency GATE PASS;
  http_probe 11/11;定向真机复验 9/9;portal_api_check 35/0/5;
  e2e_smoke 31/31;fe_boundary 12/12;demo.ps1 五项自检全过。
2026-09-20 16:22:02 +08:00
张胜宇 56cb15342b docs(D1.1): 历史轮次里 docs/46-47 的指针补改名记录(指向 §23 的 docs/48-49) 2026-09-20 15:27:45 +08:00
张胜宇 c91bbcbdc1 feat(ops)+docs: 密钥轮换工具 + 两份文档目录审计收口(D1.1 §23 / D2.1 v6.26)
一、密钥轮换(新增工具 + 操作手册)
- 新增 tools/rotate_api_keys.py:--check 体检 + 交互式轮换;getpass 不回显、
  自动备份 .env.bak-<时间戳>(已被 ignore 命中)、校验不过整体不写入、
  三个 Qwen 变量写同一值 / 两个 DeepSeek 变量写同一值。
  实测 --check:Qwen 三变量同值且非空、DeepSeek 两变量同值且非空。
- 新增 开发文档/D3.8-模型密钥轮换与凭据安全操作手册-2026-09-20.md(CS-OPS-2026-023):
  .env 5 个变量与读取方取证、五步流程、3 个坑、复核清单、回退方式、能力边界。
- 口径确认:model_endpoint_config.secret_ref 存变量名 ⇒ 轮换只改 .env,不动 DB;
  但必须重启 API + Worker。

二、门禁修复:docs/ 编号撞车
- tools/check_authoritative_docs.py(D3.4 N-14 登记的验收命令集之一)实测 FAIL:
  我方 docs/46 docs/47(2026-09-20 建)与投顾组 docs/46-投顾Agent需求文档.md
  docs/47-投顾Agent功能架构文档.md(2026-09-16 建)同号。
- 按「后到者让位」改名:docs/48-可改文件白名单.md / docs/49-底座会签申请单-2026-09-19.md,
  同步 8 处引用。修复后:checked 54 documents, no number collision,exit 0。

三、前端品牌残留(W12 合并静默回退)
- employee-advisor/dashboard/index.html 与 customer/advisor-plans/index.html 的
  <title> 仍是 南方财富(投顾组分支带回)→ 按 DEC-27 改为 南方基金。
- HTTP 实测两页标题已正确;全仓 app/ 复查 南方财富 = 0。

四、文档口径校准(12 处事实漂移)
- D1.1:D2.1 版本 v5.3 → v6.26(§4.0 / §4.1 / §1 / §2 四处长期错误);
  §8 四行遗留项闭合(D-5 / D-6 / D-7 / 仓库副本同步)+ 新增 §22 §23 留痕;
  §10.2「本区不在任何 git 仓库内」更正为已入库;新增两编号 ⇒ 计数 56 → 58 全量同步。
- D2.2:顶栏徽标 v2.4 与元数据 v2.5 自相矛盾 → 统一;「投顾已清除」→ 状态更新
  (模块 2026-09-20 已恢复,但客服范围裁定 §1.7 / RK-10 不变)。
- D2.3:徽标 v1.0 · 7 批次 51 项 → v1.1 · 8 批次 57 项;投顾清除后果 + §7.1 头号风险
  + 风险表 + 不触碰行全部加恢复口径。
- D2.4:v1.3 变更说明 ⑦ / §1.4 Out of scope / Q-09 加投顾恢复口径。
- D2.5:advisor_t 自相矛盾口径改写为账号表一行 + 口径更正;五项自检首选改为
  一键脚本 启动演示.bat / demo.ps1;补 D3.8 与未发布 advisor:* 白名单登记。
- D2.6:门禁数字 1856/2 → 1909/3 skipped、ruff 19 → 20、补 portal_api_check 行;
  §10 两项已闭环(密钥轮换已工具化、A-10 组 3/4 已补签);头部加 W12/W13 状态更新。
- D4.5:顶部状态更新补指向 D4.7。
- 新增 开发文档/D4.7-投顾模块恢复记录-2026-09-20.md(CS-PURGE-2026-014):
  时间线、8 项恢复动作、客服线不变的结论、DEC-19 理由更正、遗留 1 项、失误登记。
- _consistency.py(维护侧):§三 改为「投顾状态口径检查」,合法语境扩为
  清除史 / 恢复史 / 不属本 Agent 范围。

五、回归实测(全绿)
- pytest -q:1909 passed / 3 skipped / 0 failed
- ruff check app tools tests:20(与 W12 持平,未引入新债)
- mypy app:2(= 既有基线)
- tools/check_authoritative_docs.py:54 文档无编号冲突(exit 0)
- tools/e2e_smoke_test.py --read-only:31/31
- tools/portal_api_check.py:40 项 通过 35 / 失败 0 / 跳过 5
- _eval_harness/http_probe.py:11/11 succeeded
- _consistency.py:GATE PASS
- demo.ps1 -SkipStart -NoBrowser:五项自检全过、退出码 0
- 权威副本 ↔ 仓库:逐字节一致(客服agent 24 / 开发文档 52)

六、未做(如实登记)
- 投顾 config_release 工具白名单(advisor:*)仍未发布 ⇒ 投顾 Agent 工具调用 fail closed
  (实测 active_agent_tools 仅 customer_service:* 4 项 + risk:* 4 项)。与客服线无关;
  要演投顾线先跑 tools/publish_advisor_demo_config.py --apply。
- 两把 key 的实际轮换需你在控制台建新 key(无法代做),流程见 D3.8。
2026-09-20 15:27:07 +08:00
张胜宇 4306326a56 docs: 落档本轮(演示一键启动 + 权威文档入库 + 回归复跑)
- `开发文档/D1.6` 新增 **§4.38**:本轮指令原文、`demo.ps1`/`启动演示.bat` 的职责与实测结果、
  两个真实问题(PS 5.1 传参吞双引号 ⇒ 改走 stdin;终端乱码经复核是捕获侧假象)、
  **权威文档入库的关键判断**(仓库内是 2026-09-16 前的过期副本 + 旧文件名,少 49 份 ⇒
  按「权威覆盖过期」入库并复核零差异)、入库前密钥扫描结论、回归复跑全表、推送记录。
- `客服agent/D2.1` 升 **v6.25**:同上要点的执行看板口径,并写明「后续同步文档一律按
  权威副本 -> 仓库 单向覆盖」。
- 两份文件的仓库副本与权威副本**逐字节一致**(已复核)。

回归(入库后跑):pytest 1909 passed / 3 skipped / 0 failed;ruff 20;mypy app 2;
e2e_smoke 31/31;portal_api_check 40 项 35/0/5;http_probe 11/11;_consistency GATE PASS。
2026-09-20 15:09:12 +08:00
张胜宇 bc61d5c579 docs: 入库权威文档目录(客服agent/ 24 份 + 开发文档/ 50 份,替换旧命名的过期副本)
## 为什么做这一步

权威文档 74 份此前**只在本机**,评审者 clone 分支后看不到任何设计文档;而仓库里那两份同名目录
是 **2026-09-16 之前的过期副本,连文件名都是旧的**(无体系编号)。本次按「**权威覆盖过期**」入库。

## 入库内容

| 目录 | 文件数 | 体积 | 说明 |
|---|---|---|---|
| `客服agent/` | 24 | 0.77 MB | `D2.1`~`D2.6` 对外交付四件套 + 演示脚本/答辩报告 + `_build` 构建工具 |
| `开发文档/` | 50 | 2.16 MB | `D1.x` 索引与决策、`D3.x` 方案、`D4.x` 清除与重构留痕、`D5.x` 业务流程、`D6.x` 业务事实基座、`D7.x` 交付物、`D8.x` 规范 |

**旧的过期副本整体移除**(`客服Agent执行Todolist.md` → `D2.1-客服Agent执行Todolist.md` 之类
的改名 + 新增 `D2.5`/`D2.6`),入库后目录内容与权威副本**逐文件一致(零差异,已复核)**。

## 入库前的安全扫描(必须留痕)

- 扫描规则:`sk-` 类密钥 / `Bearer` 长串 / `password=`、`api_key=` 赋值 / 会话中出现过的两把明文 key 片段。
- 结论:**真实密钥只出现在 `.env`**(已被 `.gitignore` 命中,未入库);`.env.example` 与
  `config/risk.env.example` 只有**空占位**。
- 文档内唯一命中是 `D3.1` 里一处**截断的示例 JWT**(`Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...`),
  末尾带省略号,是接口文档的示意值,**不是可用凭据**。
2026-09-20 15:03:15 +08:00
张胜宇 e0992c663e feat(demo): 新增演示一键启动脚本(demo.ps1 + 启动演示.bat)
演示真正要做的事只有一件:**把要看的前端页面按顺序打开,并确认环境真的就绪**。
原先只有 `start.ps1`(起服务),演示前还得手工跑行情刷新与五项自检,容易漏。

本次新增:

- `start.ps1`:不动(它已经幂等,缺什么补什么,重复执行不会起第二个 API/Worker)。
- `demo.ps1`:在 start.ps1 之上补三件事 ——
  ① **刷新行情**:下单要求行情快照落在 15 分钟有效期内(`trade_service.MAX_QUOTE_AGE`),
     过期后所有委托直接 503,而系统没有自动刷新机制;
  ② **五项自检**:口径与 `客服agent/D2.5` §1 完全一致(容器三件套 / 向量库三集合计数 /
     Worker 在跑 / 行情有效期 / `milvus_local_uri` 开关为空);
  ③ **打开演示页面**:访客页(客服浮窗主战场)+ 客户登录页。
  另把「**登录限流 10 次 / 60 秒**,不要反复登录」这条最容易造成假故障的提醒印在「开讲前必看」。
- `启动演示.bat`(GBK,与本仓库既有 .bat 同编码):双击即用,参数透传给 demo.ps1。

实测(本机):`启动演示.bat -SkipStart -NoBrowser` 与完整路径(含调用 start.ps1)
均 **五项自检全过、退出码 0**。

排错留痕(避免后人改回去):Milvus 探测代码原本用 `& $Python -c $probe` 传参,
Windows PowerShell 5.1 传原生命令参数时会把内嵌双引号吞掉(报 `unterminated string literal`)
⇒ 改为经 **stdin** 传给 python,并在脚本里写明原因。
2026-09-20 15:02:53 +08:00
张胜宇 e5b4d02b0d merge: 集成投顾组 3 个提交(解除与「投顾模块清除」的冲突)+ 客服 Agent 重构收口
## 为什么要合并
远端 `origin/qyqy_develop` 领先 3 个提交(`5607751` / `2fe7d0c` / `74b7d00`:投顾需求与架构文档、
客户主动申报投顾方案 + 受理自动出草稿、方案交付落点与推荐依据 LLM 增强),而本地 `5d0becb`
按 `D4.4` / `D4.5`(CS-PURGE-2026-012/013)把投顾模块整体清除了。**两个目标不可兼得**:
远端新代码反向 import 已被清除的模块(`app.model.investment_goal`、
`app.service.product_recommendation_service`、`app.service.advisor_rollout_service`),
强行推进只会让两边都跑不起来。

**裁定:投顾组的新功能 > 本地的投顾清除。** 依据是 `D4.4` §0-②③ 自己写下的风险
——按名字清投顾会同时拆掉产品数据底座与 MVP 硬阻断,并失去"改 6 个底座文件时的对照组"。
本次合并因此**恢复投顾模块**;就代码面而言,`D4.4` / `D4.5` 的清除结果被本次合并取代
(留痕见 `开发文档\D1.6` §4.37)。

## 冲突怎么解的(12 处)
- **8 处 modify/delete 取远端**:`recommendations.py` / `product_recommendation_service.py` /
  `employee-advisor/dashboard/{actions-module.js,dashboard.css,dashboard.js,index.html}` /
  `tools/{check_portal_modules.py,grant_advisor_role.py}` —— 即"我删、远端改",保留投顾文件。
- **3 处内容冲突取远端**:`app/main.py`(投顾 import 与 `include_router`)、
  `common/api-client.js`(投顾端点表)、`tests/unit/api/test_portal_frontend.py`(4 条投顾前端契约)。
- **1 处取远端 + 保留我方**:`app/main.py` 解除冲突的同时,保留本轮的
  `/customer-service-test` 挂载移除(该联调页与用例已随重构作废)。

## 因"取消清除"而必须回滚的语义改动(否则恢复出来的投顾代码跑不动)
- `app/service/agent/bootstrap.py`:恢复 `AdvisorAgent` 与 5 个投顾工具注册
  (`query_investment_goal` / `analyze_portfolio` / `generate_asset_allocation` /
  `recommend_products` / `compare_products`);客服 Agent 注释按本轮口径保留。
- `app/core/config.py`:恢复 `advisor_rollout_enabled` / `advisor_rollout_customer_ids`。
- `app/static/portal/common/layout/app-shell.js`:恢复投顾工作台导航与 `advisor` 角色名。
- `tools/seed_test_rbac.py`:恢复"admin 取全量元组"的授权模型(保留远端新增的
  9070-9074 权限码与客户侧 9071/9072 绑定)。
- `tools/portal_api_check.py`:恢复投顾实测用例(AD003/AD005/AD011/A047 与 `advisor_t` 登录),
  并**新增判定**:被渲染的集合为空(0 条)时判 `SKIP` 而不是 `FAIL`
  —— "没有行"与"字段没带"是两回事,混报会把排查方向带偏。
- `app/static/portal/common/api-client.js`:以远端为基准,重新叠加本轮的
  **访客令牌 `Authorization` 优先**修复(浮窗访客身份稳定性)。

## 数据库夹具同步(代码恢复 ⇒ 夹具也要恢复)
- `tools/grant_advisor_role.py`:新建 `advisor` 角色并授权(实测 34 项权限)。
- `tools/create_test_user.py --id 9020 --username advisor_t --role advisor`:重建演示账号。

## 集成期发现并修掉的过期断言
- `tests/unit/test_advisor_migration_contract.py`:alembic 末端钉死值仍是
  `20260914_baseline_auto_increment`,而远端新增了 `20260916_advisor_service_request`
  ⇒ 这条断言**在远端分支上本身就是红的**。本次把它更新到新末端并补了注释。

## 验证(本机实测)
| 门禁 | 结果 |
|---|---|
| `pytest -q`(全量,含集成) | **1909 passed / 3 skipped / 0 failed** |
| `ruff check app tools tests` | 20(远端分支 22,本地仅客服线基线 19) |
| `mypy app` | 2(= 既有基线) |
| `tools/portal_api_check.py` | 40 项:通过 35 / 失败 0 / 跳过 5 |
| `tools/e2e_smoke_test.py --read-only` | 31/31 |
| `_eval_harness/http_probe.py` | 11/11 succeeded |
| `_consistency.py` | GATE PASS |
| `_fe_boundary_http.py`(前端入参边界真机) | 全部符合预期 |

## 未做(如实登记)
- **投顾演示数据未灌**:`AD011` / `A047` 需要 `advisor_product_suitability_reference`
  这类带 `source_url` + `document_sha256` 的证据行,而披露文件不在仓库里;
  `tools/seed_advisor_demo.py` 明确"不编证据"(fail closed),故这两条按空集 SKIP。
- **客服线文档目录仍未入库**:`客服agent/`、`开发文档/`(权威副本在本机)与
  `_chunks_report.txt`(本地构建产物)依旧排除在提交之外。
2026-09-20 14:52:35 +08:00
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
  P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
  转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
  禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
  (输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)

二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
  knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数

三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
  ({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"

四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
  (端点与账号均已不存在,此前稳定报 3 条假红)

五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期

六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
  过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
  _chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
2026-09-20 14:33:30 +08:00
Windows 74b7d00dff feat(投顾): 方案交付落点、可视化图表与推荐依据的大模型增强
交付落点
- 新增 GET /api/v1/users/me/advisor-contents(客户读**自己**已发布方案):
  「发送给客户」原先只改数据状态、客户端没有任何页面或接口能读到它
- 客户端新增「我的投顾方案」页与导航入口

可视化(投顾结果区与客户页**共用** common/advisor-plan-view.js,避免两处漂移)
- 净值折线图(带坐标轴与网格)、组合业绩等权合成曲线(含区间收益与最大回撤)、
  资产配置环形图与图例、组合构成条
- 修 num(null)=0 的假 0:Number(null)/Number('') 会得 0,导致「没数据」被渲染成 0.00%;
  现一律显示「--」。同理管理费/起投未维护时按没数据处理,不显示 0
- 涨跌口径为「涨红跌绿」(A 股习惯),由 CSS 变量 --plan-up / --plan-down 集中定义

推荐依据接入大模型(可选,失败即回退)
- 新增 AdvisorReasonService:**只改文案,不参与选品**(候选池与排序在它之前已固定)
- 输入只允许是已算出的真实参数(风险等级、排序得分、区间收益、最大回撤、期限与流动性)
- 命中收益承诺词(保本/保证收益/稳赚/无风险…)整条丢弃并回退规则文案
- 未启用 / 缺密钥 / 超时 / 解析失败一律回退,推荐主流程不因模型不可用而失败
- 前端标注来源(AI 生成 / 规则生成)

数据与权限
- 客户角色补齐:绑 customer 角色、补建缺失的账户与交易段权限码(9060-9065)
- 净值全量同步(20 只产品),行情同步脚本按 --codes 分块(全量一次会被超时终止)

测试
- 新增 tests/unit/service/test_advisor_reason_service.py(10 项,专测三条合规边界)
- 前端模块自检纳入 service-request-module;补「两处共用同一渲染」回归测试
2026-09-16 18:17:47 +08:00
Windows 2fe7d0c506 feat(投顾): 客户主动申报投顾方案 + 投顾受理自动生成方案草稿
客户在自己主页提交申报 → 投顾工作台受理 → 自动跑既有推荐逻辑生成一份待审核草稿 →
投顾再走既有的「审核通过 → 发送给客户」。补上原先「客户只能被动等方案」的缺口。

后端
- 新增表 advisor_service_request(本轮新建,带 AUTO_INCREMENT)+ 迁移
  20260916_advisor_service_request(幂等:先查表再建,兼容本库 alembic 指针滞后)
- 新增 AdvisorServiceRequestService:create / list_mine / queue / review
  · 申报前置:风险测评必须存在且未失效(FM-03,12 个月),服务端判
  · 队列复用 ProductRecommendationService._visible_customer_ids(本人 + 归属),待受理排前
  · 受理即调用推荐逻辑生成草稿并把 content_id 回填;生成前置失败给出人话原因并保持待受理
  · 「已发送客户」不落库,由关联方案 published_at 推导,避免两处状态各写各的
- 权限码 9071-9074(客户 write/read:self、投顾 read/review),已进种子与授权工具

前端
- 投顾工作台新增「客户申报」面板(受理 / 驳回,驳回理由客户可见)
- 客户页新增申报表单(金额/期限/风险偏好/备注)与「我的申报」列表

客户自助路由刻意不挂投顾灰度闸门:那是投顾业务的灰度,客户提交自己的申请不该被它拦下。
2026-09-16 18:17:46 +08:00
Windows 560775156c docs: 新增投顾 Agent 需求文档与功能架构文档
依据仓库既有设计与已落地代码反推整理,内容与当前实现一致(非前瞻设计)。

- docs/46-投顾Agent需求文档.md
  · 背景与三个真实断点(交付无落点 / 文案不可读 / 客户无入口)
  · 目标 G1-G5 与 4 条非目标(不真实下单、不预测收益、不自动投资、不做场外)
  · 角色场景 SC-01~SC-05、两条主链路流程图
  · 功能需求 FR-01~FR-21(含口径、优先级、验收标准)
  · 业务规则:候选池硬约束 fail-closed、5 条合规红线、状态机、FM-03 熔断
  · 非功能需求、异常处理表、权限矩阵、数据需求、验收清单、11 项待确认问题

- docs/47-投顾Agent功能架构文档.md
  · 五层架构总览、后端模块划分与 3 条设计约束、数据模型与关系
  · 接口清单(投顾 8 / 客户 3 / 依赖能力 3 组)
  · 三张时序图(生成推荐、审核发布、申报受理)
  · 外部依赖降级矩阵、前端模块结构与 2 条硬约定、权限灰度、配置清单、已知约束
2026-09-16 18:17:45 +08:00
zhangshuai_0626 8643ad1efc 上传文件至「docs/风控业务演示文档」 2026-09-15 09:39:23 +08:00
lzf_0626 06d0f9f231 知识库检索质量修复:「风险评估问卷怎么评分」从必然转人工 → 直接答
## 根因(两个问题叠加)

1. 灌库脚本 tools/build_knowledge_chunks.py 的 expand_table_rows() 用「第一个非分隔行」
   当表头且 header 从不重置 → 同一节里第 2 张表格起**表头被当成数据行**。
   《个人投资者适当性管理指南》第九条下有 16 张问卷表格 → 15 条正文逐字相同的
   19 字零信息量碎片;同类共 19 条。
2. 检索层去重只按 doc_id,接不住「同一父块的兄弟子块」(POL-AST-009-07 与 -12 是
   不同 doc_id、同一父块)→ 15 条碎片全留在候选里互相打平,把 top1/次优差压到 0.002,
   而客服判定要求中置信必须领先 ≥0.07(MIN_GAP)→ 恒判并列转人工。

## 为什么没有重灌知识库

用 Milvus 现成向量离线复算四种做法(同一问句、同一批向量):
  现状                   gap 0.002  转人工
  只修 bug(=重灌全部收益) gap 0.001  仍转人工,且更糟
  只加父块归并            gap 0.093  可答,但答案是 19 字碎片
  两处都改                gap 0.076  可答且有内容
原因:第九条被切成 94 块,删掉 15 条表头碎片后,剩下 79 条数据行碎片依然互相打平。
重灌解决不了,却要停机 3–10 分钟并丢掉上传路径的内容(含 R1–R5 那条)。

## 改了什么

① app/service/knowledge_search_service.py:新增 _merge_sibling_subblocks()
   同一父块的兄弟子块只保留最高分那条;父块自身与 FAQ/政策这类本身就是细粒度答案
   的块一律不合并(它们之间打平是真的多个候选)。6 个单测守着。
   被丢掉的只是同节其它细节,本节完整内容由 _parent_hits 带回的父块兜底。

② tools/build_knowledge_chunks.py:修表头识别(markdown 表格只有紧邻 |---| 之前的
   那一行才是表头)+ 新增 assert_no_duplicate_contents() 自带守卫 ——
   正文完全相同的块必须为 0,否则中止且不写 jsonl。该脚本是一次性灌库脚本、
   原来没有单测覆盖,这正是该 bug 活下来的原因,所以守卫放在它自己的执行路径上。
   块数 636 → 617;正文完全相同的组 1 组 15 块 → 0 组。
   负向验证:换回旧逻辑跑,退出码 1 并报出那 15 份碎片,且未覆盖 jsonl。

③ tools/drop_table_header_vectors.py:清掉已灌进 Milvus 的 19 条历史碎片。
   判定可复现:语义 id(非纯数字)且正文不在修正后产物里。只动语义 id 是因为
   纯数字 id 来自上传路径、本来就不在 jsonl 里(实测冒烟 A 线的 top1 就是数字 id 179);
   不按长度判是因为短块本身是设计的一部分(「评审标准:管理人资质 15%」13 字是有效答案)。
   实删 policy 16 + product 3,faq 0;dry-run 逐条核对过,全部是「标签 + 表头词」形态。
   这是唯一一次绕过 Outbox 的删除:事件消费侧要回读 MySQL 行,而这批是无元数据种子向量。

## 验证

真实链路 10 问句回归 10/10 与预期一致,0 个变坏:
  风险评估问卷怎么评分  0.7156 gap 0.1473 → 答(原 0.002 转人工),top1 变成真实答案行
  场内基金的管理费率    0.8114 gap 0.0977 → 直接答(冒烟 A 线)
  南方季季盈90天起投金额 0.8631 gap 0.3387 → 直接答(行级子块精确命中能力未受影响)
  今天天气怎么样        仍正确转人工
端到端:ask_customer_service.py「风险评估问卷怎么评分」→ 直接答,返回完整评分标准;
e2e_smoke_test.py 44/44(含 A4 知识库覆盖未转人工);
pytest tests/unit tests/contract 1506 passed / 0 failed;
对账 重复正文 15 → 0,孤儿/死向量/缺向量仍全为 0。
(冒烟首跑 42/43 的唯一失败 B6 买入下单 503 是行情过期,补刷后 44/44,与本次无关。)

## 顺带查明

Milvus 的 delete() 是标记删除,约 1 秒后才在 query/search 中不可见(隔离临时集合实测:
删完立即查仍看得到,+1s 起消失)。清理工具第一版"删完立刻复核"因此谎报 19 条残留,
已改为轮询复核并把文案改成实测依据。get_collection_stats().row_count 在删除后仍显示旧值,
这也是对账工具坚持用 query 实际行数的原因。

## 文档

新增 docs/演示用/知识库检索质量修复-2026-09-15.md(根因、四方案对比、改动、全部证据);
并对 docs/演示用/知识库向量对账与清理-2026-09-15.md 做更正 —— 451 条短块里 429 条是
设计内的有效数据行、只有 19 条是 bug 产物;"按长度合并短块 + 重建重灌"的建议已被实测否决。

## 仍未解决(记录在案)

「高净值客户有什么权益」gap 0.0075 仍转人工:根因是不同父块之间同族内容打平
(金卡 vs 白金权益),父块归并救不了也不该救,要从内容侧或业务口径入手。
knowledge/_chunks.jsonl(新 617 块、编号连续)与 Milvus(旧编号、含 19 个空洞)目前不一致;
不重灌无影响,但下次重灌必须 drop 集合重建而不是 upsert,否则两套编号会共存。
2026-09-15 09:31:57 +08:00
lzf_0626 859c89898e 补充 purge --apply 的真机验证证据
对账当时报 0 条死向量,--apply 分支没有真实样本。手工造了一个真实死向量
(上传后等向量写好,直接改库置 expired 且不投删除事件)跑通闭环:
对账发现 1 条 → 投递成功 1/0 → Worker 消费 → 复核归零。
2026-09-15 09:08:02 +08:00
lzf_0626 58b73ff594 知识库三项收口:向量-元数据对账 + 导入侧幂等 + 过期行向量清理入口
① 只读对账 tools/reconcile_knowledge_vectors.py
   按集合列出:孤儿向量 / 死向量 / 缺向量 / 重复正文 / 低信息量碎片 / 纯标题。
   关键口径:非数字 id(FAQ-0013 这类语义 id)是灌库脚本有意写进 Milvus 的,
   单独归类、不建议删;向量数取自 query 实际行数,不用 get_collection_stats
   (后者含已软删未 compaction 的行)。

② 导入侧幂等:同 source_file + 集合重传 = 覆盖上一版
   app/service/knowledge_ingest_service.py 新增 _supersede_previous_version:
   把上一版 active 行置为 expired,并逐行投 knowledge.vector_delete_requested
   (与本次入库同事务)。写入侧只认 active 而检索侧不看 status,旧向量不清掉
   会继续参与排序、和同题活块抢答。
   顺带修掉一个真 bug:改为先判 chunks 非空再下线 —— 否则传一份解析出 0 块的
   文档会把上一版下架、新版一行没写,这份文档在检索侧凭空消失。

③ 清理入口:POST /api/v1/knowledge/{knowledge_id}/vector-cleanups
   给历史上"被别的途径置为 expired、从未投过删除事件"的行补投向量清理。
   DELETE 对已过期行返回 404 的口径保持不变(重复删除静默成功会让调用方
   分不清"这次真下线了"和"早就过期了"),因此新开一个语义明确的端点:
   不存在 404 / 仍是 active 422(请改用 DELETE)/ 已 expired 200 并回传事件名。
   配套 tools/purge_expired_knowledge_vectors.py(默认 dry-run)批量驱动该端点。

文档:docs/演示用/知识库向量对账与清理-2026-09-15.md(含真机验证输出),
并对 docs/演示用/知识库问答诊断-2026-09-14.md 做两处更正 —— 实测孤儿向量 0 条、
那 175 行历史副本从来没有向量(不参与排序),当时的差额来自 get_collection_stats
把已软删行算进去。

新发现(未修,需业务拍板):661 条向量里 451 条正文不到 40 字,是灌库时把
markdown 表格/标题切碎产生的碎片。「风险评估问卷怎么评分」实测前 4 名是 4 条
一模一样的 19 字碎片(gap 0.0024),真正 2828 字的答案排第 5 → 客服必然转人工。
属灌库切分缺陷,补内容救不了,也不应靠放宽 MIN_GAP 解决。

验证:pytest tests/unit tests/contract → 1500 passed, 2 skipped, 0 failed;
mypy app → 3 个错全在组员文件中(与本次改动无关);ruff 本次改动文件 0 错。
真机端到端:重传 → 旧行 expired + 删除事件 published + 旧向量已从 Milvus 删除;
两个问句回归仍正常回答(r1到r5 gap 0.0766;申购确认 0.8453)。
2026-09-15 09:06:57 +08:00
lzf_0626 64ad12e44a 修「r1到r5分别代表什么」答不出来:补一条标题对齐问法的短 FAQ(不动门槛策略)
## 现象
客服对「r1到r5分别代表什么」走兜底 + 转人工;「基金申购后多久能确认」用户以为也不答。

## 排查结论(详见 docs/演示用/知识库问答诊断-2026-09-14.md)
- 向量库**有**内容、检索也命中:申购确认那一问 top1 = **0.8468**(高置信,本来就答得出,
  库里留着 23:59:06 那次完整问答);R1–R5 那一问 top1 = **0.6621**、次优 0.6412。
- 判定规则:高置信(≥0.75)不看 gap;中置信(0.55–0.75)必须领先次优 ≥0.07
  (`customer_service.py:148-150`)。R1–R5 只领先 0.021 → 判"候选并列"。
- 根因是**同一主题多来源 + 命中块太长**:高频问答对(0.6621)、适当性指南第十一条三块
  (0.6412/0.5771/0.5520)、产品手册 1.4 节(0.4983) 分数天然挤在一起;
  而我先前上传的长条目(488 字切两块)被稀释到 0.6622,**改了两次才找到有效形态**:
  短条目 + 标题与问法逐字对齐 → **0.7384 / gap 0.0761 → 可答**。

## 本次改动
- 新增知识正文 `data/knowledge/faq_r1r5.md`(口径取自 `suitability_service.MATRIX_ALLOWED` /
  `MATRIX_NEEDS_DISCLOSURE`,避开禁用词)。
- 新增幂等工具 `tools/seed_knowledge_r1r5_faq.py`:默认 dry-run,`--apply` 上传;
  已存在同源未过期知识则跳过,并自动复测检索评分。
- 真机执行:删除 3 个试验块(200/201/202)→ 上传 id 203 → 端到端验证。

## 验证
- `tools/seed_knowledge_r1r5_faq.py`(幂等路径)→ `top1=0.7384 次优=0.6623 gap=0.0761 可答`
- 端到端(登录客户 + 访客两条路径):「r1到r5分别代表什么」给出五级含义 +
  C1–C5 对应关系;「基金申购后多久能确认」给出 T+1/T+2 确认规则
- 回归 4 条同类问题仍高置信直接答:费率 0.8111 / 场内场外 0.7871 / 最小买多少 0.8289 /
  赎回到账 0.8480

## 顺带查清(登记为已知问题,未擅自改)
1. **向量与元数据严重不一致**:Milvus 159/397/297 个向量 vs MySQL 41/161/**0** 行;
   答得最好的 `faq/高频问答对.txt`(含 FAQ-0015)与两套政策文件**只在向量里、MySQL 无行**。
   ⇒ 这**推翻**了我先前"检索按 active 过滤"的建议(会把孤儿但有用的内容一起杀掉),
   已在文档里明确撤回。
2. **检索层不看知识状态**:`knowledge_search_service.py` 里 `status` 出现 0 次 →
   已 expired 的历史副本照样参与排序(费率这类会变的内容有被答旧值的风险)。
3. **重复与测试垃圾在抢答,且没有清理入口**:产品手册被种了 7 遍(199 行里只有 24 active)、
   FAQ 集合 41 行里 38 行是上传链路测试残留;而 `DELETE /api/v1/knowledge/{id}` 对已 expired 行
   一律 404「知识文档不存在」→ 清理历史副本目前无入口。
2026-09-15 08:47:10 +08:00
lzf_0626 b53b4e0bc7 修「r1到r5分别代表什么」答不出来:补一条标题对齐问法的短 FAQ(不动门槛策略)
## 现象
客服对「r1到r5分别代表什么」走兜底 + 转人工;「基金申购后多久能确认」用户以为也不答。

## 排查结论(详见 docs/演示用/知识库问答诊断-2026-09-14.md)
- 向量库**有**内容、检索也命中:申购确认那一问 top1 = **0.8468**(高置信,本来就答得出,
  库里留着 23:59:06 那次完整问答);R1–R5 那一问 top1 = **0.6621**、次优 0.6412。
- 判定规则:高置信(≥0.75)不看 gap;中置信(0.55–0.75)必须领先次优 ≥0.07
  (`customer_service.py:148-150`)。R1–R5 只领先 0.021 → 判"候选并列"。
- 根因是**同一主题多来源 + 命中块太长**:高频问答对(0.6621)、适当性指南第十一条三块
  (0.6412/0.5771/0.5520)、产品手册 1.4 节(0.4983) 分数天然挤在一起;
  而我先前上传的长条目(488 字切两块)被稀释到 0.6622,**改了两次才找到有效形态**:
  短条目 + 标题与问法逐字对齐 → **0.7384 / gap 0.0761 → 可答**。

## 本次改动
- 新增知识正文 `data/knowledge/faq_r1r5.md`(口径取自 `suitability_service.MATRIX_ALLOWED` /
  `MATRIX_NEEDS_DISCLOSURE`,避开禁用词)。
- 新增幂等工具 `tools/seed_knowledge_r1r5_faq.py`:默认 dry-run,`--apply` 上传;
  已存在同源未过期知识则跳过,并自动复测检索评分。
- 真机执行:删除 3 个试验块(200/201/202)→ 上传 id 203 → 端到端验证。

## 验证
- `tools/seed_knowledge_r1r5_faq.py`(幂等路径)→ `top1=0.7384 次优=0.6623 gap=0.0761 可答`
- 端到端(登录客户 + 访客两条路径):「r1到r5分别代表什么」给出五级含义 +
  C1–C5 对应关系;「基金申购后多久能确认」给出 T+1/T+2 确认规则
- 回归 4 条同类问题仍高置信直接答:费率 0.8111 / 场内场外 0.7871 / 最小买多少 0.8289 /
  赎回到账 0.8480

## 顺带查清(登记为已知问题,未擅自改)
1. **向量与元数据严重不一致**:Milvus 159/397/297 个向量 vs MySQL 41/161/**0** 行;
   答得最好的 `faq/高频问答对.txt`(含 FAQ-0015)与两套政策文件**只在向量里、MySQL 无行**。
   ⇒ 这**推翻**了我先前"检索按 active 过滤"的建议(会把孤儿但有用的内容一起杀掉),
   已在文档里明确撤回。
2. **检索层不看知识状态**:`knowledge_search_service.py` 里 `status` 出现 0 次 →
   已 expired 的历史副本照样参与排序(费率这类会变的内容有被答旧值的风险)。
3. **重复与测试垃圾在抢答,且没有清理入口**:产品手册被种了 7 遍(199 行里只有 24 active)、
   FAQ 集合 41 行里 38 行是上传链路测试残留;而 `DELETE /api/v1/knowledge/{id}` 对已 expired 行
   一律 404「知识文档不存在」→ 清理历史副本目前无入口。
2026-09-15 08:46:38 +08:00
lzf_0626 01e4e6a687 修掉"Worker 会自己退出"的真缺陷(场外游标续租失败误取消主任务)
## 现象(2026-09-14 实测,非人为停止)
Worker 进程自己退出,退出码 1,日志末尾是
`ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个现有的连接`
(发生在 `offsite_worker.close()` 的 IMAP `logout()`),
而真正的起点是更上面那句 `asyncio.exceptions.CancelledError`。

## 根因(两处,都是真缺陷)
1. **取消错了对象**:`OffsiteMailWorker._process_batch` 把
   `asyncio.current_task()`(= `__main__.serve()` 的**主循环任务**)交给游标心跳,
   心跳在续租失败(`rowcount != 1`)或续租抛异常时执行 `task.cancel()` ——
   于是"放弃这一批"变成了"**杀掉整个 Worker**"。`CancelledError` 从 `run_once()`
   一路冒到 `serve()`,主循环直接结束。
2. **收尾异常盖掉退出原因**:`serve()` 的 `finally` 里 `await offsite_worker.close()`
   在网络已断时抛 `ConnectionResetError`,把 `CancelledError` 顶掉,
   表现为"关闭流程崩了",看不出真实原因。

## 改法
- `_process_batch` 把"这一批"跑在**独立任务** `batch` 里,心跳只取消 `batch`;
  调用方捕获 `CancelledError` 后区分两种情况:
  **本批被放弃**(`batch.cancelled()` 且当前任务自己没有在取消)→ 记 warning、返回 `False`、
  Worker 继续下一轮;**外层在取消当前任务**(Ctrl+C / 进程关闭)→ 原样上抛,绝不吞掉。
- `serve()` 的 `finally` 里关闭场外 Worker 包 try/except:收尾失败只记日志,
  **不改变退出码与退出原因**。
- 新增 `_current_task_is_cancelling()` 用 `Task.cancelling()` 做这个区分(3.11+)。

## 守卫
`tests/unit/worker/test_offsite_mail_worker.py::test_cursor_lease_loss_abandons_batch_without_cancelling_the_worker`
—— 续租失败(rowcount=0)时断言:批次确实跑起来过、返回 `False`、
**调用方任务没有被取消**。修复前这条用例会挂在"调用方被取消"上。

## 验证
- `pytest tests/unit/worker` → 112 passed
- `pytest tests/unit tests/contract` → 见下方(0 failed)
- `mypy app/worker/offsite_mail_worker.py app/worker/__main__.py` → 0 错(除组员文件里既有的 1 个)
- 重启 Worker 后跑记忆演示链路:候选 → verified → active → **2 秒**收敛,进程稳定

## 文档
`docs/演示用/记忆系统演示文档-2026-09-14.md`:
场景五补"如果发现 Worker 自己退了是怎么回事",问答补"Worker 会不会自己中途退出"。
2026-09-15 08:01:00 +08:00
lzf_0626 9490e5043b 记忆系统演示文档 + 修掉两处会让演示断链的真问题
## 新增:docs/演示用/记忆系统演示文档-2026-09-14.md
按"操作 → 看到什么 → 这体现什么"写,五个场景(探针看存储 / 记住一件事 / 谁能读谁读不到 /
画像版本与投影 / 停 Worker),每个场景配可复现命令与**实测输出**,另附建议顺序与时长、
六条问答话术、演示前自检清单、受控信号词表摘录。

配套两个新工具(都已实跑):
- `tools/memory_demo_chain.py`:一键走完"客户说 → 候选 → 客户确认 → 管理员批准 →
  记忆与画像自动收敛",打印演示前后对比(实测 2 秒收敛)。
- `tools/memory_recall_demo.py`:同一客户换六个身份召回,当场看出"客户只读自己 /
  员工要权限码+归属 / 运营读不到 / 管理员有权限码但没归属也读不到"。

## 修掉两处会让演示当场断链的问题(都是真机复现的)

1. **候选批准后画像字段不收敛**
   `CustomerProfileCandidateService._promote` 只写画像快照、**不投**
   `profile.rebuild_requested`,而 `user_facts` 与画像字段是 `ProfileAssemblyService`
   的重建链路写的。后果:批准后记忆变 active、快照版本 +1,但**画像字段停在旧值**
   (客户说"三年以内",画像里还是"十年以上"),要等一次无关的重建才收敛 ——
   而"客户说完 → 批准 → 画像变了"正是演示主线。
   现补一条重建事件(走事件而不是就地重建:本方法所在事务还没提交,
   另开 session 看不到刚写入的记忆)。

2. **画像快照的唯一键从来没起作用,而且埋雷**
   `uk_profile_snapshot_current` 建在列 `current_customer_id` 上(不是 `is_current`),
   但 `ProfileAssemblyService._write_snapshot` 旧行只置 `is_current=False`(不清该列)、
   新行**不写**该列(实测 13 行该列全 NULL)。
   后果:只要客户**先被重建过一次**,旧 current 行仍占着 `current_customer_id=9001`,
   下一次"批准画像候选"就会撞 `Duplicate entry '9001' for key
   uk_profile_snapshot_current` → **整次批准 500**(本次实测踩到)。
   现按唯一键的真实语义写:清旧行的该列、新行显式写客户号。
   (`ProfileGenerationService` / 候选路径本来就是这么写的,只有这一处没对齐。)

## 守卫
新增 `tests/integration/test_profile_snapshot_current_invariant_mysql.py`:
按真实顺序"先重建再批准候选",断言 ① 只有一条 current 且它占着唯一键、
② 历史版本已归还该列、③ 批准不再 500、④ 批准投出了重建事件。
修复前这条用例会在第 ② 步失败。

## 验证
- `pytest tests/unit tests/contract` → 1490 passed, 2 skipped, 0 failed
- 新增集成用例通过;真机实测演示主线:候选 → verified → active → **2 秒内**
  `user_facts` 与 `fin_customer_profile.investment_horizon` 都变成新值
- `mypy tools/memory_demo_chain.py tools/memory_recall_demo.py` → 0 错;ruff 全绿

## 文档
`docs/44-演示流程.md`:配套文档清单与"记忆链路"备选场景都指向新演示文档。
2026-09-15 00:55:44 +08:00
lzf_0626 076d786bc6 补齐客服转人工工单流:从"只能看"到"能推进"(基线状态机,不自行发明)
## 问题
`svc_handover_ticket` 的 DDL 与状态机在 `docs/02` §7.2 早就定好了
(pending → assigned → processing → resolved → closed,未解决可 cancelled),
但平台**只有 handover:read(只读队列)**:没有任何入口能改状态、assigned_to /
accepted_at / resolved_at / closed_at / resolution 五列**全库 0 非空**,
于是 40 张工单永远停在 pending —— 用户看到的就是"工单全都长一样"。

## 改了什么
后端:
- 新增 `app/service/customer_service_handover_action_service.py`:五个动作
  (分配/接单/解决/关闭/取消),`SELECT ... FOR UPDATE` 锁单后判状态;
  接单允许从 pending 自助接管(同时记受理人);取消不写 closed_at(该列属 closed 状态);
  每次流转写一条 interaction_audit(handover.assigned/accepted/resolved/closed/cancelled);
  非法流转 409、坐席不存在 422、工单不存在 404;回包不含 customer_id/session_id。
- 只读服务保持只读(读侧与写侧是两条边界,单测守着"读侧不许长出写方法"),
  但列表支持 `?status=` 六态筛选、详情补上受理人与流转时间(坐席侧路由信息,非客户数据)。
- `app/api/controllers/admin.py`:五个 action 端点 A049–A053
  (assignments / acceptances / resolutions / closures / cancellations),
  走 `ApiTransactionService.execute_in` —— 幂等记录与业务写入同事务、重复键回放。
- 权限:新增 `handover:write`(9069,只授 admin),已并进种子
  `tools/seed_test_rbac.py`;配套幂等脚本 `tools/grant_handover_write_permission.py`。

前端(管理员工作台 · 转人工工单页):
- 按状态给按钮(待处理→分配/直接接单、已分配→接单、处理中→解决、已解决→关闭、
  未解决都可取消),加了状态筛选与"刷新";摘要弹窗补上受理人与四个时间点、处置结论。
- api-client 注册五个端点;workspace.js 的 api-client 引用与页面自身的 ?v= 一并升版,
  避免浏览器拿旧缓存(旧缓存里没有这些端点)。

冒烟与测试:
- `tools/e2e_smoke_test.py`:B 段建的测试工单由 F 段走完 分配→接单→解决→关闭 收尾
  —— 既不再把测试件堆在 pending 队列里(此前每次冒烟攒一张),又让每次冒烟都覆盖一遍状态机。
  总数 40 → 44 项,实测 44/44 全绿。
- 新增单测 24 条(状态机合法/非法路径、越权、坐席不存在、审计、视图不泄漏客户标识)
  与一条真机集成用例(HTTP 十步 + 数据库侧审计证据 + 自动清理)。
- 读侧那条"详情不得返回 assigned_to"的旧断言按新口径更新,并写清为什么。

## 验证
- `pytest tests/unit tests/contract` → 1489 passed, 2 skipped, 0 failed
- 新增集成用例通过;`tests/integration` 全量跑时
  `test_memory_extraction` / `test_run_cancellation_mysql` 两条偶发红 —— 单独跑都通过,
  是 AGENTS.md 已登记的"常驻 Worker 抢队列"(跑验收前须先停 Worker)
- `tools/portal_api_check.py` → 41 项通过 39、失败 0
- `tools/e2e_smoke_test.py` → 44/44 全通过
- `python tools/check_rbac_seed_consistency.py` → 通过(种子 63 条权限)
- 真机 HTTP 实测:分配→接单→解决→关闭四步 200 且时间戳齐全;取消路径 200 且 closed_at 为空;
  同键重发回放不二次推进;对已关闭工单再分配 409;风控账号处置 403

## 文档
`docs/44-演示流程.md`(场景 4/8 + 命令 + 44 项)、`docs/演示用/后端接口文档`(新增 §11.4b 与
A049–A053)、`docs/演示用/全功能流程-大白话版.md`(工单页签改"读写"+ 已知偏差)、
`AGENTS.md`(9066-9069 号段演进 + 冒烟 44 项)
2026-09-15 00:41:59 +08:00
lzf_0626 ed59e93b53 管理员配置发布:点「内容」后在视口里看不到变化(面板渲染在 20 条列表下方),改为渲染后滚动定位 + 标题用发布编号 + 空列表给显式空态 2026-09-15 00:21:24 +08:00
lzf_0626 0e68271a81 docs/44:把一份被移动过的引用改成不依赖目录(代码库全面审查报告) 2026-09-15 00:16:56 +08:00
lzf_0626 449d078a02 新增《记忆架构与底座 Worker 工作流程 · 大白话版》
两部分,各讲一件事,接口处互相对齐:

第一部分 · 记忆架构
- 四个存储的分工:MySQL 是唯一账本(memory_unit / memory_evidence / memory_conflict /
  user_facts / fin_customer_profile / profile_snapshots),Neo4j 存关系、Milvus 存向量
  (集合 user_long_term_memory_v1 是代码常量)、Redis 只做加速(召回热缓存 300 秒 /
  客服短期会话 30 分钟滑动)
- 一条记忆的一生七步;为什么画像重建要绕事件;investor_type 只来自风险测评
- 客服走"候选画像"两把钥匙(客户确认 verified → 管理员批准 active);不抽记忆的三种情形
- 召回三路(MySQL 结构化 + Milvus 语义 0.7 权重 + Redis 热缓存),没有"图召回"这一路;
  当前只有风控 Agent 消费召回结果
- 可读范围一个文件说了算(客户只自己;员工要 memory:read:customer 且归属;上限 10)
- 失效/删除链路与"清不掉就如实留痕"
- 实测表行数 + 一条要如实说明的观察:9001 的向量在 Milvus 里,但 memory_sync_outbox
  当前没有它的行,不能据此断言投影链路正常
- 八条已知遗留逐条核实(episode 幂等哈希不覆盖记忆内容、投顾两处不发 memory_sources、
  风控 system 上下文召回恒空、current_customer_id 从不写入致唯一键形同虚设、user_facts
  无唯一键、两个未接线组件、文档过期)

第二部分 · 底座 Worker
- 三段式(受理 202 → 排队 → Worker 执行)与"不跑就没有任何报错"
- 一轮 run_once 的四件事与顺序、单轮上限、每 30 轮才做的片段聚合、失败隔离
- 五条工作线逐个讲:Agent 运行(状态机/领取加锁/租约 60s+心跳 20s/重试 3 次/取消收口/
  执行前重新解析身份)、领域事件(12 类、幂等两层、重试 5 次进死信、退避公式)、
  记忆与画像投影、会话片段与知识向量、场外收件(四道闸门 + 游标租约 + blocked)
- 显式降级清单(不伪造成功)
- 怎么判断它活着/卡住/积压:看表 + 看日志关键词 + 当前死信实况(570 条 dead 的构成)
- 三个常被搞错的边界(风控定时扫描是独立进程且默认关;AgentRunWorker 生产未用;
  GraphProjectionWorker / ProjectionReconciliationService 未接线)+ 一页速查
2026-09-15 00:16:35 +08:00
lzf_0626 42a330c4c9 docs/44 演示流程按 2026-09-14 实测同步:今日盈亏可讲、投顾自助审核、管理员八页签、权限数与已知空数据 2026-09-15 00:14:20 +08:00
lzf_0626 776b504ee6 新增《全平台功能流程 · 大白话版》:从登入讲到六门户全部流程
- 读者是第一次接触平台的人:全文不用"链路/编排/收敛"这类词,讲"点一下之后发生了什么"
- 结构:三个角色(浏览器/API/Worker)→ 访客 → 登入与角色落地 → 客户 8 页 → 风控 →
  投顾 → 运营三条线 → 管理员 8 个页签 → 底座 9 件事 → 一条主线时间线 → 常见疑问 → 速查
- 每节都标了文件/接口依据,并写清"已知空数据"与"已知小偏差"(通知类型下拉未接线、
  NL2SQL 错误信息列恒为 --、推广页无 form 导致 required 不生效、场外默认 dry-run 等)
- 顺带纠正三处易误传的说法:幂等键只防"同一次请求重发"(连点仍会下两笔)、
  客户侧前端权限门是装饰性的(后端 403 才是拦截)、FM-03 熔断目前只在前端拦
- 风控演示数据那 6 行 RISKDEMO 场外申购/赎回:代码注释与今日盈亏说明改口径为
  "风控异常交易演示的触发材料,刻意保留",不再当脏数据;今日盈亏按 transaction_type 语义排除它们
2026-09-15 00:10:23 +08:00
lzf_0626 9dd2802d67 客户看板「今日盈亏」不再是硬编码 0:按行情/净值基准真实计算(含当日买卖)
- 口径:今日盈亏 = 今日市值 − 昨日持仓市值 − 今日买入金额 + 今日卖出金额(不含费用,与「持有盈亏」同口径)
- 昨日持仓数量由当日成交反推,不需要新表新字段
- 基准优先场内行情最近两个交易日收盘价(与同页 latest_price/market_value 同源、客户可核对),
  行情只有一天时回退基金净值(15911/159991-159995 的行情本身就来自净值序列)
- 只认 transaction_type ∈ {买入,卖出}:演示库里混进的风控场外申购/赎回(RISKDEMO-*)
  会把「昨日持仓数量」抬到 76 万份、今日盈亏从 -21.22 变成 -1612.72
- 接口字段与前端一行未改:HoldingItem.today_profit_loss / PortfolioSummary 两个字段数值变真
- 新增 tools/check_today_profit_loss.py:纯 SQL 独立复算并与接口逐只比对(实测 9001 = -132.70 / -0.2528%)
- 新增 6 条单测;pytest tests/unit tests/contract → 1466 passed, 0 failed
2026-09-15 00:03:23 +08:00
lzf_0626 48b43cfad5 投顾推荐空候选池:把"为什么没有产品"讲清楚,而不是一句"没有通过校验的产品"
"当前没有通过适当性与证据校验的产品。" 这句话对运营毫无信息量:既不知道是谁的问题,
也不知道下一步该做什么。现在空态会说明证据门口径(每个产品都要求 ①销售机构的**已验证**
适当性证据 ②基金合同快照,且都要带可核查的来源链接与文档 sha256,缺一即整只排除,
fail closed),并给出本轮候选/入选/排除的数量,以及两条可执行路径:

- 真实方案:先由产品治理线导入适当性证据与合同快照
  (	ools/import_product_governance_reference.py,CSV 需带 source_url 与 document_sha256);
- 呈现效果:切到页面顶部「本地演示数据」,那里的方案会标注"规则模拟、不构成真实推荐"。

同时说明本环境为空的原因:dvisor_product_suitability_reference 与
dvisor_product_contract_snapshot **均为 0 行**(26 个上市产品因此全部被证据门排除,
candidate_count=0,所以连"排除原因"也没有可展示的条目)。
2026-09-14 23:34:36 +08:00
lzf_0626 467d2b5169 投顾可自助审核/发布自己生成的推荐方案(原先只有管理员能推进草案)
## 现象与根因

投顾工作台生成推荐方案后,草案停在 `pending_review` 且投顾无法推进:

- 服务层 `review` / `publish` 都带 **`admin=True` 角色闸门**
  (`product_recommendation_service.py:286/317`),即使投顾角色**已经持有**
  `product-recommendation:review` / `:publish` 两个权限码也一律 403;
- 审核/发布端点只注册在 **admin 路由**下(`/api/v1/admin/advisor/...`),
  投顾侧根本没有对应入口;
- 投顾工作台也没有审核/发布按钮(`published-module.js` 原注释即写着
  "发布动作要求管理员,投顾侧只读")。

于是业务上"让投顾自己审核"完全做不到,必须切管理员账号。

## 修法(三处配套,安全边界保留)

1. `app/service/product_recommendation_service.py`
   - `review` / `publish` 去掉 `admin=True`,**只按权限码判定**
     (`product-recommendation:review` / `:publish`,目前仅 advisor 与 admin 持有);
   - `reviewer_user_id` 照旧如实落库,审计可追;
   - 注释写明:若要回到"四眼原则/管理员专属",把 `admin=True` 加回即可。
2. `app/api/controllers/recommendations.py`
   - 新增投顾侧路由 `POST /api/v1/advisor/recommendations/{id}/reviews`
     与 `.../publications`(与 admin 路由调用同一服务方法)。
3. 前端
   - `common/api-client.js`:注册 `ADVISOR_REVIEW_RECOMMENDATION` /
     `ADVISOR_PUBLISH_RECOMMENDATION`;
   - `employee-advisor/dashboard/actions-module.js`:结果区在拿到 `content_id` 后
     给出「审核通过 / 驳回 / 发布给客户」按钮(结果区是 `innerHTML` 重建的,
     所以每次渲染后重新绑定);审核通过后就地换成「发布给客户」;
   - `published-module.js`:监听 `advisor:published-refresh`,发布成功后列表自动刷新。

## 未放宽的部分(有意保留)

- **管理面复核队列** `GET /api/v1/admin/advisor/pending-contents` 仍为
  `admin=True` 专属 —— `tests/integration/test_advisor_review_queue_mysql.py`
  里"投顾读不到该队列"的断言**未改动**;
- 客户/风控/运营角色不持有这两个权限码,因此不受影响。

## 验证(真实 HTTP,9020 身份)

```
① 生成推荐方案(客户 9001)→ content_id=19, pending_review
② 投顾自助审核通过          → HTTP 200 status=approved     (改前 403)
③ 投顾自助发布              → HTTP 200 status=published
④ 已发布列表                → 含 id=19 ✅
```

新增回归测试 `test_advisor_can_review_and_publish_own_recommendation`
(客户缺测评/目标时 `pytest.skip` 并说明是数据前置,不误判为权限失败)。

## 门禁

- `pytest tests/unit tests/contract` → 1458 passed;
- `pytest tests/integration` → 111 passed + 1 例
  `test_worker_runtime_mysql::...repeat[False]` 失败,**经复跑确认是 AGENTS.md 记载的
  "常驻 Worker 抢队列",停掉常驻 Worker 后该用例 2 passed**,与本次改动无关;
- `ruff` 干净;三个 JS 文件 `node --check` 通过。
2026-09-14 23:27:44 +08:00
lzf_0626 5da2fb5790 投顾代客三项的权限码缺失:整片 403(与 9057-9059 同一个坑的第二批)
## 现象

投顾工作台(`advisor_t`,9020)一选客户就整片失败,页面只显示
「请求失败,请检查权限或稍后重试」。API 访问日志给出真相:

```
POST /api/v1/advisor/recommendations     403
POST /api/v1/advisor/asset-allocation    403
POST /api/v1/advisor/portfolio-analysis  403
GET  /api/v1/advisor/recommendations/published  200   ← 只有它不需要代客权限
```

## 根因:三个 `*:customer` 权限码在库里根本不存在

三个服务在"代客"(`customer_id != context.user_id`)时要求的是**动态拼出来的
`:customer` 变体**:

- `product_recommendation_service.py:71` → `product-recommendation:generate:customer`
- `asset_allocation_service.py:57`       → `asset-allocation:generate:customer`
- `portfolio_analysis_service.py:46`     → `portfolio-analysis:read:customer`

而 `sys_permission` 里 `:customer` 后缀**只有 4 个**(`memory:read:customer`、
`investment-goal:{read,write,confirm}:customer`)—— 这三个从来没登记过。
`AuthorizationService.require()` 第一步就查不到该码 ⇒ 直接 403。

这与种子里 **9057-9059 的注释是同一个坑**("动态拼出来的权限码,对账工具抓不到字面量,
于是投顾查/建/确认客户目标全部 403"),前人修了 investment-goal 那三个,这三个漏了。

## 修法(按仓库纪律:种子定义权限码 → grant 脚本绑定角色)

1. `tools/seed_test_rbac.py`:新增 **9066-9068** 三个码,`data_scope=own_customers`
   (`require_customer_scope` 只放行 `all`,或 `own_customers` 且客户确在
   `context.customer_ids` = 归属客户内 —— 这正是投顾该有的最小权限),
   并写明三处调用点,避免后人再漏;
2. `tools/grant_advisor_role.py`:把三个码加入 `ADVISOR_GRANTED_CODES`
   (admin 因 `ADMIN_PERMISSIONS = 全部种子权限` 自动获得)。

已执行:`seed_test_rbac.py` → `grant_advisor_role.py`
(advisor 新增 3 项,共 31 项;admin 62 项)。

## 验证(真实 HTTP,9020 身份)

| 客户 | 推荐方案 | 资产配置 | 组合分析 |
|---|---|---|---|
| 9101(演示客户) | 200 `profile_required` | 200 `profile_required` | 200 `no_positions` |
| **9001(真实客户)** | **200 已生成方案**(content_id=5, pending_review) | **200 ready**(含配置比例) | **200 ready**(7 个持仓,市值 10698.60) |

**403 全部消失**;对真实客户三项均返回真实结果。

## 同时补的归属数据

`sys_customer_assignment` 原有 9020→9001 一行;工作台把演示客户的 id
(9101-9104)当真实 `customer_id` 发给后端,而 `own_customers` 要求客户在投顾名下,
因此用 `tools/assign_customer_scope.py` 补了 4 行(9020 → 9101/9102/9103/9104),
回验 `customer_ids = ('9001','9101','9102','9103','9104')`。

## 仍未解决(属**数据**缺口,不是权限)

1. **9101-9104 在库里没有任何数据**:`advisor-config.js:126` 注释指向的
   `_seed_demo_customers.py` 在仓库、git 历史与桌面上**都不存在**(从未提交),
   所以这四位没有账号 / 风险测评 / 投资目标 / 持仓 —— 只能返回 `profile_required`。
2. **推荐候选恒为 0**:`AdvisorProductRepository.authoritative_tradable_products`
   要求**已验证**的适当性证据与合同快照(`review_status='verified'` 且
   `source_url` / `document_sha256` 非空,fail closed),而
   `advisor_product_suitability_reference` / `advisor_product_contract_snapshot`
   均为 0 行。这需要产品治理线提供可核查证据,**不应伪造**。

## 门禁

`tools/check_rbac_seed_consistency.py` 通过(种子内 id 唯一、各 grant 脚本与种子逐条一致);
`pytest tests/unit tests/contract` 全绿。
2026-09-14 22:56:42 +08:00
lzf_0626 d8e47df20c 推介材料:勾选「展示排名」后无法填写来源 → 必然合规阻断(死锁)的修复
## 现象(你的截图)

任务 `PM-20260914-0007` 的合规检查结果为:

```
业绩排名来源不满足要求
补充三年期以上公开评价数据来源 · block
```

输入快照里的实证:

```json
ranking = {"enabled": true, "ranking_text": null, "public_source": null,
           "institution_name": null, "evaluation_period_years": null}
```

## 根因:勾选框有了,填来源的地方没有

`promotion_compliance.py:82-91` 的规则是:

```python
if ranking.get("enabled"):
    years = _years(ranking.get("evaluation_period_years"))
    if years < 3 or not ranking.get("institution_name") or not ranking.get("public_source"):
        → block "performance.ranking_source_invalid"
```

而前端**只有**一个复选框 `performance_info.ranking.enabled`:

- 表单里**没有** `institution_name` / `public_source` / `evaluation_period_years` /
  `ranking_text` 四个输入(实测枚举全部 `data-promo-field` 只有那一个 ranking 字段);
- `inputsPayload()` 也只提交 `ranking: { enabled }`。

⇒ 运营一旦勾选「展示排名」,来源四项永远是 null,规则必然阻断,
而界面上**无处可填** —— 要么取消勾选,要么永远生成不了。这是**死锁**,不是数据问题。

## 修法(前后端契约字段本就有,只补界面与提交)

1. `promotion/index.html`:在展示选项上方新增一组输入(`data-ranking-source`):
   评价期间(年,需 ≥3)/ 评价机构 / 公开来源 / 排名文本;
2. `promotion/promotion.js`:`inputsPayload()` 的 `ranking` 补上这四个字段
   (契约 `RankingInfo` 本就定义了 `institution_name` / `evaluation_period_years`
   / `ranking_text` / `public_source`,`evaluation_period_years` 是字符串,如 "3年")。

## 验证

- 用**真实任务 0007 的输入**跑 `PromotionComplianceChecker.check_inputs()`:
  - 现状:3 条阻断(`history_short` + **`ranking_source_invalid`** + `data_attachment_missing`);
  - 把来源填全后:**`ranking_source_invalid` 消失**(其余两条由"未上传业绩文件"引起,上传即解);
  - 反例:评价期间改成 2 年 → 仍拦;3 年但缺公开来源 → 仍拦(规则没有被放松);
- 从源文件抽出 `inputsPayload()` 用桩真实调用:四个来源字段都进 payload;未勾选时 `enabled=false`;
- `index.html` 标签净增 +1 `<div>` / +1 `</div>`(结构平衡);`node --check` 通过;
- `pytest tests/unit tests/contract` → 1458 passed, 2 skipped, 0 failed。

## 附带说明

你看到的是"2 条"是因为**点了两次生成**:每次生成都会把该次的 findings 落库,
页面"读取合规结果"会把历史记录一并列出(不是一次调用重复产出)。
2026-09-14 22:45:57 +08:00
lzf_0626 e6147bb6ec 推介材料:中文文件名让上传请求头非法 → 两个附件都"网络连接失败"(服务端一条记录都没有)
## 现象

上传 `业绩数据1.xlsx` 与 `基金经理1号王建龙.png`(扩展名都在白名单内):
页面提示 `业绩数据文件上传失败:网络连接失败;基金经理照片上传失败:网络连接失败`,
而库里、盘上、审计里**都没有任何上传痕迹**。

## 排查与根因

1. API 是健康的(`/portal/` 200、129 条路由在、8000 正常监听);
2. `api_client` 里 "网络连接失败" 的触发条件是 **`fetch` 抛了非超时的异常**(超时会显示"请求超时");
3. **`api_request_receipt` 里没有任何上传记录** —— 同一时段的建任务/存资料/生成三条都有 receipt
   (连"生成未通过合规校验"这种业务失败也落 receipt)⇒ **上传请求根本没到应用**;
4. `promotion.js` 给上传传的幂等键是 `` `${taskNo}-${type}-${file.name}-${file.size}` `` ——
   **把中文文件名拼进了 HTTP 头 `Idempotency-Key`**;
5. HTTP 头值只能由 ≤0xFF 的码点组成:实测同一请求用标准客户端发送时抛
   `UnicodeEncodeError: 'ascii' codec can't encode characters in position 34-45`;
   浏览器更严格,`fetch` 在**构造请求头时直接抛 `TypeError`**,请求一个字节都没发出去,
   却被 `api-client.js` 的 catch 包装成"网络连接失败"——**"参数非法"伪装成了"网络故障"**。
6. 反证:换成纯 ASCII 的幂等键,同两个文件、同一端点**立刻 200 成功**
   (attachment_id=67/68,`performance_summary` 正常返回)。

## 修法

1. `employee-operations/promotion/promotion.js`
   - 新增 `asciiOnly()` / `uploadKey()`:幂等键改为 `任务号-类型-字节数-最后修改时间`
     (**刻意不用文件名** —— 中文名即非法头值),纯 ASCII 且同一文件重传得到同一键(幂等回放);
2. `common/api-client.js`
   - 对幂等键做**前置校验**(平台规范:16-128 位可打印 ASCII),不满足时抛
     `IDEMPOTENCY_KEY_INVALID` 并**带上端点与键值** —— 下一次这类问题 10 秒可定位,
     不必再从"网络故障"倒推。

## 验证

- 从源文件抽出 `asciiOnly`/`uploadKey` 用 Node 断言:中文文件名 → 键全 ASCII 且通过校验、
  同一文件两次同键、不同文件不同键、任务号含中文也被转义、旧写法会被拦下(全部通过);
- `node --check` 两个文件通过;
- 真实 HTTP(带令牌、真实文件)验证:ASCII 键 → **HTTP 200**,中文键 → 客户端抛 `UnicodeEncodeError`;
- 该账号的两个附件已成功入库(id=67/68),业绩摘要 = `as_of_date 2025-12-31 /
  history_months 23 / product_return 17.1% / max_drawdown -1.15%`;
- `pytest tests/unit tests/contract` 全绿。
2026-09-14 22:36:49 +08:00
lzf_0626 84bf51c0d0 NL2SQL:5 位产品代码被丢弃导致"查询成功但数据错"(演示产品 15911 正是 5 位)
## 现象

通用自然语言查询里问「查询15911的净值」,返回的是**别的产品**的数据:

```
问:查询15911的净值          → 返回 511810 货币ETF南方 的净值
问:查询基金代码15911最近30天净值 → 同上
```

而且 `status = success`、`message = 查询成功` —— **不报错,只是数据是错的**。

## 根因

`financial_nl2sql_service._filters()` 只认 6 位数字:

```python
re.findall(r"(?<!\d)\d{6}(?!\d)", question)      # 15911 只有 5 位 ⇒ 被静默丢弃
```

过滤条件为空 ⇒ 生成的 SQL **没有产品过滤**(`WHERE 1=1 ... GROUP BY ... LIMIT 50`)
⇒ 返回一批无关于问题的产品净值。现库实测:**6 位代码 25 个、5 位代码 1 个**
(后者就是演示产品 `15911`)—— 所以这个缺陷精确地打在了演示产品上。

`tests/unit/service/test_financial_nl2sql_golden_cases.py` 里 30 条黄金用例**全是 6 位代码**
(159511/588890/159948),因此从未暴露。

## 修法

`app/service/financial_nl2sql_service.py`:

- 产品代码模式放宽为 **5–6 位**(`(?<!\d)\d{5,6}(?!\d)`);
- 5 位数字更容易与"金额/数量/天数"撞车(6 位不会),因此加单位守卫:
  紧跟 `元/万元/万/亿/份/股/手/天/日/周/个月/年/次/笔` 的一律**不当**产品代码
  (例:「申购金额50000元」里的 50000);
- 抽成 `_product_codes()` 便于单测。

## 验证

- `query("查询15911的净值")` → SQL 含 `p.product_code = :filter_0`、参数 `15911`、
  返回行 `{"product_code": "15911", "product_name": "15911 自定义产品", "nav": "1.074596"}`;
  三种问法("查询15911的净值"/"查询基金代码 15911 最近 30 天的净值"/"15911 的最新净值是多少")全部命中 15911;
- 新增 5 条单测:5 位代码成为过滤条件、6 位仍正常、金额不当代码、
  年份/天数不被误认、多代码并存;
- `pytest tests/unit tests/contract` → **1458 passed, 2 skipped, 0 failed**;
- `ruff` 干净。

## 说明(未改,供你决定)

问「最新净值」时返回的是该产品的 50 行净值明细(SQL 无 `ORDER BY`),
不是"最新的那一条"。要的话我可以补成按日期倒序、单点取值。
2026-09-14 22:32:26 +08:00
lzf_0626 5b97475950 场外前端:单据核对区显示不出字段 —— 邮件详情的单据只是摘要,须与识别接口按 task_id 合并
## 现象

"单据核对与运营动作"里的单据信息全是 `--`:

```
20260914-001-A01
subscription · -- · --
申请日期 --        申购金额 / 赎回份额 -- / --
机构     --        基金代码            --
```

而 OCR 识别字段里这些值都**有**(基金代码 15911 / 申请日期 2026-09-11 /
申购金额 5000.00 / 机构 星澜财富服务中心)。所以不是"没保存",是**渲染时取错了数据源**。

## 根因:同一个单据,两个接口给的不是同一份字段

| 接口 | `attachments[].documents[]` 的字段 |
|---|---|
| `GET /mails/{id}`(`_mail_detail`) | **只有摘要 4 个键**:`task_id` / `document_type` / `status` / `operator_decision` |
| `GET /mails/{id}/recognition-fields`(`_recognition_payload`) | **全部标准化字段**:`fund_code` / `fund_name` / `application_no` / `application_date` / `agency` / `subscription_amount_yuan` … |

`renderDocuments()` 渲染单据 kv 用的是 `state.documents`,而 `loadMail()` 只从
**邮件详情**那一份构建它 ⇒ 标准化字段根本不在对象里,只能显示 `--`。

前端其实**已经有** `syncDocumentsFromRecognition()` 想做这件事,但 `loadMail()` 里
是「先 `syncDocumentsFromRecognition(...)`、紧接着又用邮件详情重建 `state.documents`」
—— 合并结果被后一行覆盖掉了(这也是保存识别字段后那次同步没生效的原因)。

## 修法

1. `loadMail()`:构建完 `state.documents`(邮件侧摘要)之后**再调用一次**
   `syncDocumentsFromRecognition(state.recognition)`,把识别侧的标准字段合并进来;
2. `syncDocumentsFromRecognition()` 由"整段替换"改为**逐条按 `task_id` 合并**:
   - 两侧都有 → 识别侧字段优先,摘要里独有的键(`status` / `operator_decision`)保留;
   - 只有摘要侧 → 原样保留(不因为识别接口没提到就丢单据);
   - 只有识别侧 → 也补进来(例如刚生成、摘要还没刷新);
   - 识别接口无 attachments(请求失败等)→ 早退,**不清空**已有单据。

## 验证

`node --check offsite.js` 通过;并用 Node **从源文件抽出该函数**(不另抄一份逻辑)灌入
线上两个接口的真实载荷形态,6+4+3+1 项断言全部通过:

- 摘要 + 识别侧全字段 → `fund_code=15911` / `application_date=2026-09-11` /
  `agency=星澜财富服务中心` / `subscription_amount_yuan=5000.0000`,且
  `status=planned`、`operator_decision=未处理` 未被覆盖,`attachment_id` 已补上;
- 识别接口返回空 → 邮件侧摘要仍在;
- 邮件侧独有单据保留、识别侧独有单据出现。

前端相关测试:`tests/unit/api/test_portal_frontend.py` → 45 passed, 1 failed
(仍是组员在改的投顾页,与本次无关);`pytest tests/unit/api -k "offsite or operations"`
→ 1 passed。

## 说明:为什么没改后端

也可以让 `_mail_detail` 直接带上标准化字段,但那会改动已被文档化的接口载荷形状;
而前端本来就有合并函数、意图明确(`save-ocr` 路径早就在调用它),
因此按"恢复原有意图 + 按 task_id 正确合并"来修,零接口契约风险。
2026-09-14 22:23:49 +08:00
lzf_0626 ae7a89f33d 投顾工作台:修推荐结果渲染(幂等响应形状归一)+ 对齐门户版式规范
投顾页「生成推荐方案」拿不到产品的根因:该端点标了 idempotent,浏览器必带
Idempotency-Key,而后端此时返回的是 {data:{content_id, status, plan:{...}}, meta}
—— 真正的文档嵌在 data.plan 里。此前前端只处理了不带键时的裸文档形状。

- api-client:ADVISOR_RECOMMEND 撤掉 raw(带幂等键时确为信封,需正常解包);
  ADVISOR_ALLOCATION / ADVISOR_ANALYSIS 保留 raw(不标幂等,返回裸文档)
- actions-module:新增 normalizeRecommend(),把「信封 + plan 嵌套」与「裸文档」
  两种响应归一成一种形状;结果卡片显示方案编号与待审核状态
- 投顾页:hero 大图 + 4 张概览指标卡 + 左栏/主区两栏构图
- 投顾页:页头常显「退出登录」按钮;新增风评预警数、快捷问句按钮
- 投顾页:动作名统一为「生成推荐方案」(对齐软件需求文档 5.4 ⑦)
- 管理台「待审投顾内容」空态文案同步改名
- 前端模块相对 import 加 ?v= 版本号:改文件内容而 URL 不变会被浏览器缓存挡住
2026-09-14 22:17:39 +08:00
lzf_0626 1d6c32f6b3 场外核对:桥接"单据账户标识 → 平台交易账号";保存识别字段后自动重跑核对;运营角色补推介材料权限
## 一、根因:核对查不到不是"缺数据",是**账户口径没桥接**

实测(客户 10002 的单据):

```
生成的 SQL: JOIN fin_holding h ... WHERE h.trade_account = '10002'   ← 单据上的账户标识
结果:       0 行 → 「申请前持有份额」= null → 规则判「无法判断」→ 单据状态 query_failed
```

而库里 `fin_holding.trade_account` 存的是**平台交易账号** `FSA{客户号:06d}`
(与 `fin_sim_account.account_no` 同值,见 `tools/seed_sim_account_demo.py:226`),
客户 10002 **一直持有 15911 共 1000 份**。所以页面上"单据核对与运营动作"是空的,
看起来像缺数据,实为**单据印的账户标识与平台账号是两个口径,中间少了一步换算**。

修法:新增 `OffsiteFundService._platform_account()`,在**单据字段落库的两个入口**
(`_create_document` 新建、`_apply_recognition_fields` 保存/重试)做换算,规则:

1. 已是库里的 `account_no` → 原样;
2. 纯数字且命中 `customer_id` → 用该客户的 `account_no`;
3. 其余原样返回并留 warning —— **失败关闭,不猜不造**(不凭空生成 `FSA099999`)。

一处修好,后端查询、NL2SQL 页面的自然语言、页面展示三处口径一致。
附件上的 OCR **原始值不受影响**(`extracted_fields` 仍是单据上印的 `10002`)。

端到端证据(同一张单据,只走服务层):

| 阶段 | document.account_identifier | 规则数据 | 单据状态 |
|---|---|---|---|
| 修复前 | `10002` | `{}` → 无法判断 | `query_failed` |
| 保存识别字段后 | **`FSA010002`** | — | `query_failed` |
| 触发核对后 | `FSA010002` | **申请前持有份额 1000.0000** → **正常** | **planned** |

## 二、前端:保存识别字段后自动重跑核对

`employee-operations/offsite/offsite.js`:原先 `save-ocr` 只保存 + 重载页面,
**不触发核对**,于是运营改完字段点保存,那两个区块要么停留在上一次核对的状态、
要么整块是空的,必须再手动点一次"重新核对并判定规则"——看起来像"保存没生效"。

现在:保存 == 运营已人工确认该单据内容,因此保存成功后**自动对该附件关联的单据**
执行「触发 NL2SQL → 拉取返回字段 → 重新判定规则」,并在提示里区分"已重新核对"
与"核对未全部成功"。

顺带把这段逻辑抽成 `recalculateDocument(taskId)`,与面板上的"重新核对并判定规则"
按钮**走同一条路径** —— 两处各写一份正是"保存后不刷新"这类不一致的来源。

## 三、运营(operator)角色权限

`tools/grant_operator_role.py` 的授权清单补齐:`promotion:write/read/review/deliver`
(`promotion_material_service.py` 的八处 `_require` 恰好只用这四个码,缺任一都会 403,
例如只给 write 会在查看详情 read 那一步被拒)+ `agent:run`。

已实际执行并**从身份侧验证**(`IdentityRepository.load_context`):
9005 / 9006 现在各 7 项权限,四个推介材料码齐全。

- NL2SQL 全库与它相关的权限码**只有 `financial:nl2sql:read`**(`offsite:nl2sql` 在
  `sys_permission` 里并不存在,是 `offsite_fund_service` 里 any-of 校验的死值),
  该码运营早已有,本次无需新增。
- 可持续性已核实:`sys_role_permission` / `sys_user_role` **没有外键**,
  重跑 `seed_test_rbac.py`(DELETE 重建 9001-9099 号段权限)**不会**删掉运营的绑定;
  且本工具按**权限码查 id**、不写死 id,天然抗号段变动。

## 四、10001 / 10002 的 15911 持仓

复核结论:**各 1000 份,且三处口径一致**(`fin_holding.market_value` = 数量 × 最新净值、
净值历史 120 条、`fin_product.current_nav` 与净值最新一条一致、账户可用资金正常)。
`fin_holding` 的唯一键是 `(customer_id, product_id)`,所以**不能**再插一行
`trade_account='10002'` 的"同一个持仓"—— 那会把持仓重复计数,是错的。
需要改数量就用 `python tools/seed_custom_holdings.py --quantity N`(默认就是这两个客户 + 15911)。

## 五、验证与回归

- 新增 `tests/unit/service/test_offsite_account_bridge.py`(5 条:账号原样 / 客户号换算 /
  认不出原样返回 / 不凭空造账号 / 空值不查库);
- `pytest tests/unit/service -k offsite` → 34 passed;
- 场外集成测试 4 个文件 → 27 passed;
- `ruff` 干净;`mypy app` 仍只有组员新代码里那 3 个既有错(与本次无关);
- 前端 `node --check offsite.js` 通过。
2026-09-14 22:16:29 +08:00
lzf_0626 292acd2e2c 新增「补客户归属」工具,并订正一处因迁移而过期的注释
## 新增 tools/assign_customer_scope.py

`sys_customer_assignment` 是**全平台**的"谁负责哪个客户"口径,不是记忆私有:
记忆召回(app/core/memory_scope.py)、`memory:read:customer` /
`investment-goal:*:customer` 的 `own_customers` 授权判定、投顾"已发布方案"的可见客户
都读它。所以"给某人补归属"是一句**授权动作**,不该用一次性 SQL 随手写进库。

本工具把它做成幂等、可复跑、默认只预览(--apply 才写),并**当场用真实身份链路回验**
(IdentityRepository.load_context → customer_memory_scope),还会在目标账号缺少
`memory:read:customer` 时明确提示"归属关系不等于授权"。

内置两个已踩过的坑:
1. `assigned_at` 不能写"当前时间" —— MySQL DATETIME(0) 四舍五入到秒可能落进未来,
   于是 `assigned_at <= now` 判定"尚未生效",归属拿不到**且不报错**;统一往前留 5 秒;
2. `employee_role` 是 NOT NULL 且参与唯一键 `(customer_id, employee_role)`,
   故从 RBAC 反查员工真实角色码,不靠手填。

`id` 在 9901+ 段分配(避开业务号段)。另外修掉一个写入期 bug:校验查询已经开了隐式事务,
再 `async with session.begin()` 会抛 `InvalidRequestError: A transaction is already begun`。

用法:
    python tools/assign_customer_scope.py                 # 预览:9002(风控) ← 9001
    python tools/assign_customer_scope.py --apply
    python tools/assign_customer_scope.py --show          # 只读:每个员工的实际可见范围

## 订正 tools/seed_advisor_demo.py 的过期注释

该文件原写"`sys_customer_assignment.id` 没有自增(EXTRA 为空),必须自己给" ——
这条**已被 20260914_baseline_auto_increment 迁移改变**(实测 EXTRA 现为 auto_increment)。
注释改为记录现状,并说明仍然显式给 id 的理由(保持号段约定 + 让种子可复现,
自增会让同一份种子在不同环境落到不同 id)。

## 本轮实际执行

- 补入一行归属:员工 9002(risk_operator)→ 客户 9001(id=9902,幂等复跑正确跳过);
- 回验:风控 9002 的记忆可读范围从 `()` 变为 `(9001,)`,召回从 0 条变为 2 条;
  投顾 9020 仍 2 条;
- 该动作**只影响记忆召回**:9002 其余权限都是 `data_scope='all'`,
  `scope_from_context()` 见 all 直接放行、不看 customer_ids。
2026-09-14 21:46:29 +08:00
lzf_0626 c4f4c33fd6 记忆召回补一道能力码闸门:归属关系不等于授权
上一版把员工召回改成"按 sys_customer_assignment 归属"时,只看了**归属关系**,
没校验**能力码** —— 这留下一个我自己带出来的口子:

- 平台读他人画像/记忆的正式路径(customer_profile_service、public_platform_service)
  校验的是 `memory:read:customer`(sys_permission id=9010,data_scope='own_customers')
  + `customer_ids`;
- 而召回是**另一条**把客户记忆送进模型上下文的路径,只按归属放行,等于让
  "有归属行但没有该权限"的账号(例如 operator:只有 financial:nl2sql:read
  + offsite:write)凭空获得读他人客户记忆的能力 —— 它上一版之前是读不到的。

现改为**两个条件都满足才放行**(缺一即失败关闭,并在日志里分开点名"缺能力码"
与"归属未维护",避免运维在错误的表上找问题):
  ① `memory:read:customer` 在 context.permissions 里;
  ② 客户落在 context.customer_ids(sys_customer_assignment 生效行)内。

演示账号不受影响:9002(risk_operator)、9020(advisor)、9003(admin) 三个角色都持有该权限码,
真实身份链路复验 9020 仍是"修复前 0 条 → 修复后 2 条"。

测试:tests/unit/core/test_memory_scope.py +3 条(缺能力码读不到/能力码与归属是"与"关系/
现有用例补上能力码),tests/unit/service/test_agent_governance.py 同步。
`pytest tests/unit tests/contract` → 1448 passed, 2 skipped, 1 failed
(唯一失败仍是组员正在改的投顾页面)。
2026-09-14 21:40:05 +08:00
lzf_0626 89b15f32cb 忽略 .env 自动备份:它含真实密钥却不在 .gitignore 里,git add -A 会把它带进版本库 2026-09-14 21:33:48 +08:00
lzf_0626 9eebf9627f 记忆召回:按 sys_customer_assignment 归属定范围,不再把员工号当客户号
## 修的是什么

`governance.recall()` 把 `int(context.user_id)` 当客户号用。后果有两个,
方向相反但都致命:

1. **员工身份(风控/投顾/运营/管理员/system)恒空** —— 员工不是客户,
   那是个不存在的客户号;日志只说 "empty",看不出是"设计如此"还是"记忆坏了"。
2. **越权陷阱** —— 员工号与客户号同号段(演示数据里客户 9001-9020、
   员工 9002/9020 并存)。`int(user_id)` 一旦与真实客户号重合,就会把
   **陌生客户的长期记忆读进来并注入提示词**,且不报错、看起来正常。

同一个问题在代码里还有另外两处**各自判断**、口径互不一致:
`BaseAgent.recall_memory()` 要求"每条记忆 customer_id == context.user_id"
(否则抛"越过客户范围"),`review_output()` 的引用校验只认同一条件。

## 怎么修的

新增 `app/core/memory_scope.py` 作为**唯一判定口径**,三处共用:

- 客户身份(customer / authenticated_user):**只读自己**,分配表里有别行也不读别人;
- 员工身份:**只读 `sys_customer_assignment` 分配给自己**的客户
  (`context.customer_ids`,由 `IdentityRepository.load_context()` 读入);
  归属未维护 ⇒ **失败关闭**,并在日志里点名"归属未维护",与"库里确实没有记忆"区分开;
- 访客:无(上游已拦)。

细节约定:
- 归属客户按客户号**升序**召回、单次上限 `MAX_RECALL_CUSTOMERS=10`
  —— 升序是为了确定性(同一身份每次取同一批,不随数据库返回顺序漂移),
  上限是为了别把成百上千条他人记忆塞进一个提示词;
- 跨客户合并后按置信度降序、`(客户号, uuid)` 兜底排序,最多 10 条;
- 员工同时持有多个归属客户的记忆时,`memory_context_text()` **逐行标注客户号**
  并把提示词改成"多个客户的长期事实" —— 否则模型会把 A 客户的事实当成 B 客户的。
  单一客户时保持原格式(客户身份的提示词与改动前逐字相同);
- 引用校验与范围守卫都改用同一口径:员工引用**归属客户**的记忆不再被判成伪造引用;
  引用**非归属客户**的记忆即便被塞进 memories 也照样拦下。

## 验证(真实身份链路 + 生产召回装配)

`IdentityRepository.load_context` → `PlatformGovernance.recall`(含 Milvus 语义通道):

- 身份展开:roles=('advisor',)、customer_ids=('9001',)(sys_customer_assignment
  里唯一那行 9020→9001)、可读范围 (9001,);
- **修复前** `recall(int(user_id)=9020)` → **0 条**;
- **修复后** `recall(按归属)` → **2 条**(客户9001:进取型 / 约三年);
- 边界:客户身份 9001 可读范围 (9001,);无归属员工 9002 = ()(失败关闭,
  且**没有**把 9002 当客户号);未分配时的 9020 = ()。

测试:`pytest tests/unit tests/contract` → **1445 passed, 2 skipped, 1 failed**
(1432 + 新增 13;唯一失败是组员正在改的投顾页面,与记忆链路无关)。
新增用例:`tests/unit/core/test_memory_scope.py`(8 条,含"员工号不得被当成客户号"
的反例断言)、`tests/unit/service/test_agent_governance.py`(+5 条:归属召回/
无归属失败关闭且不碰数据库/客户只读自己/引用校验/越界守卫)。

## 遗留(已在 AGENTS.md 与文档里写明,未自行实施)

风控扫描这条线**仍读不到记忆**:它是唯一消费召回内容的地方
(`risk_agent.py:224`),而扫描上下文是 user_id="0"/roles=("system",) 且无归属行。
根因是**顺序问题**:召回发生在 handle() 之前,上下文里没有"本次目标客户"这个概念。
出路有两条:① 给风控专员补 sys_customer_assignment 行(运维动作,立即可用);
② 在 RequestContext 加显式的 target_customer_id 并校验它落在归属集合内
(推荐,但属跨线协议改动,等确认)。

文档:docs/演示用/记忆召回恒空-根因与修复-2026-09-14.md 新增 §五(含 §5.4 遗留说明)、
AGENTS.md 新增"记忆可读范围只有一个判定口径"易错点,并按 2026-09-14 复测更新测试基线。
2026-09-14 21:33:16 +08:00