7.5 KiB
处方双模型实现审查
审查日期:2026-09-09。范围:Store、Worker、Logic、Controller、Request、PrescriptionLogic 保存钩子、迁移及其与生成器/比较器的接口。审查时主实现仍在修改;以下行号对应本轮读取,修复后应按方法名复查。未修改业务文件,未访问数据库、生产服务或 .env。
已修复问题(保留审查记录)
以下六项均已在本轮合入修复:Worker 解码原始 JSON 后比较;资料时钟退出变化指纹;冻结来源权限清单单独加密,并在读取、模型分片调用和发布前重验;有效基线不再写入排除原因;生成器统一覆盖状态;比较器识别 instructions 和明确无额外炮制,并拒绝未知炮制与煎服语义冲突的重复行。
字典已改为在准备批次时冻结完整有效目录与全局内容哈希,两模型共用。PrescriptionAiPipelineTest.php 覆盖原始数据库 JSON 到 100% 的完整链路、两分支之间目录变化、权限中途撤销;PrescriptionAiPolicyTest.php 覆盖炮制与重复煎服差异;队列测试覆盖真实保存回滚和有效统计输入。以下内容记录修复前的发现,不表示这些问题仍然存在。
-
[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%”集成测试。
- 位置:
-
[P1] 资料指纹包含读取时间,无资料变化也会自动重生成。
- 位置:
server/app/common/service/prescriptionai/PrescriptionAiContext.php:296、:299;PrescriptionAiWorker.php:188。 source_hash对包含cutoff_at=time()的整个 source 哈希。refreshSources()每次重建都会看到新指纹,产生新批次并失效旧报告,直至日预算耗尽。- 修复:将稳定资料内容/版本/可用性指纹与冻结快照哈希分开;变化检测排除扫描时间,快照仍保存截止时间。验证同内容不同扫描时间不入队,新增或修改资料只入队一次。
- 位置:
-
[P1] 读取报告只核验诊单范围,漏掉生成时的聊天/通话员工权限。
- 位置:
server/app/adminapi/logic/tcm/PrescriptionAiLogic.php:335;server/app/common/service/prescriptionai/PrescriptionAiContext.php:131。 - Context 除诊单可见外还通过
sourceStaffAllowed()限制聊天和通话归属;visibleBatch()仅核验处方、诊单 ID。具体场景:医生生成包含本人聊天/通话的报告,同诊单医助拥有处方和诊单访问权,可读取该报告,但按 Context 的员工规则,该医助自己构建资料时不能读取医生的这些原始来源。 - 修复:冻结逐来源权限清单,报告/列表摘要/统计入口对当前读者重新核验全部来源范围。短期可保守限制为原生成者且权限范围未变,或具有覆盖全部冻结来源的明确授权;不能用“同诊单”替代员工归属核验。需增加“同诊单但不同员工消息范围”的拒绝访问测试。
- 位置:
-
[P1] 合格基线也写入排除原因,统计有效数永远为零。
- 位置:
server/app/adminapi/logic/tcm/PrescriptionAiLogic.php:228。 - 当前表达式在
$eligible=true、结果成功且无其他排除原因时仍填incomplete_coverage。Statistics 对任何非空排除原因都拒绝纳入,正确分数也无法成为有效样本。 - 修复:合格时
exclusion_reason='';不合格时区分资料不足、失败、不可比与基线条件。向统计输入传递comparison_reason_code,避免把“剂型不可换算”等原因一概归为覆盖不足。
- 位置:
-
[P2] 覆盖字段契约不一致,所有结果被持久化为 partial。
- 位置:
server/app/common/service/prescriptionai/PrescriptionAiStore.php:318;PrescriptionAiGenerator.php:174。 - Generator 输出
coverage.complete、coverage.source_complete布尔值,没有coverage.status;Store 读取后者并默认 partial,完整结果也无法显示完整或进入完整覆盖基线。 - 修复:统一严格覆盖契约,由已有完成布尔值、逐来源与附件清单确定持久化状态;未知值保持 partial。添加完整/部分覆盖输出到数据库字段的离线集成测试。
- 位置:
-
[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。