Files
zyt/docs/plans/prescription-ai-implementation-review.md
2026-09-10 15:19:17 +08:00

60 lines
7.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 处方双模型实现审查
审查日期:2026-09-09。范围:Store、Worker、Logic、Controller、Request、PrescriptionLogic 保存钩子、迁移及其与生成器/比较器的接口。审查时主实现仍在修改;以下行号对应本轮读取,修复后应按方法名复查。未修改业务文件,未访问数据库、生产服务或 `.env`
## 已修复问题(保留审查记录)
以下六项均已在本轮合入修复:Worker 解码原始 JSON 后比较;资料时钟退出变化指纹;冻结来源权限清单单独加密,并在读取、模型分片调用和发布前重验;有效基线不再写入排除原因;生成器统一覆盖状态;比较器识别 `instructions` 和明确无额外炮制,并拒绝未知炮制与煎服语义冲突的重复行。
字典已改为在准备批次时冻结完整有效目录与全局内容哈希,两模型共用。`PrescriptionAiPipelineTest.php` 覆盖原始数据库 JSON 到 100% 的完整链路、两分支之间目录变化、权限中途撤销;`PrescriptionAiPolicyTest.php` 覆盖炮制与重复煎服差异;队列测试覆盖真实保存回滚和有效统计输入。以下内容记录修复前的发现,不表示这些问题仍然存在。
1. **[P1] 原始数据库药味 JSON 直接送入比较器,全部医生方不可比。**
- 位置:`server/app/common/service/prescriptionai/PrescriptionAiWorker.php:74``:88``PrescriptionAiStore.php:101`
- 冻结处方来自 `Db::name(...)->find()``herbs` 是 JSON 字符串。Worker 仅在收集药名时临时调用 `Policy::decode`,实际传给 `compare()``$doctor['herbs']` 仍为字符串;比较器要求数组,返回 `empty_prescription`
- 修复:构造专用比较输入,先解码 `herbs``aux_usage` 等 JSON 字段;保留冻结原文。增加“原始数据库行 → Worker 输入 → 100%”集成测试。
2. **[P1] 资料指纹包含读取时间,无资料变化也会自动重生成。**
- 位置:`server/app/common/service/prescriptionai/PrescriptionAiContext.php:296``:299``PrescriptionAiWorker.php:188`
- `source_hash` 对包含 `cutoff_at=time()` 的整个 source 哈希。`refreshSources()` 每次重建都会看到新指纹,产生新批次并失效旧报告,直至日预算耗尽。
- 修复:将稳定资料内容/版本/可用性指纹与冻结快照哈希分开;变化检测排除扫描时间,快照仍保存截止时间。验证同内容不同扫描时间不入队,新增或修改资料只入队一次。
3. **[P1] 读取报告只核验诊单范围,漏掉生成时的聊天/通话员工权限。**
- 位置:`server/app/adminapi/logic/tcm/PrescriptionAiLogic.php:335``server/app/common/service/prescriptionai/PrescriptionAiContext.php:131`
- Context 除诊单可见外还通过 `sourceStaffAllowed()` 限制聊天和通话归属;`visibleBatch()` 仅核验处方、诊单 ID。具体场景:医生生成包含本人聊天/通话的报告,同诊单医助拥有处方和诊单访问权,可读取该报告,但按 Context 的员工规则,该医助自己构建资料时不能读取医生的这些原始来源。
- 修复:冻结逐来源权限清单,报告/列表摘要/统计入口对当前读者重新核验全部来源范围。短期可保守限制为原生成者且权限范围未变,或具有覆盖全部冻结来源的明确授权;不能用“同诊单”替代员工归属核验。需增加“同诊单但不同员工消息范围”的拒绝访问测试。
4. **[P1] 合格基线也写入排除原因,统计有效数永远为零。**
- 位置:`server/app/adminapi/logic/tcm/PrescriptionAiLogic.php:228`
- 当前表达式在 `$eligible=true`、结果成功且无其他排除原因时仍填 `incomplete_coverage`。Statistics 对任何非空排除原因都拒绝纳入,正确分数也无法成为有效样本。
- 修复:合格时 `exclusion_reason=''`;不合格时区分资料不足、失败、不可比与基线条件。向统计输入传递 `comparison_reason_code`,避免把“剂型不可换算”等原因一概归为覆盖不足。
5. **[P2] 覆盖字段契约不一致,所有结果被持久化为 partial。**
- 位置:`server/app/common/service/prescriptionai/PrescriptionAiStore.php:318``PrescriptionAiGenerator.php:174`
- Generator 输出 `coverage.complete``coverage.source_complete` 布尔值,没有 `coverage.status`Store 读取后者并默认 partial,完整结果也无法显示完整或进入完整覆盖基线。
- 修复:统一严格覆盖契约,由已有完成布尔值、逐来源与附件清单确定持久化状态;未知值保持 partial。添加完整/部分覆盖输出到数据库字段的离线集成测试。
6. **[P1] 生成器与比较器的炮制/煎服字段不一致,既可误报 0% 也可误报 100%。**
- 位置:`server/app/common/service/prescriptionai/PrescriptionAiGenerator.php:307``:405``PrescriptionAiComparison.php:37``:196``:247`
- Generator 要求 `processing` 非空,提示模型填写“明确无”;医生旧数据及当前字典查询不带 processing,比较器归一化为 `''`。同一药名同剂量、模型写 `processing='无'` 时两个药项键不同,结果变成 0%。
- Generator 的特殊煎服字段为 `instructions`,比较器只读取 `usage_instruction/decoction_instruction/special_usage/...`,会忽略它。将一味药拆成“先煎 4g + 后下 6g”,对照 10g,现返回 100% 且无用法差异。
- 修复:统一端到端字段;将 `instructions` 纳入严格重复行合并语义及用法差异。明确定义“无额外炮制”的规范值,并与服务端药材字典衔接;“未知”不得当作“无”,生/炙等真实差异不得抹平。
## 已知字典版本问题及修法
`PrescriptionAiStore.php:332` 当前对整份病例 normalization 哈希作为 dictionary_version,导致每个病例独立分层。主实现者已知并在修复。**只替换为 `normalization.dictionary_hash` 仍不足**:Worker 当前仅查询本病例出现药名的子集,同一版机构字典下不同病例的子集哈希仍不同。
建议使用机构发布的完整药材字典修订号;若暂无版本表,对完整有效目录的规范字段(id/name/aliases/processing/status,以及未来批准的换算规则)稳定排序后计算全局内容哈希并缓存。首次冻结批次时固定字典版本及对应映射,两模型复用;病例匹配子集哈希另留审计,不作医生统计分层键。不要把库存、价格或查询时间等与药材身份无关的字段纳入版本。
## 离线验证与边界
通过 standalone PHP 纯函数复现,无框架初始化:
| 输入 | 当前结果 |
| --- | --- |
| 医生 herbs 为原始 JSON 字符串 | `empty_prescription` |
| 同药同剂量,医生无 processing,候选 processing 为“无” | `score=0` |
| 同药拆成不同 instructions 的 4g/6g,对照 10g | `score=100``usage_differences=[]` |
| baseline_eligible=true,但 exclusion_reason=incomplete_coverage | `valid_count=0` |
静态检查确认新增保存及作废钩子运行于现有事务保护内,Request 的预占/完成键位于新增处方事务,task/result 唯一键及租约 token 可防止一般重复落库。本轮未执行数据库事务、并发领取、迁移实跑,因此不把这些静态观察当作运行验证。现有 Context 明确因历史来源版本不可重建排除独立基线,零合格样本应如实展示;不能为了让统计出现数值而将该条件改成 true。