Commit Graph
14 Commits
Author SHA1 Message Date
张胜宇 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
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 0e68271a81 docs/44:把一份被移动过的引用改成不依赖目录(代码库全面审查报告) 2026-09-15 00:16:56 +08:00
lzf_0626 42a330c4c9 docs/44 演示流程按 2026-09-14 实测同步:今日盈亏可讲、投顾自助审核、管理员八页签、权限数与已知空数据 2026-09-15 00:14:20 +08:00
lzf_0626 e85e958389 排障表补一条:所有页面一起卡 → Milvus 抖动 + 同步调用阻塞事件循环
## 为什么补这一条

把审查报告的 P1–P3 共 45 项按 8 个演示场景筛完,**"会砸演示"的是 0 条**
(P0 四条已全修)。剩下的分两类:37 项演示路径根本走不到,8 项是"边缘"
(平时无感、特定条件才暴露)。

这 8 项里只有 **P1-2** 属于"一处慢、全平台卡":检索层用的是**同步** Milvus 客户端,
单次 `search` 卡 3 秒会让整个 API 进程停摆 3 秒,连 Worker 心跳一起停。

它同时也是**最容易被误判**的一个 —— 现象是"客服、风控、看板一起变慢",
而排障表原有的 6 条全是**单点**故障(503 行情 / Worker 没起 / 浏览器缓存 /
知识库没同步 / MySQL 没起 / 风控样本用完),**没有一条对得上"全场同时变慢"**。
没有这一条,现场会往"服务挂了"的方向查,而它其实是外部依赖抖动、不需要改代码、
也不需要重启。

所以这条的价值不在改代码,而在**把一个边缘隐患变成一眼能认出的现象**:
演示版本选择不修 P1-2(Milvus 正常时完全无感),代价就是必须知道它长什么样。

## 依据(都是会话内实际核过的)

- 客服链路确认:`app/service/knowledge_tool.py:29` 的 `search_knowledge` 工具
  → `get_knowledge_search_service().search(...)`;
  而 `knowledge_search_service.py:207-213 / 224 / 229` 调的是**同步** `MilvusClient`
  (`app/infrastructure/vector_memory.py` 的 `def search(...)`,非 `async def`)。
- 对照:项目在其它 10+ 处已用 `asyncio.to_thread`(如
  `app/infrastructure/fund_market_adapter.py:273/382`),说明异步规范已建立,
  这 4 处是遗漏 —— 但**不是演示阻塞项**,本次不改。
- "风控日报会不会砸"(P1-6,报告标了【确定】)也核过了:`_distribution` 的
  `counts` 键是字段**值**,两个调用点用的 `risk_level`(中文高/中/低)与
  `alert_type` 都是字符串,`str(key) == key` 恒成立,**触发不了** —— 场景 6 安全。

## 改动范围

只改 `docs/44-演示流程.md` 的 §4 排障表,**新增一行**,未动其它内容。
2026-09-14 20:41:34 +08:00
lzf_0626 36c7a9d8d2 文档:审查报告入库 + 全量校对补注
## 新入库(`docs/演示用/`)

- `代码库全面审查报告-2026-09-14.md`
- `代码修改方案-2026-09-14.md`
- `记忆系统排查报告-2026-09-14.md`
- `记忆系统修复文档-2026-09-14.md`
- `文档一致性审计报告-2026-09-14.md`
- `多Worker接入方案-2026-09-14.md`

## 全量校对(32 个既有文档 + `AGENTS.md`)

跨 39 个文件、**1125 insertions / 148 deletions**。

⚠️ **这批改动同样不是本次会话写的**。我抽样核对过性质:是**实质内容补充**而不是
格式/换行转换。例如 `docs/44-演示流程.md` 新增两条"2026-09-14 补注":

- `启动金融Agent平台.bat` 只在**桌面**上,仓库里只有 `启动平台.bat` 这一份
  (两份由同一个 `tools/make_launcher_bat.py` 产出,改完 `start.ps1` 重跑它一起更新);
- `advisor_t`(9020) 与 `offsite_t`(9006) **不在 `tools/seed_test_rbac.py` 的演示用户里**
  (那里只有 `cust_t`/`risk_t`/`admin_t`/`review_t` 四个),由 `grant_*.py` 系列创建,
  **重跑种子不会重建它们** —— 换机器时这两个账号登录失败,要先查 `sys_user` 有没有这两行,
  而不是查密码。

这两条都是对的地方,与我这一路踩到的现象一致(我确实用到了 `advisor_t`/`offsite_t`)。

**我没有逐字审阅全部 39 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落,
请指明文件,我逐处核对。
2026-09-14 20:36:00 +08:00
lzf_0626 e59a9905a0 把「今日盈亏是硬编码 0」提级为待业务确认项(SRS Q22)
## 问题

`today_profit_loss` / `today_profit_loss_ratio` 在 `app/service/trade_service.py` 里是
**硬编码 `ZERO`**(持仓列表 L678、账户看板 L748-749),客户看板、持仓页、盈亏分析页的
「今日盈亏」永远显示 0.00。同一函数里的「持有盈亏」是**真算的**
(`market_value - cost_amount`,L660 / L726),所以只有这一项是占位。

三份 2026-09-14 文档其实**都记录了这个事实**(接口文档第 11 条、SRS 第 212 / 458 / 615 行),
但**都埋在「注意事项」里,没有进第 0 章那份「★ 请先回答本章」的待确认清单** ——
业务翻文档时看不到,演示现场被问到就答不上来。而且 SRS 第 212 行还把它**错引到了 Q7**
(Q7 讲的是场外阈值,与此无关)。

## 改动

**docs/软件需求文档-2026-09-14.md**
- 新增 **0.5 节「占位实现与本期边界」**,登记 **Q22**:客户看板的「今日盈亏」本期是否实现。
  该节专门收这类「接口有、值还没真算」的偏差(不报错、单元测试全绿、只有业务看得出不对),
  以后同类问题继续往这里追加。
- 第 0 章项数 21 -> 22;阻塞项清单补上 Q22。
- 修正 F-4.2 的错误引用(见 Q7 延伸 -> 见 Q22)。
- R2 状态改为「已提级为待决项 -> 见 Q22」。
- **R1 状态改为已修复**:组员提交 `4b7ee13`「修复管理员工作台函数缺少闭合大括号」
  已解决 `workspace.js` 的语法错误。按该条自己要求的方式复验过 ——
  `node --experimental-vm-modules` + `vm.SourceTextModule` 遍历 `app/static/portal`,
  **44 个模块全部通过**(`node --check` 会假通过,不能用来判定)。

**docs/44-演示流程.md**(演示现场用)
- §3「可能被问到的问题」新增话术:直说这是占位值、指出持有盈亏是真算的、
  指向 SRS Q22 与两条口径的结论;并建议「时间紧就避开这一栏,被问到不要含糊也不要现编」。
- 场景 2(客户资产)加现场提示:讲「持有盈亏 / 总市值 / 可用资金」,不要指着「今日盈亏」讲。

**docs/后端接口文档-2026-09-14.md**
- 第 11 条补上代码行号与交叉引用,并点明同一响应里的 `profit_loss` 是**真算的**。

## 顺带交给业务:两条口径的可行性(实测,非推测)

业务要决定的是「要不要做」,但「能不能做」也得一起给,否则业务选完还要再问一轮:

| 口径 | 数据现状 | 结论 |
|---|---|---|
| 用行情算(今收 − 昨收) | `fin_market_price` **只在同步时才写行**,实测每个产品**只有 2 行**(515450 只有 09-11 与 09-14,还跨了周末) | ❌ 取不到连续「昨收」 |
| 用净值算(今净值 − 上一交易日净值) | `fin_nav_history` 每个产品 **120~160 行连续交易日净值** | ✅ 建议按这个口径 |

⇒ 文档建议按**净值口径**实现,并提醒业务一并确认语义:净值是日终数据,
该指标实际是「最近一个交易日的盈亏」,**不是盘中实时**。

## 入库说明

三份 `docs/*-2026-09-14.md` 此前是**未跟踪文件**,本次一并入库(同一批文档交付物且互相引用)。
`.workbuddy/`(工具会话记忆)属工作区过程产物,**未入库**。
2026-09-14 11:24:51 +08:00
lzf_0626 404f8f6aa5 固化接口体检脚本,并交付双击即用的启动 bat
## tools/portal_api_check.py(新)

把 2026-09-13 那轮"照着接口内容把前端测一遍"的验证固化成可重复跑的脚本。
当时靠这套验证查出 6 个真缺陷,但它们只存在于一次会话里,下次改动没人会重跑:

- `K002`/`K003` 是裸信封,前端按 `payload.data` 取 -> 知识库页显示"已入库 0 块 / 为空"
- 委托/成交详情页照着建表字段写,而接口返回视图没带那些列 -> 5 行永远显示 `--`
- 配置项/路由规则的 `PUT` 要 `If-Match`,却没有端点能返回该 digest -> 首次编辑必然 409
- 回复模板 `scene` 只校验长度不校验枚举 -> 非法值撞数据库 CHECK、冒成 500
- 路由规则表单固定 `max_attempts=2` 且 `fallbacks` 为空 -> 后端必然 422
- 模型端点手填 ID -> 未激活的 ID 直接 422

与 `e2e_smoke_test.py` 分工互补:后者测**业务链路通不通**,本脚本测**接口契约对不对**
(状态码、信封形状、字段名与前端期望是否一致)。

三档,默认只跑第一档:
- 默认只读 41 项(不动数据)
- `--write` 加测写操作:配置项 ETag 链路、`max_attempts` 越界、知识库上传+失效、
  回复模板非法枚举、敏感词、推广物料全链路
- `--dangerous` 再加测会改生效配置的操作(激活配置版本会整版本替换,默认不跑,
  脚本内注明了恢复办法)

实测:只读 39/39、写入档 54/54 全绿。

## 桌面一键启动(双击即用)

桌面 `启动金融Agent平台.bat` + 仓库 `启动平台.bat`,由
`tools/make_launcher_bat.py` 生成,只负责"双击"这一层,启动逻辑复用 `start.ps1`
(不重复实现,避免两边漂移)。参数可透传:`-Port 8100` / `-NoBrowser` / `-SkipPriceSync`。

不要手写这个 bat:必须同时满足 **GBK 编码 + CRLF 换行 + 无 BOM**。生成器自己
读回来校验这三条。踩过的坑:用 `write_bytes` 直接落盘时没转 CRLF,cmd 对 LF-only
批处理会行边界错乱,把 `echo` 的说明文字当命令执行(实测报
`AT 命令已弃用`、`']' 不是内部或外部命令`)。

## start.ps1 的三处修复

1. **解释器探测选错环境(真机双击失败的主因)**
   原先只验证 `--version` 成功就选中,结果挑到一个 Python 3.10 环境:
   本项目用了 `datetime.UTC`(3.11+)且依赖 `asyncmy`,启动当场 ImportError ——
   而报错发生在**行情刷新**那一步,看起来像"行情源坏了"。
   现在实测两项:**版本 >= 3.11** + `import fastapi, sqlalchemy, asyncmy, pydantic` 通过,
   并在跳过时打印具体原因。

2. **`Select-Object -First 1` 掐断 native 管道**
   `& $exe -c ... 2>&1 | Select-Object -First 1` 拿到首个对象后停掉上游管道,
   等于把进程掐了、`$LASTEXITCODE` 变脏,于是**每个候选都被误判成"无法执行"**
   (连装好的 jr_py313 也被跳过)。改为先整体接住输出、再在结果上取行。

3. **幂等 + 就绪探测 + Docker 自动拉起**
   重复双击不再起第二个 API/Worker(API 按端口、Worker 按进程判断);
   起完等 `/internal/health/ready` 真正应答才报成功;Milvus 不可达时尝试拉起
   Docker Desktop 并最多等 60 秒(它不常驻,是"知识检索静默降级"最常见的原因)。

## 实测

- `启动平台.bat` 重复执行:解释器正确选中 jr_py313、依赖检查全 OK、行情已刷新、
  两个服务识别为已在跑并跳过、就绪探测 1 秒
- `-Port 8199` 冷启动:新起 API 窗口,8199 的 `/internal/health/ready` 与 `/portal/` 均 200,
  测试后已清理
- `ruff check app tests tools alembic hq.py` -> All checks passed
- `start.ps1` 语法解析通过(BOM 已保留)、bat 三项编码约束校验通过

文档同步:`AGENTS.md`(启动方式、bat 生成器、解释器探测两个坑)、
`docs/44-演示流程.md` §0.3/§0.4(双击启动、两条自检线的分工)。
2026-09-14 01:59:46 +08:00
lzf_0626 9a5259d787 feat(market): 存下行情源给的涨跌幅,产品列表与排行页不再显示"暂无"
## 问题

产品列表与排行页的「最近涨跌」全是"暂无"。原因是它只能由**两个交易日的收盘价**
现算,而 `fin_market_price` 每只产品每天只有一行 —— 库里头一天只有一天数据时
根本算不出来。

## 但行情源本来就给了这个数

腾讯行情接口的第 32 位就是**当日涨跌幅**(适配器此前只解析了开高低收量额,没取它)。
所以不必等第二个交易日去算,把源给的数存下来即可。

## 改动

- **迁移** `20260913_market_price_change_pct`:给 `fin_market_price` **新增一个可空列**
  `change_pct DECIMAL(10,4)`。只加列、不改任何已有字段(AGENTS.md 规则 2 允许),
  可空且不回填,既有读写方全部不受影响。
- **适配器**:`_parse_tencent` 增解析位置 4(昨收)与位置 32(涨跌幅)。
- **同步服务**:写入 `change_pct`;降级路径(净值兜底)没有这个数就写 **NULL**。
- **接口**:`_resolve_change_pct` **优先用存下来的值**;迁移前的历史行没有该列的值,
  才回退到"今日收盘 vs 昨收"现算。仍为 `null` 时前端显示"暂无" ——
  **不得当成 0**(`formatPercent(null)` 会渲染成 `+0.00%`,那等于说"今天平盘")。

## 契约测试同步

两处契约断言因结构演进需要跟进 —— 它们的作用正是拦住这类变更:

- `test_fund_readonly_contract.py`:`fin_market_price` 列数 **13 → 14**
- `test_advisor_migration_contract.py`:迁移 head 更新为新版本
  (核心断言仍是"链收敛到唯一 head",此处只是钉住末端)

验证:实测 20 只产品**全部有真实涨跌幅**(如 515450 −0.43%、159511 +1.23%);
unit+contract **1397 passed**;integration **110 passed**;ruff 通过;
mypy 251 文件 0 错;`audit_schema` 与 `migration_state_check` 均通过;e2e 冒烟 40/40。
2026-09-14 00:30:06 +08:00
lzf_0626 7208713c17 feat(portal): 接入历史净值,产品详情页恢复真实走势图
`fin_nav_history` 一直是**空表**,所以产品详情页画不出走势图 —— 此前那条曲线是
前端 `mock-data.js` 里编的 12 个点位,接入公开产品接口时把它去掉了
(走势图最容易被当成真实业绩),页面改为显示"尚未接入"。本次补上完整链路。

## 1. 取数(app/infrastructure/fund_market_adapter.py)

新增 `fetch_nav_history()`:东方财富历史净值接口(`api.fund.eastmoney.com/f10/lsjz`)
分页取序列。

- **为什么不复用 `fetch_kline`**:它走 `push2his.eastmoney.com`,
  该域名在本环境实测连接被拒(`RemoteProtocolError`);
- **为什么不复用 `hq.get_southern_fund_nav_history`**:那个函数校验**南方基金白名单**,
  而 `fin_product` 里有非南方基金的产品(510300 是华泰柏瑞的),调它会直接 `ValueError`。

⚠️ 实现里踩到一个坑:接口**会忽略请求中的 `pageSize`**(实测固定每次返回 20 条)。
最初按硬编码的 30 判断"是否最后一页",于是 `len(items) < 30` 永远成立、只取到第一页 ——
走势图看上去"有数据",其实只有最近 20 天,而且毫无报错。
改为按首屏**实际条数** + `TotalCount` 推算页数后,同样区间取到 124 条。

## 2. 落库(tools/sync_nav_history.py,新增)

写入 `fin_nav_history`,按 `(product_id, nav_date)` 幂等 upsert。
实测:20 只产品 / **2513 行** / 2026-03-17 ~ 09-13;重跑**新写入 0 行**。

## 3. 接口(P002)

`GET /api/v1/products/{product_code}/nav-history`,编号 **P002**,已登记 `docs/05` §19。
鉴权口径与 P001 相同(要求有效令牌、不校验权限码,访客令牌可用);`days` 有界 1–365。

**表为空时返回 `count=0` 与空数组,而不是报错** —— 调用方据此显示"尚未接入",
**不得回退到编造曲线**。产品不存在或未上市 → `404`(否则前端分不清"没有数据"
和"没有这只产品")。

## 4. 前端

详情页按序列画 SVG 折线,期数标题改为动态("近 N 个交易日")。
表为空时仍显示"尚未接入"占位,并补上此前缺失的 `.detail-chart__empty` 样式。
`product-detail.js` / `.css` / `index.html` 的缓存版本参数一并 bump 到 `-7`。

## 5. 演示数据

`tools/seed_demo_data.py` 增加第 5 步「历史净值」(现 **11 步**),
否则换台机器演示时走势图又会是空的。

验证:P002 实测 515450 / 510300 各 120 个净值点;ruff 通过;mypy 251 文件 0 错;
unit+contract 1391 passed;integration 108 passed;e2e 冒烟 40/40。
2026-09-13 23:38:13 +08:00
lzf_0626 9cb0f6e474 feat(portal): 公开产品接口(P001)落地,访客三页改读真实数据
访客首页推荐、产品列表、产品详情此前读的是前端手写的
`app/static/portal/common/mock-data.js`:只有 8 只,且**其中 6 只根本不在
`fin_product` 里**(159915 / 512100 / 513100 / 511360 / 159645 / 159925),
还把海富通的 `511360` 标成"南方短融ETF"、把 `510500` 净值写成 6.742(真实 7.6027)。
`README.md` 把它记为"公开产品 HTTP 接口尚未实现"的临时方案。

## 接口

新增 `GET /api/v1/products`(编号 **P001**,已在 `docs/05` §19 总目录与 §19 说明中登记):

- **要求有效令牌但不校验权限码**:访客令牌的角色是 `visitor`、不带任何权限,
  这与 `/api/v1/agent-runs`、`/api/v1/conversations` 面向访客的口径一致;
  产品信息本身是公开信息。数据面只暴露 `fin_product`(`status='上市'`)与
  `fin_market_price` 的最新一行,**不含任何账户/客户字段**(由 integration 测试守着)。
- 字段与 `mock-data.js` 对齐,因此前端渲染与筛选逻辑**一行未改**。
- `change_pct` **可能是 `null`**:当日涨跌需要两个交易日的收盘价,行情只同步过一天时
  算不出来。前端对 `null` 显示"暂无" —— `formatPercent(null)` 会渲染成 `+0.00%`,
  那等于对客户说"今天平盘",是编出来的结论。

## 前端

- 新增 `common/visitor-token.js`:访客令牌的**唯一实现**(这段逻辑原先只写在客服浮窗里,
  现在四处要用;复制四份的话存储 key 与过期判断迟早不一致);`widget.js` 改为复用它。
- 新增 `common/product-notes.js`:产品级披露文案。510300"非本公司发行"那条是**合规披露**,
  不能随 mock 一起删掉。
- 三个访客页改读接口,并显式区分 loading / error 状态。
- **删除 `common/mock-data.js`**。
- 产品详情页**不再画走势图**:`fin_nav_history` 目前 0 行,此前那条曲线是 mock 里
  12 个编造点位 —— 走势图最容易被当成真数据,宁可不画,并显式说明"尚未接入"。

## 顺带修正

`min_amount` 在库里全是 0.00(那本是场外"最小认购金额"的概念,对场内按手交易的 ETF
不适用),页面不再显示"¥0.00"(会被读成零元起购),改为"1 手(100 份)起"。

验证:接口实测 `count=20`;ruff 通过;mypy 251 文件 0 错;unit+contract 1387 passed;
integration 106 passed;e2e 冒烟 40/40。
2026-09-13 22:57:48 +08:00
lzf_0626 4629e7d5dd docs(44): 补一步风控助手演示(意图分派已修复,实测置信度 1.0) 2026-09-13 22:08:39 +08:00
lzf_0626 bfd3964ef8 feat(demo): 演示数据一键准备、启动脚本与演示流程文档
交付"别人能自己把系统跑起来做演示"所需的四件东西:

- tools/seed_demo_data.py      演示数据一键准备:10 步按依赖排序
  (此前散在 10 个脚本里,没人知道该跑哪些、按什么顺序跑,且知识库那步
  根本没有脚本、靠手工调接口)
- tools/seed_knowledge_demo.py 知识库演示素材灌入,幂等(先删同 source_file 再灌)
- start.ps1                    启动 API + Worker,含依赖检查与**自动刷新行情**
- docs/44-演示流程.md           8 个主线场景的照读流程 + 排障表 + 账号/命令速查

两条硬约束同时写进了脚本和文档:

1. **Worker 必须常驻**:没有它客服对话一直停在 queued(前端只显示"超时")、
   新知识不进 Milvus 且**没有任何报错**。
2. **行情有效期仅 15 分钟**(`trade_service.MAX_QUOTE_AGE`):超时后所有委托
   直接 503「行情已过期」,而系统**没有自动刷新机制**。故 start.ps1 启动时刷一次,
   文档另给"演示中途 503 时补刷、无需重启服务"的处置方法(已实测)。

实测证据:
- start.ps1 在 8099 完整启动,`/docs` 与 `/portal/` 均 HTTP 200(测试进程已清理)
- `tools/e2e_smoke_test.py` → 40/40 通过
- `tools/seed_demo_data.py --dry-run` → 10 步全部正常列出
- 文档中的下单命令实跑:510300 成交价 4.579、金额 457.90、手续费 0.05
2026-09-13 21:56:27 +08:00