Files
zyt/docs/plans/prescription-ai-deployment.md
2026-09-21 10:26:14 +08:00

491 lines
65 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。功能默认关闭;本次没有迁移线上数据库、启动线上消费者、调用真实模型或批量分析历史患者。
## 本地运行包更新(2026-09-10)
用户反馈本地启动后看不到功能。核验发现:项目“一键运行”优先使用 `app/dist/DoctorWorkstation/DoctorWorkstation.exe`,原 EXE 尚未包含 `doctor_workstation.ui.dialogs.issued_prescription_ai`;桌面快捷方式另指向 `C:/Program Files/ZYT/DoctorWorkstation/DoctorWorkstation.exe`,该安装版为 1.3.0,同样没有此模块。
已为处方列表增加独立可见的 AI 状态说明,区分服务未启用、暂不可用、缺少查看权限和空列表;仍按真实权限显示报告入口,未启用/不可用时不展示虚构分数。服务恢复后清除错误说明。相关 65 项桌面回归、Ruff 和 diff 检查通过。
已将项目“一键运行”使用的目录更新为包含本功能的 1.4.2 运行包,旧目录备份为 `app/dist/DoctorWorkstation-before-prescription-ai-20260910-091537`。新 EXE SHA-256:`d97625e84b3e418aef471e24cfd8830cd07dda85a7762757a5b323d84ce10300`。归档模块、Qt WebEngine/Multimedia、字体、OpenSSL 同源与两项隔离启动检查通过,一键运行入口验证通过。完整证据见 `app/artifacts/issued-prescription-ai/local-package-build-20260910.md` 和 `local-package-promotion-20260910.json`。
桌面安装版没有更新。使用项目 `app/一键运行_医生工作站.bat` 可打开新包;现有客户端偏好连接线上后端 `https://admin.zhenyangtang.com.cn/adminapi`,本地程序更新不会自动部署线上代码、执行数据库迁移或启用分析任务。本轮未执行这些线上变更。
## Linux 宝塔进程预部署记录(2026-09-09)
已通过 SSH 在用户指定的 Linux 宝塔服务器完成进程配置。服务器尚未同步本功能代码,因此仅启动 Supervisor 管理服务,三个消费者均为 `STOPPED / Not started`,配置均为 `autostart=false`。未同步业务代码、修改应用 `.env`、迁移数据库或执行分析任务。
- 实际项目目录:`/www/wwwroot/zyt/server`;站点运行目录为其 `public` 子目录。
- PHP CLI:`/www/server/php/82/bin/php`,现场版本 8.2.28;运行用户 `www`。
- 进程名称:`prescription-ai-prepare`、`prescription-ai-qwen`、`prescription-ai-openai`;各一个实例,命令使用 PHP 和 `think` 的绝对路径。
- 已补齐宝塔官方 Supervisor 管理界面,复用服务器已安装的 Supervisor 4.2.1,未升级宝塔 Python 依赖。
- 主配置:`/etc/supervisor/supervisord.conf`;进程配置:`/www/server/panel/plugin/supervisor/profile/prescription-ai-*.ini`。
- 日志:`/www/server/panel/plugin/supervisor/log/`,每个进程分别记录 stdout/stderr,每文件 10 MB、保留 5 个轮转备份。
- 服务:`/etc/systemd/system/supervisord.service`,管理服务已设开机启动;三个消费者仍不会自动启动。
- 已配置异常退出重启、TERM 停止及进程组清理。prepare 停止等待为 600 秒,模型消费者为 14,400 秒;当前 worker 只在任务外层响应退出,单任务包含多次模型调用,调整上游超时/调用预算后应重新核对等待时间。
现场验证通过:Supervisor 配置解析、systemd 单元校验、PHP CLI 必需扩展、`www` 用户的信号函数与 runtime 写权限;宝塔 `GetPorcessList` 返回三个 `STOPPED` 进程,系统进程表中无处方 AI 消费者。配置校验没有执行业务命令,不能代替上线后模型联调。
代码同步后,先按下方部署顺序完成数据库、密钥、启用时间、权限和模型配置检查,再启动消费者。确认上线后将三个配置的 `autostart` 改为 `true` 并重新加载 Supervisor,才能在服务器重启后自动恢复消费者。保留现有快照密钥;如本地已对同一数据库登记任务,必须使用登记时的相同密钥。
## 已接入的业务流程
- 手工新增处方、空白处方第一次编辑为手工、临床内容修改:与处方保存处于同一数据库事务的分析事件。
- 新客户端对同一新增请求复用 `request_key`;服务端验证请求内容,网络响应丢失后返回原处方 ID。旧客户端按处方版本去重。
- 基于诊单的患者 ID 绑定,空白处方编辑时补齐患者 ID;绑定不足显示明确状态。
- 独立的资料准备、千问、OpenAI 三个消费者;一次冻结资料,两模型独立执行与失败重试。
- 分批读取授权资料和附件,保存模型读取覆盖与缺口;等待本次视频转写最多 5 分钟,晚到资料通过扫描安排新版本。
- 处方临床版本变化、作废、删除后取消旧任务。结果不可覆盖,旧报告保留过期标识。
- 桌面已开处方列表显示模型状态及“药味与剂量一致度”;点击/右键可看报告、候选方案、逐味差异、资料缺口和历史。患者详情也可进入。
- 提供单模型失败重试、重新分析和人工复核意见;AI 结果不会自动写回正式处方、审核、订单或药房。
- 快照、模型中间进度、报告正文和复核意见加密存储。服务端返回报告前重新验证处方和每个来源的授权。
## 百分比的含义
对两张单位、剂量基准、剂型等可比较的处方,服务端依据本地药材身份映射计算:
`一致度 = 100 × 2 × Σ(共同药味 min(医生剂量,AI剂量)/max(医生剂量,AI剂量)) ÷ (医生药味数+AI药味数)`。
药味别名、炮制和主辅方需要明确;单位/每剂或每日基准不明、剂型不可比较、关键资料不足、候选方案未生成时显示“—”及原因,不记为 0%。0% 仅表示可比较但无相同项。煎服与疗程差异另外展示。
此指标反映处方一致程度,不能称为医生准确率。医生统计只接受有证据的独立基线;普通“已查看/未采纳/已复核”不是独立专家判定,不能计为专家合格率。
## 部署顺序
1. 备份数据库,使用现有迁移方式执行 `server/database/migrations/2026_09_09_prescription_ai_analysis.sql`。默认表前缀是 `zyt_`,其他前缀需一致替换。脚本可重复执行,新增表和权限不修改现有报告表。
2. 核实角色权限:`tcm.prescriptionAi/statuses`、`reports`、`detail`、`regenerate`、`retry`、`review`、`statistics`。迁移继承现有患者 AI 报告的读取/生成权限;既有角色未获得相应权限时,应由管理员按实际职责配置。
3. 各 Web 与消费者节点配置同一个至少 32 字符的随机 `PRESCRIPTION_ANALYSIS.ENCRYPTION_KEY`。不配置时单节点会在非公开 `server/runtime/prescription_ai_private/snapshot.key` 建立密钥。多节点必须显式配置并备份相同密钥;丢失密钥将无法解密历史报告,不得把密钥提交代码库。
4. 将 `PRESCRIPTION_ANALYSIS.START_AT` 设为实际启用时刻的 Unix 秒时间戳,用于有界补偿扫描。保留既有 `prescription_ai` 双模型供应商配置,核验两个端点的实际图片、文件和 JSON 输出能力。
5. 先在测试环境配置 `PRESCRIPTION_ANALYSIS.ENABLED=true`,分别运行一次三个命令,确认任务与报告正常。上线启用需完成同样的真实供应商验证。
ThinkPHP `.env` 示例(随机密钥自行配置,不使用示例文字作密钥):
```ini
[PRESCRIPTION_ANALYSIS]
ENABLED = false
START_AT = 0
```
在 `server` 目录运行三个独立进程,并交给现有进程守护器管理:
```sh
php think prescription-ai:work --lane=prepare
php think prescription-ai:work --lane=qwen
php think prescription-ai:work --lane=openai
```
`--once` 只处理一轮,适合诊断或已有定时执行方式。只启动 prepare 不会调用模型。默认每模型并发 1,每次任务最多自动尝试 3 次、手动重新尝试 2 轮,每模型每日最多领取 200 次任务。一次模型任务可能包含多次分片调用;实际调用次数及供应商可用的 token 使用量保存在加密进度和结果中,不能把任务数当成 token 数或费用金额。
`prescription_ai.manual_analysis` 控制输入预算和每模型任务最大调用次数;`prescription_analysis` 控制等待、租约、并发、补偿范围与任务预算。长请求超时应小于任务租约。调整后需重启常驻消费者。
## 历史补生成与回退
历史记录不会一次全量运行。先明确日期范围和条数进行预览:
```sh
php think prescription-ai:backfill --from=2026-09-01 --to=2026-09-09 --limit=50
```
确认预览范围后同命令追加 `--apply` 才登记任务;用返回的 `last_id` 继续传入 `--after-id` 翻页。日期筛选使用处方 `update_time`,不是处方笺日期。命令本身不调用模型,消费者随后执行。既有同版本任务不会重复创建;已失败的单模型从报告窗口重试。
回退时先停止三个消费者,再关闭功能开关并重启 Web 服务;保留新增表和密钥,旧处方流程继续工作。再次启用后从既定起始时间补偿缺失事件。不要删除历史报告表或密钥来暂停任务。
## 当前数据与供应商能力限制
- 旧来源缺少可重建历史版本,当前批次不能获得独立基线资格;列表仍可展示具备比较条件的辅助一致度,医生统计有效样本可能为 0。后补资料不会被冒充为医生开方时已经掌握的证据。
- 远程附件 URL 尚不能证明字节不可变;系统会分批提交并保留该缺口,不标记完整覆盖。默认仅使用已配置存储域名内授权上传路径。
- 当前 OpenAI-compatible 路径支持图片;PDF/不支持的文件保留能力缺口,由模型基于实际已读证据评估候选方案。一般附件缺口本身不再一律禁止候选方;关键用药安全信息仍缺失时继续暂缓。Dify 严格文件路径不允许偷偷丢弃附件并声称完整成功。通用本地 PDF 页面渲染/OCR 和附件内容寻址存储尚未接入,不能把链接清单当成全文已读。
- 聊天归档缺少完整同步水位时显示缺口;未能证明患者关联和授权的记录不会凭患者 ID 扩大读取。
- 自 2026-09-10 起默认研究对照模式:缺少用药、过敏等关键事实不再抑制候选方案,改为在假设下开方并逐项标注缺口与复核点。空白仍不等于“无”;男性/女性实际编码以及明确否认值会保留。
- 源资料自动更新扫描默认覆盖最近 7 天的分析批次。更久的处方可手动重新分析;历史全量更新需明确范围与预算。
- 暂无已验证的独立专家评审数据,不显示虚构的“医生准确率”。AI 百分比排序/全库筛选与专家抽样评审工作流不在当前接口内。
以上为实际支持边界,不代表已完成线上模型联调。
## 本地验证
新增 PHP 独立测试覆盖比较公式、统计排除与版本分层、资料隔离、文件分批、JSON 契约、恢复与加密;数据库测试只使用本地临时 MySQL 实例,创建随机前缀测试库,结束后删除该测试库,绝不加载应用生产数据库配置。
```sh
php server/tests/PrescriptionAiPolicyTest.php
php server/tests/PrescriptionAiComparisonTest.php
php server/tests/PrescriptionAiStatisticsTest.php
php server/tests/PrescriptionAiContextTest.php
php server/tests/PrescriptionAiGeneratorTest.php
```
将 `ZYT_AI_TEST_MYSQL_PORT` 指向独立的本地空密码测试 MySQL 后运行:
```sh
php server/tests/PrescriptionAiQueueTest.php
php server/tests/PrescriptionAiPipelineTest.php
```
桌面无网络测试使用项目 Python 环境,主要入口:`app/tests/test_issued_prescription_ai.py`;截图位于 `app/artifacts/issued-prescription-ai/`。
本轮最终验证:17 个后端测试脚本通过,包括 54 项数据库队列/真实保存检查、63 项带模型边界桩的完整链路检查、252 项比较器检查、64 项统计检查和 20 项策略/加密检查(检查数有重复覆盖,不相加冒充独立病例数)。桌面五份相关测试文件全部通过;PHP 语法检查及 `git diff --check` 通过。真实供应商输出质量、图片/PDF能力和线上进程配置仍需部署环境联调。
## 2026-09-10 本地运行排障
“等待转写/资料”有固定 300 秒截止时间。本次实际观察到等待截止后约 3 秒进入双模型分析,准备进程正常。部署时需区分桌面 Debug 配置、本地 API 域名映射与线上 API,不能仅因线上 Supervisor 未启动就认定本地任务没有消费者。
本次修复了真实供应商调用中暴露的四类问题:
- 接受完整单个 JSON 代码围栏,对合法来源引用列表去重;仍拒绝夹杂解释文字、遗漏或未知来源、错误字段和未通过临床校验的候选方案。
- 最终压缩按完整提示词计算预算,包括全部来源覆盖、附件状态和资料缺口;固定说明本身已超限时明确失败,不丢弃缺口或扩大调用预算。
- 严格附件传输保留全部逻辑附件;分析器将相同 URL 的不同来源分到不同请求,避免上游模型合并后遗漏来源。普通聊天保留原去重行为。
- 严格解析失败后持久化清除该步骤的无效返回缓存,保留其他成功步骤、累计调用记录及原重试上限,避免重试反复复用错误结果。调用记录新增经过格式限制的错误码,不记录新明文正文或凭据。
离线回归覆盖 Generator、UpstreamContract、StreamContract 和 Policy;本地模型进程在确认空闲后重启,失败任务通过原权限与预算检查恢复。OpenAI 已实际生成一份报告,但该应用的附件请求返回 HTTP 400 / `invalid_param`,故保留附件缺口,不能声称图片已被该模型读取。
随后通过只读 `/parameters` 核实:千问应用的 `allowed_file_types` 包含 `image/document/audio/video`;OpenAI 应用只有 `custom`,虽列出图片扩展名,但没有 `image` 类型。这是需要在 Dify 应用侧核对的实际配置差异;当前请求拒绝不能靠把图片标记为已处理解决。Dify 的[官方文件校验实现](https://github.com/langgenius/dify/blob/main/api/factories/file_factory/validation.py)也分别检查文件类型、扩展名与传输方式,部署版本具体行为仍需在该环境确认。
本轮最终状态为 OpenAI 报告成功、千问附件结构校验失败。千问两次手动重试额度已耗尽,未重置额度或创建新批次绕过限制;错误缓存清理已生效。两条模型消费者均已加载最新修复并继续运行,prepare 保留原正常进程。继续排障需要 Dify 管理后台的附件及输出配置;本次未修改远程业务代码或线上开关。
## 2026-09-10 中文展示与资料不全时的候选方案
按用户追加要求,界面的状态、资料缺口、来源编号和统计排除原因提供中文说明,兼容旧报告中嵌入的技术代码。原始存储数据、来源绑定和统计含义不因显示翻译而改变;医学缩写与模型品牌保留原文。
候选方案采用 `manual-prescription-available-evidence-v2` 提示词策略:图片未读、部分转写未完成、历史版本和归档同步无法验证等覆盖限制,不再单独阻止模型根据已有临床证据提出候选方。缺口仍逐项展示,候选方案注明资料不完整和医生复核要求;药味、剂量、单位等具备可比条件时,正常进行药味与剂量一致度比较。
若附件确已送达但一组返回结构不合格,清除该组无效缓存,丢弃全部识别内容,并逐项标记“模型未能正确解析这组附件”,继续其他资料及报告。该组附件不能被最终报告引用为已读证据。附件送达数量不符、文字证据结构错误或最终报告校验失败仍严格报错;重试保留已耗调用量。
年龄、性别、过敏史、当前用药或适用人群妊娠哺乳等关键安全事实缺失,未知的关键风险条件,或模型判断现有证据不足以支持具体用药时,仍暂缓具体候选用药,不能为了分数编造药味剂量。这不是无条件自动开具正式处方。医生统计保持首次独立基线口径,辅助对照结果不冒充医生准确率。
旧报告保持原分析结论;重新分析产生新批次并使用新策略。恢复遇到旧提示词版本时不复用旧输出,但保留累计调用预算,不因升级重置成本限制。此次变更不执行历史全量重跑。
本次验证:72 项桌面相关测试、Generator 回归、252 项比较器检查、PHP 语法与差异格式检查通过。Windows 1.4.2 中文包通过冻结入口、媒体组件和中文标签验证,已切换至 `app/dist/DoctorWorkstation`,旧包保留在 `app/dist/DoctorWorkstation-before-chinese-20260910-100701`;切换记录位于 `app/artifacts/issued-prescription-ai/local-package-promotion-chinese-20260910.json`。本地两条模型消费者在空闲时重启并加载新策略,prepare 保持正常运行。未关闭当前 Debug 客户端;重启当前本地客户端后使用新版显示,旧分析需要通过“重新分析”生成新批次。本轮没有生成新的真实患者报告。
## 2026-09-10 处理进度
追加 `2026_09_10_prescription_ai_progress.sql` 迁移,给任务表增加小型公开进度摘要;已有密文缓存与历史报告不变。列表和双模型报告窗口显示当前阶段、本阶段组数、等待说明、尝试次数及耗时,完成状态以结果保存成功为准。旧库尚未迁移时继续运行原流程并返回缺少分段进度的提示;迁移后需在空闲时重启三个消费者。界面每 5 秒读取最新状态,等待重试时冻结上次执行耗时。
本地数据库迁移及消费者更新已完成;测试和限制详见 [进度验证记录](prescription-ai-progress-2026-09-10.md)。线上部署须同步本次代码、执行追加迁移并重启对应消费者,本轮未改动远程服务器。
## 2026-09-10 研究对照:必须开出候选方
按用户确认的医学研究用途,默认要求两个模型在看不到本次人工方的前提下各自开出候选处方,再由服务端比较。
- 新配置 `prescription_ai.manual_analysis.require_candidate`(默认 `true`)与 `candidate_insist_rounds`(默认 2)。设为 `false` 可恢复原“关键安全信息缺失即暂缓候选方”的策略。
- 环境变量:`prescription_ai.MANUAL_REQUIRE_CANDIDATE`、`prescription_ai.MANUAL_CANDIDATE_INSIST_ROUNDS`。
- 提示词版本升至 `manual-prescription-required-candidate-v3`。恢复中的旧任务不复用旧提示词的输出,但保留已耗调用预算;已保存的历史报告不变,需要新策略时用“重新分析”生成新批次。
- 模型连续拒绝开方时任务以 `CANDIDATE_WITHHELD_BY_MODEL` 失败并可重试,拒绝的回答不进缓存,界面不显示为完成。追问会额外消耗调用预算,长期拒绝的模型请核对上游应用的内容策略配置。
- 未变:候选方不写回正式处方、审核、订单或药房;一致度仍由服务端固定算法计算;医生统计的独立基线口径不变。
## 2026-09-10 排障:准备资料过久与模型直接报错
现场记录(处方 7556 / 诊单 1391)显示三个独立问题,均已在本地修复。
**1. 每个批次固定卡 300 秒。** 该诊单有 25 条通话记录,全部是已结束但从未转写的旧通话(`transcription_status` 为空、无会话号、无分段)。原判断把"空转写状态"一律视为"仍可能到达",于是每次都等满整个转写窗口;实际资料组装只需 3~4 秒(实测批次 1~4 的准备耗时均为 303~304 秒)。现在只有三种情况才等待:通话仍在进行、转写任务为 pending/running、或通话刚结束且在等待窗口之内。其余旧通话照常作为 `TRANSCRIPT_NOT_VERIFIED_COMPLETE` 缺口展示,不再阻塞。
**2. OpenAI 每次都在 90 秒超时。** 后台分阶段分析原本复用同步页面的 `prescription_ai.timeout=90`,而最终汇总一次调用就超过该时长,三次尝试全部 `UPSTREAM_TIMEOUT`。新增 `prescription_ai.manual_analysis.request_timeout`(默认 240 秒,上限 300,环境变量 `prescription_ai.MANUAL_REQUEST_TIMEOUT`),只作用于后台分析;同步页面仍用原超时。该值必须明显小于任务租约 `lease_seconds`(默认 600 秒),任务每一步都会续租。
**3. 千问每次因格式校验失败整支报废。** 结构不符原本直接结束整个模型分支,一次格式抖动就要走完整轮重试。现按方案第 9.3 节实现"最多一次受控格式修复":任一阶段(文字、附件、压缩、最终报告、追问)解析失败时,立即在同一步骤内追问一次,只重申结构要求,不放宽校验、不接受夹带解释文字、不改动来源编号。修复失败才报错,且 `INVALID_EVIDENCE_OUTPUT`、`INVALID_REPORT_OUTPUT` 改为可重试。两次无效回答都不进缓存,调用次数照常计入预算,进度中记录 `format_rejects` 阶段标记(不含正文)。
其他:界面上"资料截止"等未设置的时间戳(服务端返回 0)显示为"—",不再显示 0 或 1970 年。
本地验证:13 个后端离线测试通过(含新增的转写等待、格式修复、拒绝不入缓存与调用计数用例),桌面测试通过。三个本地消费者已在空闲时重启加载本次修复。线上未做任何改动。
## 2026-09-10 让一致度真正能算出来
排障中用真实数据复算发现:即使模型正常出方,百分比仍然恒为"—"。原因有三,都在本地修好了。
- **医生方没有逐味单位。** 工作站把用量单位存在处方级 `dosage_unit`(用量单位,实测 9389 张为 `g`),剂量基准由 `dose_unit=剂` 表达,逐味行只有药名、剂量、主辅方和药材 ID。比较器原来只在剂型为"饮片"时才补 `g/每剂`,而本机构 9507 张处方是"浓缩水丸",于是每一味都判为"剂量单位缺失"。现在改为读取医生自己填写的 `dosage_unit`,并在 `dose_unit` 为剂/付时确定每剂基准;两者都没有时仍然不可比,不猜单位。所有套用都逐行记录在 `defaults` 中。
- **两边剂型和基准对不上。** 快照中新增"调配约定"(剂型、每味用量单位、每剂/每日基准),来自处方的调配字段,不含任何药味或剂量,交给两个模型,要求候选方按同一剂型、同一单位、按饮片原药材克数/每剂表达。比较器另外只做剂型写法归一(中药配方颗粒→颗粒等),不做任何剂量或提取比换算。
- **模型写的药名不在机构药材字典里。** 实测千问给出的 9 味中有 5 味无法唯一映射:机构字典里是"生麦冬""生五味子""麸炒白术""生地骨皮""麸炒苍术",模型写的是"麦冬""五味子""炒白术""地骨皮""苍术"。按方案要求不能把生品与炮制品自动判为同一味,因此改为把机构药材名清单(仅药名,无库存、价格或患者信息)随候选阶段一起下发,要求逐字选用清单内名称;清单超过提示词预算四分之一时整体不下发,不做截断。
提示词版本升至 `manual-prescription-required-candidate-v4`。旧报告保持原结论,需要新口径请用"重新分析"。本轮新增 8 项比较器检查和 2 项候选阶段检查,后端 11 个离线测试与桌面相关测试全部通过。
## 2026-09-10 消费者被数据库断线卡死
把后台单次请求超时提到 240 秒后,OpenAI 那条消费者从领到任务起就再也没有恢复:日志连续输出 `storage_or_configuration_error`,任务只能等租约到期被判 `LEASE_EXPIRED`。
原因:本机 MySQL 的 `wait_timeout` 与 `interactive_timeout` 都是 **120 秒**,而一次模型调用期间数据库连接是空闲的。超时 90 秒时连接刚好活得下来,提到 240 秒后连接被服务端关闭,之后每一次查询都抛 `PDOException`,连 `fail()` 都写不进去,消费者就永久空转。
修复(只作用于消费者进程,不改 Web 配置):
- 命令启动时对默认数据库连接打开 `break_reconnect`,断线自动重连。
- 每轮异常后主动关闭可能已失效的连接,下一轮重新建立。
- 失败输出改为"异常类名@文件:行号 + SQLSTATE",不打印异常消息本身(消息可能带 SQL 值或病历文本)。原来那句 `storage_or_configuration_error` 查不出任何信息。
部署注意:数据库的 `wait_timeout` 应大于 `prescription_ai.manual_analysis.request_timeout`,两者与任务租约 `lease_seconds` 的关系是 请求超时 < wait_timeout 且 请求超时 << 租约。新增 `server/tests/PrescriptionAiWorkerResilienceTest.php` 固定这些约束。
## 2026-09-10 千问格式失败:不是输出被截断
先前根据"每次 completion 都在 4600~5700 tokens"怀疑上游最大输出长度截断。用合成数据实测否定了这一点:同一个千问应用被要求输出 200 条数组时返回了 13204 tokens 的**完整合法 JSON**(`probe_output_limit.php`)。再用合成病例跑完整候选阶段(`probe_final_schema.php`),千问 9.3 秒返回合法 v4 报告与 6 味候选方,零次格式修复。
因此失败来自真实病例的内容,而不是长度上限。最可能的是来源编号引用:真实病例有几十个来源编号和附件编号,模型引用了未读到或不存在的编号,整份报告即被拒。为此在最终提示词中显式给出 `ALLOWED_EVIDENCE_IDS`(本分支已读来源 + 实际解析成功的附件),并要求引用只能逐字取自该清单——不放宽校验,只消除歧义。未读成功的附件编号不会出现在清单里。
同时校验失败现在记录具体规则(`json_syntax`、`top_level`、`report_lists`、`candidate_herbs` 等)与返回内容长度,修复追问会附上该规则对应的中文提示。下一次真实失败即可直接读出原因,不再靠猜。
两个探针脚本只使用合成数据,不读取任何患者记录。
## 2026-09-10 首批真实对照结果与两处计分缺陷
数据库断线修复后,OpenAI 连续三次成功(批次 6、7、8),千问在批次 6 成功,四份结果全部 `comparable`、零不可比问题。也就是说"AI 独立开方 → 程序比较 → 列表出百分比"这条链路在真实数据上跑通了。
用真实结果逐味核对,发现两处会压低分数的缺陷:
- **炮制标签把同一味药拆成两项。** 机构药材字典把炮制写在药名里(醋五味子、麸炒白术、生麦冬),字典本身没有 processing 字段。模型按接口要求填了 `processing:"醋制"`,比较器就把 `[19,"","主方"]` 和 `[19,"醋制","主方"]` 当成两味不同的药。现在规则收窄为:**只有当处方写的炮制标签的每个字都已出现在药名中时**,才视为对药名的重复描述,不参与身份键(仍作为"炮制标注差异"展示);药名没有承载的炮制(如"黄芪"+"蜜炙")依旧是不同药项。字典自己声明了 processing 时行为不变。比较算法版本升至 `prescription-soft-dice-v1.1.0`。
- **主辅方语义不一致。** 本系统的"辅方"指与主方分开调配的另一张处方,医生用它装另包的煅龙骨、煅牡蛎等;模型把它理解成君臣佐使里的佐使药,于是茯苓、干石斛被标成"辅方",和医生主方里的同一味药匹配不上。已在提示词中明确定义,并要求除非确有单独的辅助处方,所有药味一律填"主方"。
修正身份键后,批次 6 千问由 14.35% 升到 21.76%(共同药味 2→3)。主辅方定义要新批次才生效,历史结果保持原值不改写。
真实分数目前普遍偏低(0%~22%):医生方 16~19 味,模型方 9~11 味,共同 0~3 味。这既有临床思路差异,也因为模型现在只能读到部分证据(附件仍读不到、聊天与转写有缺口)。这个数字是本指标的如实测量,不代表医生或模型谁对谁错。
## 2026-09-10 千问失败的真实原因与药名不在字典的处理
用只读探针把处方 9575 的冻结快照按当前代码重跑(不写任何数据),拿到了此前只能猜的答案:
- `final` 阶段被拒的规则是 **`candidate_fields`**——候选方对象的键或类型不合规(多出字段、`times_per_day`/`usage_days` 写成字符串、`evidence_references` 为空等),不是被截断,也不是来源编号问题。一次受控格式修复即通过。
- `files` 阶段被拒的规则是 `files_shape`——返回的附件条数或编号与清单不一致,同样一次修复即通过。
- 修复通过后仍然 `not_comparable`,原因是 `unknown_herb_name`:模型 12 味里有 1 味不在机构药材字典中(写"生山楂",字典没有;另一例写"泽泻",字典里是"生泽泻/麸泽泻")。按方案不能自动替换或剔除,一味不认识整张方就不可比。
据此新增两项:
1. **药名回问。** 候选方生成后,服务端逐味核对机构药材名清单;有不在清单中的药名,就把这些药名回给模型,要求改用清单内逐字一致的名称,或在临床上确无合适药材时删除该味并说明,其余药味与剂量保持原判断。默认最多回问 2 次,服务端绝不代替模型换药。仍未纠正时,把这些药名写进候选方的风险提示,交给医生核对,不隐藏也不硬算分数。
2. **提示词按实际失败点收紧。** 候选方键集必须完全一致、数值字段必须是 JSON 数字、引用不得为空数组;附件阶段数组长度与编号必须和清单完全一致。
处方 9575 的完整重跑(生成 + 比较,只读)结果:`comparable`,9 次调用,其中 1 次附件格式修复、1 次候选字段修复、2 次药名回问,最终药味与剂量一致度 5.13%、药味重合 7.69%,零不可比问题。
## 2026-09-10 附件能力已放开与吞吐调整
只读核对两个应用的 `/parameters`(不提交任何患者数据):
| 应用 | allowed_file_types | number_limits |
| --- | --- | --- |
| 千问 | image / document / audio / video | 3 |
| OpenAI | document / image / audio / video | 10 |
也就是说 OpenAI 应用先前"只有 custom、不含 image"的限制已经不在了,两个应用现在都接受图片。据此调整:
- **每个模型按自己的上限分批附件。** 新增 `prescription_ai.models.<模型>.max_files`(千问 3、OpenAI 10,环境变量 `prescription_ai.QWEN_MAX_FILES`、`prescription_ai.OPENAI_MAX_FILES`),全局 `max_files` 作为兜底。22 个附件在 OpenAI 侧由 8 次请求降到 3 次,直接缩短一份报告的时间。上限必须与应用配置一致:超限时 Dify 会用 400 invalid_param 拒绝整单。
- **每个模型允许 2 个任务并行**(`prescription_analysis.max_parallel_per_model` 1 → 2)。该值只有在同一通道真的运行了这么多消费者进程时才生效,因此本地按 `prepare×1、qwen×2、openai×2` 启动。此前列表里出现"已用时 1 小时"多半是排队等待,不是单份报告真的跑了一小时。
线上如需同样吞吐,需在进程守护器中为每个模型通道配置两个实例,并确认上游应用与账号的并发额度。
## 2026-09-10 报告窗口重新设计
原窗口把免责声明、批次信息、四段式流程、批次进度、两套模型面板(各自四个页签、复核下拉、意见框、两个按钮)纵向堆在一起,字号权重相同,医生打开后没有落点。重排为六层,信息一条没删,只是各归其位:
1. **标题卡**:处方/诊单/版本一行加粗,右侧是版本有效性、对照类型的状态药丸,再右是历史批次选择与刷新/重新分析。第二行小字放状态、失败原因、资料截止与批次建立时间。
2. **状态行**:读取提示与错误信息。
3. **流程条**:四阶段流程只在批次仍在处理或属于历史批次时显示,完成后不再占位。批次级进度行在两个模型各自报告阶段时隐藏,避免重复。
4. **两张结果卡(视觉重点)**:模型名 + 状态药丸 + **大号一致度数字** + "药味与剂量一致度"说明,下面是覆盖情况与原因、当前阶段、进度条与耗时。数字用中性墨色,不用红绿——高低不是评分。没有可比结果时显示灰色"—"。
5. **共用页签**:综合分析 / 候选用药 / 逐味对照 / 来源与缺口 由八个页签合并为四个,页内左右分栏同时显示两个模型的同一节,真正可以横向对照。
6. **复核条**:两个模型的复核状态、意见、保存与重试压缩到一行,原来纵向占用的约 200px 让给报告正文。免责声明降为脚注。
改动只在展示层:接口、轮询、权限、进度语义和落库数据都没变;`model_views` 的既有键全部保留,新增 `score`、`status_chip`、`card`、`model_label`。97 项桌面回归与 Ruff 通过,示意图见 `app/artifacts/issued-prescription-ai/redesign-running-20260910.png` 与 `redesign-finished-20260910.png`(合成数据,无网络)。
## 2026-09-10 三栏对照工作区与医生原方快照
上一版把两个模型的分数做成了重点,但医生仍要靠记忆比对自己开的方。本轮把"医生原方"接进窗口,改成一次看三列。
**服务端:报告详情新增 `doctor_snapshot`。** 由批次入队时冻结的处方密文投影而来(`PrescriptionAiDoctorSnapshot::project()`),因此模型还没开始就能显示,且选择历史批次时始终是当时那一版——线上处方后来被改动也不会回写。投影只保留展示所需的标量字段:患者姓名/性别/年龄、`clinical_diagnosis`、剂型与用法、逐味药名剂量单位炮制与煎服说明、独立的辅方用法;明确排除电话、签名、`case_record`、药材 ID 等。权限沿用 `loadBatch`(当前处方 + 历史来源双重校验),列表与状态摘要不带该字段。源数据没有的单位或中西医诊断拆分一律不补造。
**桌面端:`PrescriptionReviewWorkspace` 三栏工作区。**
- 左栏"医生原方":患者、医生诊断、逐味药方与用法,按保存顺序原样展示(含重复行)。
- 中栏"诊断对照 + 逐味剂量差异":两个模型的辨证结论并排,下面是逐味剂量点线图(医生原方 / 千问 / OpenAI 三点同轴),支持药名搜索与"仅看剂量差异"过滤,未保存值显示"—"。
- 右栏"复核笔记":待确认差异、已保存对比计数、复核状态与意见表单。
- 顶部导航收敛为"对比总览 / 完整报告 / 资料记录"三个主入口,方义与用法、逐味明细、处理进度、重新分析移入"更多内容"菜单;上方"本次关注"一行给出本批次最该看的一句话。
展示层之外没有改动:轮询、权限、进度语义、比较算法与落库数据均不变。
本轮验证:桌面 182 项(报告窗口 / 对照组件 / 工作区三个套件)通过,Ruff 通过(顺手修了本轮编辑引入的一处 import 排序);后端 12 个离线套件通过,独立 MySQL 上 Queue 78 / Pipeline 90,旧结构 Queue 76 / Pipeline 85 通过,其中新增了原方快照的隐私、权限、不可变与"不补造字段"检查。界面渲染脚本 `app/scripts/render_issued_prescription_ai.py` 用合成数据离线产出 1024/1280/1440 三种宽度及部分完成、历史、等待中状态的截图。
## 2026-09-10 并发生成:多张处方同时分析
此前每个模型最多只跑 1~2 个任务,多张处方只能排队等待,界面上就表现为"一次只能生成一个"。本轮把并发做成真正可调的能力:
- `prescription_analysis.max_parallel_per_model` 默认提到 **4**,并改为环境变量 `prescription_analysis.MAX_PARALLEL_PER_MODEL` 可调(最小 1)。该值只是上限,**每一个并发任务都需要一个消费者进程**:本地按 `prepare×2、qwen×4、openai×4` 启动,启动脚本 `artifacts/prescription-ai-runtime/start_workers.ps1` 支持 `-Mode Add`(保留在跑的进程,补齐缺的实例)与 `-Mode Restart`(全部重启,仅在没有任务运行时使用)。
- `daily_model_tasks`、`daily_patient_batches` 同样改为环境变量可调;单张处方每日分析上限由 10 提到 20——每次修复后重新分析是正常复核方式,这是预算护栏而不是正确性限制。超限时接口仍明确返回"今日分析次数已达预算"。
- **资料扫描不再挡住新处方。** 准备通道原来在同一轮里既领批次又跑来源/处方扫描,而扫描会重建完整上下文;现在扫描只在准备通道无事可做时执行,刚保存的处方永远优先被领取。
实测:同时对 3 张处方发起重新分析,加上原有 1 个在跑的任务,**7 个模型任务并行执行**(千问 3 + OpenAI 4),队列为空,无失败。批次 13(处方 7556,今天反复失败的那张)两个模型都完成:千问 18.88%、OpenAI 16.73%,均为可比。批次 14/15/16 的千问侧一致度分别为 19.29%、42.97%、17.36%,OpenAI 侧三个任务同时在跑。
线上同步部署时,需要在进程守护器里为每个模型通道配置与 `max_parallel_per_model` 相同数量的实例,并确认上游应用与账号的并发额度;数据库连接数也要留出余量(每个消费者一条长连接)。
`PrescriptionAiWorkerResilienceTest` 增加 4 项契约检查(并发下限、三个预算/并发值可由环境变量覆盖、扫描仅在空闲时执行),共 11 项。
## 2026-09-10 科技蓝指标条与图表
报告窗口补上"一眼看懂"的一层:在三栏工作区之上增加科技蓝指标条(`issued_prescription_ai_metrics.py`),全部取自已保存的不可变报告,不新增任何接口调用。
- **两张模型卡**:模型名 + 状态药丸 + 覆盖情况;大号一致度数字与刻度条(千问 #1769E8、OpenAI #0E9384);下面是药味构成堆叠条与图例——医生独有 / 共同 / 该模型独有,并标出药味重合度。没有可比结果时数字为灰色"—"、刻度条留空,绝不显示 0%。
- **历史一致度折线**:来自历史批次列表(按时间从左到右),不可比的批次断线不补点;这是真正能看出"这次比上次接近还是更远"的图。
- **资料覆盖卡**:附件已读 / 受限不支持堆叠条、缺口总数与其中关键项数、资料构成(病历、医生备注、历史处方、问诊通话、监测记录、聊天记录、记录合计)。
- **自适应高度**:窗口高度小于 780 时指标条收起副图(折线、构成条、阶段行),只留数字与刻度条,把首屏让给处方工作区;`MAX_HEIGHT` / `COMPACT_HEIGHT` 有回归测试守住。
同时修掉一个真实显示缺陷:逐味剂量差异表的列宽按"尚未布局完成的视口宽度"按比例计算,结果所有列被压到二三十像素,药名一个字一行、剂量显示成省略号。改为药名列与模型列有最小宽度、图表列吸收剩余宽度,窗口过窄时优先保证药名和剂量可读,并在 `showEvent` 里再测一次。
新增 `app/tests/test_issued_prescription_ai_metrics.py` 19 项检查(空值不变 0、0% 是真实测量、共同药味不超过任一侧、覆盖与缺口计数、历史折线时间顺序、紧凑模式取舍、百分比格式边界)。四个报告窗口相关套件共 **201 项**通过,Ruff 通过。
需要注意:本地客户端连接的是线上 `admin.zhenyangtang.com.cn`,而**医生原方快照(`doctor_snapshot`)尚未部署到线上**,所以左栏会显示"原方快照未保存",图表只能回退到对比结果里记录的医生剂量。要看到完整左栏,需要把服务端这轮代码部署上线。
## 2026-09-11 报告窗口改版落地(第一批)
按确认的设计稿开始落地,配色全部取自工作站的 `reception_style.TECH_BLUE`(主色 #1769E8、深色 #124EA9、画布 #F3F7FD、线条 #DBE5F2),OpenAI 侧继续用 #0E9384 区分。设计稿 `app/artifacts/ui-mockups/v2/` 已同步换色。
**① 蓝色渐变头部条。** 面包屑、标题、版本与对照类型药丸、批次选择与刷新/重新分析/保存复核、六列信息带(处方·诊单 / 患者 / 临床诊断 / 剂型·剂数 / 资料截止 / 比较口径)以及主导航全部收进同一条渐变带,下方白色区域完整留给处方内容。信息带取自冻结快照,线上未部署 `doctor_snapshot` 时逐项显示"—",不猜测。窗口高度小于 780 时信息带与副标题自动收起,保证处方工作区仍占首屏一半以上(有回归测试守住)。
**② 模型失败可就地重试。** 失败原因从隐藏的进度页移到该模型卡片上:中文原因 + 「重试 千问 / 重试 OpenAI」按钮同行显示,只重试该模型并使用原资料快照;手动重试额度用尽时按钮隐藏,改为提示改用"重新分析"。无权限、批次过期、进度暂停时按钮不可用。
**③ 新图表进入程序。** 新增 `issued_prescription_ai_charts.py`(QPainter 自绘):
- `VennChart` 三方用药交集——由两个模型的候选药味与对比行中的医生药味算出七个区域,无候选方时不画空图;
- `WaffleCoverage` 附件华夫图——一格一个附件,蓝=模型已读、橙=受限或不支持;
- `DivergingDoses` 剂量差异发散条——已实现待接入逐味页。
回归:报告窗口相关四个套件 **208 项通过**,Ruff 通过。后续按设计稿推进:逐味页发散条与贡献列、资料与缺口页、处理进度页、历史与趋势页、统计页。
## 2026-09-11 报告窗口改版落地(第二批,全部页面完成)
第一批只完成了头部条、失败重试与三个图表控件;这一批把设计稿里剩下的页面全部做进程序,并顺手修掉改版过程中暴露出来的几处旧问题。
**① 导航按目的地命名,不再按下标。** 新增页面后原来写死的 `setCurrentIndex(2/4/6)` 全部错位("资料记录"实际跳到了原文页,"处理进度"出现了两个同名标签)。改成 `tab_pages` 字典 + `_goto(key)`:主导航是 `对比总览 / 逐味明细 / 资料与缺口 / 历史与趋势`,`更多内容` 菜单收 `方义与用法 / 综合分析 / 处理进度 / 原方记录 / 逐味原文 / 来源原文`。工作区内的"查看依据"链接现在指向新的组合页而不是原始文本页。回归测试断言标签名不重复、每个链接落到正确的页面。
**② 逐味明细页。** 发散条形图(相对医生原方,只画两侧都有剂量的药味)+ 合并表:一行一味药,列出医生 / 千问 / OpenAI 三方剂量与两个模型的贡献值,说明列区分"仅医生使用""模型新增""剂量差 N g"。支持搜索药名与"仅看剂量差异"过滤,脚注写明贡献的算式。
**③ 资料与缺口页。** 左栏资料构成(病历 / 问诊通话 / 记录合计等,按行数自适应高度)与读取范围(资料截止、比较算法、提示词版本、覆盖状态、对照类型);右栏附件华夫图 + 一句话说明"读取 N 个、受限 N 个、未送达 N 个",下方缺口清单按类型合并并标注关键 / 一般。
**④ 处理进度页(与旧进度页合并)。** 批次流程与实时进度条保留在上方,下方是新的每模型阶段卡与调用记录表(模型 / 阶段 / 耗时 / 附件数 / 输出 tokens / 结果)。阶段键 `text:0`、`files:1`、`final` 一律翻成中文(文字资料 1、附件读取 2、开方与对比),错误码走词表。
**⑤ 历史与趋势页。** 一致度趋势柱(按时间从左到右,不可比的批次留空)+ 批次列表(批次号、时间、状态、两个模型的分数、算法版本、对照类型)。分数同时兼容列表接口的扁平 `score` 与详情接口的 `comparison.score`,并且只有 `comparable` 才给分——之前趋势图与列表全是"—"就是漏了这一层。排序也从"把时间戳当数字"改成按时间字符串排。
**⑥ 统计窗口改版。** 四张 KPI 卡(合格开方事件 / 两个模型的有效比较数与均值 / 专家复核合格率)、样本口径漏斗、排除原因合计、按医生列表,下方保留原有的分层明细 HTML。统计窗口现在也套用同一套样式表,卡片有边框有底色。脚注继续写明"一致度不是医生准确率",没有复核样本时合格率显示"—"而不是拿一致度顶替。
**⑦ 顺带修掉的问题。**
- `资料截止:` 一行因为写错表达式一直是空的,现在正常显示时间,没有就显示"—"。
- `覆盖状态:partial` 直接吐英文;覆盖语境下译为"部分资料缺失"(沿用 STATE_LABELS 的"部分完成"会误导成进度)。
- 表头默认居中,在被拉宽的最后一列里飘到中间;统一改为左对齐。
- 指标条在压缩模式下高度不够,华夫图与文字叠在一起;压缩时只留计数文字。
- 打开自带图表的页面(逐味 / 资料 / 历史 / 进度)时指标条自动收起,把高度让给正文。
- `QFrame#AiMetricCard` 此前根本没有样式,卡片是"白底白字";补上边框与圆角。
- 短表格(资料构成、样本口径、排除原因)按行数固定高度,不再出现四行内容配一个滚动条。
回归:
- 桌面端报告窗口五个套件 **226 项通过**,Ruff 通过。
- 服务端 12 个离线套件全部通过(对比 266 项、统计 64 项、进度 38 项、Worker 韧性 11 项等)。
- 服务端两个落库套件在独立 MySQL(127.0.0.1:13379)上通过:`PrescriptionAiQueueTest` 78 项、`PrescriptionAiPipelineTest` 90 项。
- `scripts/render_issued_prescription_ai.py` 现在额外产出 `per-herb / sources / history / pipeline / failed / statistics` 六张截图用于核对。
已知与本次无关的失败,均已用"把本次改动 stash 掉再跑"验证过是既有问题:
- `tests/test_busy_overlay.py::test_shell_construction_never_shows_orphan_business_controls`;
- `tests/test_diagnosis_drawer_visual.py` 整个模块跑到第 8 项时进程级崩溃(access violation / 0xc0000374),stash 后同样在同一位置崩溃。
## 2026-09-11 报告窗口按设计稿重做(第三批)
上一批做出来的界面和确认过的设计稿 `app/artifacts/ui-mockups/v2/` 并不是一回事——指标条、图表形态、页面骨架都是旧的。这一批按设计稿逐页重做。
**导航。** 六个目的地全部平铺在蓝色带里,和设计稿一致:`对比总览 / 完整报告 / 候选与逐味 / 资料与缺口 / 处理进度 / 历史与趋势`。「更多内容」下拉去掉;「重新分析」从菜单里挪到band 上,和「刷新」「保存复核」并排。原始文本页(逐味原文、来源原文、原方记录)不再占导航位,由页面内的链接进入。跳转全部改成按名字寻址(`tab_pages` + `_goto`),不再用会随页面增减而错位的下标。
**① 对比总览(设计稿 01)。** 整页重写:
- KPI 行:两张模型卡(左侧色条 + 环形一致度 + 大号分数 + 药味重合/共同药味/候选药味三列),加一张「本次关注」卡(标题句 + 关键缺口/附件受限/共识药味三个药丸)。原来横在标题下的指标条取消,KPI 行现在属于总览页本身,其它页面不再被它占掉高度。
- 左卡:三方用药交集韦恩图(七个区域各自的药味数,三个集合各自标注「医生 N / 千问 N / OpenAI N」)+ 资料覆盖华夫图与图例。
- 中卡:剂量差异分布——一行一味药,左侧药名与原方剂量,中间以「与原方相同」为中轴的双色发散条,右侧两个模型各自的差值;某个模型没收录就画「OpenAI 未收录」的虚线徽标,两边都一致则画绿色「两模型一致 6 g」,模型新增的药味用浅色条画出全量。底部一条共用刻度尺。
- 右卡:复核清单——按严重度排序的圆点列表(模型风险 > 模型缺口 > 系统缺口),每条注明是哪个模型提出的;下方是复核意见表单。
**② 候选与逐味(设计稿 03)。** 顶部三张处方卡(医生原方 / 千问候选 / OpenAI 候选),各自列出药味与剂量、味数与剂型、服法与风险提示;中间是逐味对照表,贡献列改为「小条 + 数值」,说明列区分三方共有/仅医生使用/某模型未收录/剂量差 N g;筛选从一个复选框换成设计稿的四个计数筹码(全部 / 仅共同 / 仅差异 / 仅未收录)加搜索;底部四格用法对照(服法、疗程、剂型、辅方,医生与模型分行)。
**③ 资料与缺口(设计稿 04)。** 改成三栏:资料构成用按类型的比例条(问诊通话、监测记录、医生备注、历史处方、病历、聊天归档),读取范围列出资料截止、比较算法、提示词、药材字典与覆盖状态;中栏是附件华夫图、读取说明与「模型各自读取到的量」表;右栏是按严重度排序的缺口清单,配一段说明缺口含义的提示块。
**④ 处理进度(设计稿 05)。** 顶部四格统计(批次总耗时 / 模型调用 / 格式修复·追问 / 失败调用);两张模型泳道卡,阶段列表由保存的调用记录归并得出(文字资料分析、附件读取、生成候选与报告、格式修复、药名回问……),没有调用记录就不编造阶段;调用记录表增加「耗时分布」条,按本批次最慢的一次调用取比例。
**⑤ 历史与趋势(设计稿 06)。** 左右两栏:左边是趋势柱与一段「分层说明」——相邻两个批次之间只要比较算法或提示词版本变了,就写清楚 `v1.0.1 → v1.1.0`、`v3 → v4` 并注明两侧分数不能直接相减;右边是批次列表(批次号、时间、状态/原因、两个模型的分数、算法版本),失败批次直接显示模型给出的原因。
**⑥ 统计窗口(设计稿 07)。** 四张 KPI 卡之后是一致度分布直方图(按 20% 分箱,两个模型并排)与「均值 / 中位 · 配对样本」三格小结;右侧样本口径与排除原因改成条形(标签写在条内),下面是那段说明「一致度不是医生准确率」的琥珀色提示块;按医生列表保留在最下方。
为此在服务端把已经算好但没往外传的分布补上:`PrescriptionAiLogic::statistics()` 的每个模型多返回一个 `distribution`(`PrescriptionAiStatistics::summarize()` 早就在算 `[0,20) … [80,100]` 五个分箱,只是之前没进接口)。`PrescriptionAiQueueTest` 增加一条断言,保证分箱确实随统计接口返回。
**⑦ 完整报告(设计稿 02)。** 每一栏加上模型名 + 状态药丸 + 生成时间的抬头。设计稿左侧的章节目录(TOC)没有做——报告正文是服务端结构化字段渲染的,没有可跳转的锚点,硬做会是个假目录。
**顺带修掉的问题。** `比较口径` 改为从对比行真实的单位与剂量基准推导(全部一致才显示,否则回退到冻结处方的剂数单位);`QFrame#AiMetricCard` 之前完全没有样式;统计窗口现在也套用同一套样式表。指标条里已经用不到的堆叠条、迷你折线、覆盖卡与交集卡整体删除,不留死代码。
回归:报告窗口五个套件 **243 项通过**,Ruff 通过;服务端 12 个离线套件全部通过,落库套件 `PrescriptionAiQueueTest` 79 项、`PrescriptionAiPipelineTest` 90 项通过。`scripts/render_issued_prescription_ai.py` 的示例数据换成三份真正不同的处方(医生 8 味 / 千问 7 味 / OpenAI 6 味)与 5 个历史批次,六张页面截图逐张核对过。
## 2026-09-11 报告窗口改造为深色分析控制台(ai-redesign-v2)
按 `C:/Users/pc/WorkBuddy/2026-09-11-16-46-03/ai-redesign-v2.html` 与同目录 `overview.md` 重做。注意:动手前发现这些文件正在被另一个进程同时修改(白色顶栏、可展开交集卡、"保存千问复核"都不是本会话写的),与用户确认另一会话已停止后才接手,本轮是在那个状态上继续改的。
**① 主题令牌独立成模块。** 新增 `issued_prescription_ai_theme.py`:海军蓝底 `#080D18`、卡片 `#0F1726`/`#131D2E`、线 `#22304A`;科技蓝 `#2E7BF6` 只做界面色(按钮、激活态、焦点),千问改天青 `#17BFDD`、OpenAI 改紫 `#9B7BFF`,风险保留琥珀 `#F5B942` 与玫红 `#F4697A`。窗口内六个模块统一从这里取色,不再各自写死浅色值;原先散落的 30 多个浅色硬编码全部换成令牌。
**② 顶栏。** 面包屑「甄养医生工作站 / AI 分析报告」+ 标题 + 版本药丸;右侧四组事实(处方/诊单、剂量口径、资料截止、批次)、版本选择、刷新、重新分析、登录医生。患者 / 主诉 / 临床诊断 / 剂型收在标题下一行——设计稿顶栏没有这几项,但这是医疗窗口,冻结快照的身份信息不能丢。
**③ 一致率对比条(新控件 `issued_prescription_ai_console.py`)。** 两侧各自:模型点 + 状态 + 资料覆盖 + 用时,大号一致率,0/25/50/75/100 刻度条,以及对手所在位置的灰线标注「OpenAI 在此」。中间给差值 `+7.2pt` 与领先方,并列出两侧药味重合。下面一行写明一致率的定义与可比条件。模型失败的原因与「重试 X」按钮也移到这里——那是整个窗口里唯一只属于单个模型的位置。
**④ 左侧编号步骤导航。** 01–06 六个目的地,各带数量徽标(剂量差异项数、报告数、药味数、缺口数、调用次数、批次数),下面是「本次关注」四项计数与「保存本次复核」。原来的横向标签条与顶部 KPI 卡整块删除,`MetricsPanel` 及其 21 项测试一并移除。
**⑤ 对比总览重排为设计稿的两行。**
- 本批结论:四条按规则生成的编号结论(候选方味数 / 一致率与差值 / 剂量偏差最大的三味 / 资料缺口分布),配「结论由规则生成,不代表医生判断」的说明条。
- 药味归属分布:三方共识 / 医方独有 / 千问独有 / OpenAI 独有,各自条形 + **直接列出药名**(韦恩图读不出结论,换成这个)。四组不含"医方与单一模型共用"的药味,标题会把这部分单独说明,避免四个数加不回总数。
- 复核清单:关键 / 一般徽标 + 归并后的条数 + 来源模型,复核意见与状态表单折进同一张卡。
- 剂量差异分解:以「与医方相同」为轴,右=加量、左=减量,刻度 −N/−N/2/0/+N/2/+N,右侧两列分别是千问 Δ 与 OpenAI Δ。
- 高风险提示:偏差 ≥10 克或幅度 ≥50% 自动进清单,标高/中并写明触发原因;阈值只做排序,不替代医生判断。
- 底部四个快捷入口。
**⑥ 其余五页。** 完整报告、候选与逐味、资料与缺口、处理进度、历史与趋势跟随新色板;四页包在滚动容器里,窗口不够高时滚动而不是把控件压在一起。图表的模型色统一换成天青/紫。
**图标。** 窗口不带图片资源,新增 `issued_prescription_ai_glyphs.py`,所有图标(含两个模型徽标、窗口标记)都用 QPainter 画线稿,颜色由调用方给。
**设计稿里剩下的四项也已补齐:**
- **深 / 浅主题切换(◐)**:`issued_prescription_ai_theme.py` 里给出 `DARK` / `LIGHT` 两套令牌,`use_theme()` 就地改写 `CONSOLE` 并回调所有派生值(图表色、卡片样式表、窗口样式表)。窗口构建整段抽成 `_build_ui()`,切换时原地重建:当前页面、选中批次与**尚未保存的复核意见**都会带过去。
- 顺带查出一个真 bug:`QPushButton#AiStep:checked QLabel#AiStepName` 这种"父级伪状态 + 后代"选择器在 Qt 里是按子控件的状态判定的,于是六个步骤的文字**全部**取了选中态的白色 —— 深色底看不出来,一换浅色就成了白底白字。改为在 `set_current()` 里按选中与否直接给标签上色。
- **附件缩略图网格**:资料与缺口页把华夫图换成一格一个附件的磁贴,标注类型与编号,蓝=已读、琥珀=受限/不支持,悬停给出完整编号、状态、原因与版本核验情况。为此服务端 `PrescriptionAiGenerator` 在覆盖清单的四条写入路径上都补了 `type` 字段(原来只有 `file_id`),并在 `PrescriptionAiGeneratorTest` 里加了断言守住。
- **图表悬停说明**:贡献条给出算式、来源构成条说明归一口径、调用耗时条给出占最慢一次的比例、趋势柱与分布柱逐批次/逐分箱列出数值、归属分布的条形与药名列出完整药味。
- **完整报告页章节目录**:报告按字段切成带锚点的章节,左栏列出章节可跳转(两栏同时滚动),并提供「并排对照 / 仅千问 / 仅 OpenAI / 只看不同段落」四种显示方式;"只看不同段落"按章节渲染结果是否逐字相同来判定,规则可解释。
- 另外把左栏「附件受限」的口径改成**模型确实没读到的附件数**(原先取的是缺口记录条数,与页面上的附件状态对不上)。
回归:
- 报告窗口四个套件(`test_issued_prescription_ai{,_pages,_workspace,_comparison}.py`)**231 项通过**,Ruff 通过;服务端 `PrescriptionAiGeneratorTest` 通过。
- 全量桌面套件(排除早已进程级崩溃的 `test_diagnosis_drawer_visual.py`)有 18 项失败,全部落在本轮没有碰过的模块里:`busy_overlay`、`list_detail_geometry`、`patients_visual_blue`、`patient_orders_visual_blue`、`patient_progress_visual_blue`、`reception_completion_button_ui`、`reception_parity_ui`、`silent_list_loading`、`diagnosis_order_video_visual`、`patient_ai_report_desktop`、`diagnosis_index_visual`。
- 与改造前的一次完整跑(同一工作区、深色化之前)逐条比对:失败集合完全一致,只多出 `diagnosis_index_visual` 一项,而该项单独跑通过 —— 是整轮跑到后段(进程占用 4.6 GB)时的顺序/内存相关抖动。
- 另外验证过:把 `DEBUG_MODE` 临时改回 `True` 后这些用例仍然失败,所以与工作区里那处版本/调试开关的改动无关;`git status` 显示本轮只动了 `issued_prescription_ai*` 这一组文件。
## 逐项对齐设计稿 `ai-redesign-v2.html`(字号 / 间距 / 描边)
上一轮把结构搭到位,但字号与间距是估的,医生反馈"好多细节都不匹配",并指出两处硬伤:一致率刻度的百分比标签被裁掉、逐味对照矩阵的列错位。这轮按设计稿的 CSS 逐条换算(`html{font-size:15px}`,所以 .68rem = 10.2px、.82rem = 12.3px、.9rem = 13.5px、1.8rem = 27px),把两边的数值对齐。
**取值方式。** 设计稿是本地 HTML,直接读它的 CSS 而不是量截图:`--radius:10px`、`--radius-sm:7px`、卡片描边用 `--line-soft` 而不是 `--line`、`.card-h{padding:15px 18px 0}`、`.card-body{padding:14px 18px 18px}`、`.grid{gap:14px}`。浅色令牌与 `rgba()` 色调(`--blue-dim` 等)按叠在卡片底色上的合成值写进调色板,Qt 这边没有 alpha 混合也能落到同一颜色。
**改到的地方:**
- **卡片头**。设计稿里 `.card-h` 只有标题 + 小字说明 + 右侧图例,**没有图标**;`_panel_head()` 去掉了 glyph 参数,标题 13.5px/600、说明 10.65px `--text-faint`。说明文字换成 `HintLabel`:宽度不够时省略号收尾并留 tooltip,绝不再把卡片顶宽(上一版为此把它设成 `Ignored`,结果整行说明直接消失)。
- **左侧步骤导航**。`.stepnav` 的 26px|1fr|auto 栅格:序号片 22×22/圆角 6/10.2px,名称 12.6px,数量徽标做成 20px 起宽的胶囊(9.9px、圆角 8、上下 17px 固定高)——之前徽标会被拉满整行高度,六个连成一条竖条,看着像滚动条。选中态按设计稿画左侧 3px 圆角竖条(`QSS` 的 `border-left` 会把内容顶偏,改在 `_StepButton.paintEvent` 里画),徽标按严重度分色:待决项有关键项时取玫瑰,资料缺口取琥珀。
- **一致率对比条**。百分号按 `.val sup` 处理成小一号、上标、`--text-dim`,新增 `ScoreLabel` 自绘,`text()` 仍返回 "56.1%" 这一个字符串,调用方与测试不受影响。刻度条填充改成设计稿的 `rgba(hue,.45) → hue` 渐变,四分位分隔线换 `--grid-line`,对手模型的标记改成 2px 圆角竖条。卡片左缘补上蓝→紫渐变竖条(按卡片圆角裁剪),中间差值列的分隔线改虚线。两侧模型块顶部对齐,失败的一侧不再把整列往下推。
- **刻度标签**。设计稿里 `.axis` 的 `bottom:-1px` 让 0%/25%/… 压在轨道上、被填充盖掉一半 —— 这正是医生截图里"看不清"的那处。这里**不照抄**:标签仍排在轨道下方 31px 处,可读优先。
- **逐味对照矩阵**。列固定为 药味 / 千问(克/剂 · 贡献度)/ OpenAI(克/剂 · 贡献度)/ 医方原方 / 结论,后四列定宽、首列拉伸,每个模型一格自绘(剂量 + 贡献条 + 数值),不再靠自动列宽挤在一起。右上补回「千问贡献度 / OpenAI 贡献度」图例;「仅医方使用」按设计稿不套胶囊,用弱化文字。
- **列表行**。本批结论、复核清单按 `.finding li` / `.rv-item` 加 1px `--line-soft` 分隔线与 10/11px 的上下留白;结论正文 12.3px/160%,复核标题 12.3px、副题 10.5px、计数用等宽字 13px。
- **表格与统计卡**。所有表头 10px/`--text-faint`/`padding:10px 16px`,单元格 12px/`padding:8px 16px`,分隔线 `--line-soft`;处理进度页四个数字改 26px 等宽字,标签 10.65px 带字距。
- **完整报告正文**。文档样式表原先写死了浅色(`#EFF4F9` 表头、`#244C79` 标题),深色主题下表格是一块白。改成按当前调色板生成:正文 13px/195%、章节标题 11px `--blue-text`、表头 10px。Qt 的富文本引擎会自己决定 `<h3>` 的字号、无视样式表,所以章节标题改用 `<p class=sec>`。
- **趋势图**。每根柱子按设计稿在顶部标出自己的数值(用该模型的颜色),两根柱子拉开到能容下各自标签的间距。
- **说明条**(`ReadingNote`)。虚线描边、9/12 内边距、10.8px、圆角 7,并在 `resizeEvent` 里按换行后的高度设最小高——否则 QLabel 只申报一行的高度,第二行被卡片裁掉。
**窄窗口跟着设计稿的断点走。** 设计稿在 `@media (max-width:1240px)` 把卡片栅格收成一列、把左栏改成横向;照着这个思路补了三处,都是这轮出图时才暴露的:
- 对比总览的两行卡片改用 `QGridLayout`,工作区窄于 960px 时收成一列。原先三张卡各占 1/3,940 宽时「本批结论」只剩两个字一行。
- 左栏放进自己的滚动区。窗口不够高时它原来会被压到最小高度以下,「本次关注」四行直接塌成四条空条。
- 顶栏在窗口窄于 1400px 时把资料摘要(处方 / 诊单、剂量口径、资料截止、批次)换行到第二排;窄于 1240px 再收起翻页箭头、面包屑与读取状态。原先 1280 宽时标题会被裁成「诊断与药...」。
**原生标题栏跟着主题走。** 深色控制台原先只画到窗口边框为止,Windows 自己画的标题栏还是浅色,接缝很突兀。`apply_window_chrome()` 用 `DwmSetWindowAttribute` 把标题栏底色、文字色、边框色设成当前调色板(Windows 11 22000+ 生效;其他系统调用失败即保持原样,不报错),窗口显示时与主题切换时各调一次;统计窗口同样处理。
**候选卡片不再自己截断。** 三张处方卡原来写死 `setMaximumHeight(250)`,方义长一点(真实数据里常有五六行)就把药味表和「另有 N 味」一起切掉。去掉上限,药味表按内容申报高度,方义换成 `WrappedLabel` —— 普通 `QLabel` 开了自动换行也只申报一行的高度,布局照给一行,剩下的被裁;这个类在 `resizeEvent` 里按实际宽度回问一次所需高度并设为最小高度。`ReadingNote` 里原来那段同样的代码合并到这个类上。
**表格直接往下铺,不再挤在剩余空间里。** 调用记录原来带 stretch 塞在页面底部,实际只剩六十来像素,表头被切、一次只看得到一行。`_fit_rows()` 统一成按实际行高量一次(原先那版假定每行 31px),关掉表格自己的纵向滚动条,把高度设成表头 + 所有行;逐味对照矩阵、缺口清单同样处理。页面长了就由页面自己滚——这也是设计稿的做法,矩阵在稿子里就是十二行一次铺完。
顺带修掉两处连带问题:
- 处理进度页是唯一没进滚动容器的页,表格铺开后整页被压到 464px,两个模型的阶段行直接塌成零高。改成和其余五页一样走 `_add_scrolled_tab()`。
- 顶栏换行阈值原来写死 1400px,但批次翻页箭头出现时横向要 1482px,1440 下资料摘要又被切了。改成按 `top_row.sizeHint()` 与顶栏可用宽度实测比较,换行后把收起的宽度加回去再比,不会来回抖。
回归:
- 报告窗口四个套件 **234 项通过**(新增一个跳过分隔线取结论文本的辅助函数),Ruff 通过。
- 六个页面在 1440 / 1280 / 1024 / 940 四档宽度重新出图,深浅两套主题各出一张,逐张与设计稿截图对照。
- 全量桌面套件(排除早已进程级崩溃的 `test_diagnosis_drawer_visual.py`)**17 项失败**,全部落在本轮没碰过的模块里,与改造前基线的 18 项相比只少了 `diagnosis_index_visual` 那条——该条单独跑通过,是整轮跑到后段的顺序/内存抖动,此前已记录。全仓只有那四个 AI 测试文件 import 本轮改动的 dialog 模块。