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

7.5 KiB
Raw Permalink Blame History

处方双模型实现审查

审查日期: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:88PrescriptionAiStore.php:101
    • 冻结处方来自 Db::name(...)->find()herbs 是 JSON 字符串。Worker 仅在收集药名时临时调用 Policy::decode,实际传给 compare()$doctor['herbs'] 仍为字符串;比较器要求数组,返回 empty_prescription
    • 修复:构造专用比较输入,先解码 herbsaux_usage 等 JSON 字段;保留冻结原文。增加“原始数据库行 → Worker 输入 → 100%”集成测试。
  2. [P1] 资料指纹包含读取时间,无资料变化也会自动重生成。

    • 位置:server/app/common/service/prescriptionai/PrescriptionAiContext.php:296:299PrescriptionAiWorker.php:188
    • source_hash 对包含 cutoff_at=time() 的整个 source 哈希。refreshSources() 每次重建都会看到新指纹,产生新批次并失效旧报告,直至日预算耗尽。
    • 修复:将稳定资料内容/版本/可用性指纹与冻结快照哈希分开;变化检测排除扫描时间,快照仍保存截止时间。验证同内容不同扫描时间不入队,新增或修改资料只入队一次。
  3. [P1] 读取报告只核验诊单范围,漏掉生成时的聊天/通话员工权限。

    • 位置:server/app/adminapi/logic/tcm/PrescriptionAiLogic.php:335server/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:318PrescriptionAiGenerator.php:174
    • Generator 输出 coverage.completecoverage.source_complete 布尔值,没有 coverage.statusStore 读取后者并默认 partial,完整结果也无法显示完整或进入完整覆盖基线。
    • 修复:统一严格覆盖契约,由已有完成布尔值、逐来源与附件清单确定持久化状态;未知值保持 partial。添加完整/部分覆盖输出到数据库字段的离线集成测试。
  6. [P1] 生成器与比较器的炮制/煎服字段不一致,既可误报 0% 也可误报 100%。

    • 位置:server/app/common/service/prescriptionai/PrescriptionAiGenerator.php:307:405PrescriptionAiComparison.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=100usage_differences=[]
baseline_eligible=true,但 exclusion_reason=incomplete_coverage valid_count=0

静态检查确认新增保存及作废钩子运行于现有事务保护内,Request 的预占/完成键位于新增处方事务,task/result 唯一键及租约 token 可防止一般重复落库。本轮未执行数据库事务、并发领取、迁移实跑,因此不把这些静态观察当作运行验证。现有 Context 明确因历史来源版本不可重建排除独立基线,零合格样本应如实展示;不能为了让统计出现数值而将该条件改成 true。