"""Render the prescription comparison window with synthetic, offline data.""" from __future__ import annotations import os from copy import deepcopy from pathlib import Path from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PySide6.QtWidgets import QApplication from doctor_workstation.ui.dialogs import issued_prescription_ai as ai from doctor_workstation.ui.theme import apply_theme def example_batch() -> dict[str, Any]: """Three prescriptions that actually differ, so every state of the page has something to draw.""" doctor = {"生地黄": 16, "天花粉": 15, "干石斛": 20, "醋五味子": 6, "生麦冬": 12, "茯苓": 10, "红参片": 6, "生牡丹皮": 10} candidates = { "qwen": {"生地黄": 15, "干石斛": 12, "醋五味子": 6, "生麦冬": 12, "茯苓": 15, "麸炒白术": 12, "丹参": 15}, "openai": {"生地黄": 12, "醋五味子": 6, "生麦冬": 10, "茯苓": 12, "生白芍": 5, "炒酸枣仁": 6}, } reports = { "qwen": {"summary": "界面演示数据:对照药味组成、剂量及用法,辅助医生逐项复核。", "diagnosis": "消渴病,气阴两虚兼血热。兼证需结合舌脉资料核实。", "analysis": "阅读药方对比页,查看同名药材的剂量差异、仅医生方药味与仅候选方药味。", "risk_assessment": [{"level": "high", "label": "血压数据缺失,影响补气药安全性评估"}, {"level": "medium", "label": "肝肾功能具体指标未提供"}], "missing_information": ["甲状腺功能及眼底检查报告缺失", "舌象与脉诊仅有附件,无文本记录"]}, "openai": {"summary": "界面演示数据:两份候选方独立生成,引用编号可回查原始资料。", "diagnosis": "已记录气阴两虚证;辨证依据需补充。", "analysis": "候选方以益气养阴为主,安神药味为本模型新增,需要医师确认。", "risk_assessment": [{"level": "high", "label": "缺少当前用药记录,无法排除配伍风险"}], "missing_information": ["舌象与脉诊仅有附件,无文本记录", "近期体重变化及 BMI 数据缺失"]}, } models = {} for key, doses in candidates.items(): herbs, rows = [], [] for name in sorted(set(doctor) | set(doses), key=lambda item: (item not in doses, item)): common = {"name": name, "unit": "g", "dose_basis": "per_dose", "formula_type": "主方"} left, right = doctor.get(name), doses.get(name) if right is not None: herbs.append({**common, "dosage": right}) rows.append({ **common, "key": name, "doctor": {**common, "dosage": left} if left is not None else None, "candidate": {**common, "dosage": right, "source_rows": [len(herbs) - 1]} if right is not None else None, "doctor_dosage": left, "candidate_dosage": right, "match_type": "candidate_only" if left is None else "doctor_only" if right is None else "matched", "contribution": min(left, right) / max(left, right) if left and right else None, }) matched = sum(row["match_type"] == "matched" for row in rows) denominator = len(doctor) + len(herbs) models[key] = { "status": "success", "report_id": 10 if key == "qwen" else 11, "candidate": {"status": "available_for_review", "prescription_name": "候选药方 · 界面示例", "herbs": herbs, "dose_basis": "per_dose", "prescription_type": "浓缩水丸", "usage_instruction": "服法由医生复核后确认。", "usage_days": 7, "times_per_day": 2, "rationale": "这是用于检查界面排版的模拟药方,不对应真实患者。", "risk_warnings": ["药味与剂量差异需要逐项复核。"]}, "comparison": {"status": "comparable", "score": 200 * sum(row["contribution"] or 0 for row in rows) / denominator, "herb_score": 200 * matched / denominator, "matched_count": matched, "doctor_count": len(doctor), "candidate_count": len(herbs), "rows": rows, "reason": "药味与剂量可比;服法与疗程需单独复核。", "usage_differences": []}, "report": reports[key], "coverage": {"status": "incomplete", "complete": False, "files": [{"file_id": f"a{index:04d}", "type": "image" if index % 3 else "document", "status": "processed", "transmitted": True, "version_verified": True} for index in range(20 if key == "qwen" else 17)] + [{"file_id": f"z{index:04d}", "type": "document", "status": "restricted", "transmitted": False, "version_verified": False, "reason": "FILE_UNAVAILABLE_OR_UNSUPPORTED"} for index in range(2)]}, "progress": {"stage_label": "处理完成", "elapsed_seconds": 135 if key == "qwen" else 591, "attempt": 1}, "usage": {"total_calls": 3, "calls": [ {"stage": "text:0", "ok": True, "latency_ms": 3120, "file_count": 0, "usage": {"completion_tokens": 980}, "error_code": ""}, {"stage": "files:0", "ok": True, "latency_ms": 22400, "file_count": 20 if key == "qwen" else 17, "usage": {"completion_tokens": 2140}, "error_code": ""}, {"stage": "final", "ok": True, "latency_ms": 18800, "file_count": 0, "usage": {"completion_tokens": 5617}, "error_code": ""}]}, "review": {"status": "viewed", "comment": ""}, "algorithm_version": "prescription-soft-dice-v1.1.0", "prompt_version": "manual-prescription-required-candidate-v4", } return {"id": 40, "prescription_id": 7556, "patient_id": 1391, "diagnosis_id": 1391, "prescription_revision": 1, "status": "success", "validity": "current", "comparison_type": "non_independent", "models": models, "coverage_status": "partial", "created_at": "2026-09-10 15:29:00", "cutoff_at": "2026-09-10 15:29:00", "source_summary": {"diagnoses_count": 2, "attachment_count": 22, "video_calls_count": 4, "chat_messages_count": 137, "source_record_count": 31}, "doctor_snapshot": {"patient": {"name": "张卫君", "gender": 2, "gender_label": "女", "age": 58}, "diagnosis": {"clinical_diagnosis": "2型糖尿病 消渴病 · 气阴两虚兼血热", "chief_complaint": "咳嗽反复1月余"}, "prescription": {"herbs": [{"name": name, "dosage": dose, "unit": "g", "dose_basis": "per_dose", "formula_type": "主方"} for name, dose in doctor.items()], "prescription_type": "浓缩水丸", "dose_count": 1, "usage_instruction": "每日1剂,水煎分服。"}}, "missing": [{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True}, {"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True}, {"code": "ARCHIVE_SYNC_WATERMARK_UNAVAILABLE", "critical": False}]} def example_statistics() -> dict[str, Any]: """Doctor-level shape the statistics window renders; synthetic, but structurally complete.""" def doctor(identifier: int, name: str, totals: tuple[int, int, int], qwen: tuple[int, float | None], openai: tuple[int, float | None], review: tuple[int, int]) -> dict[str, Any]: total, patients, paired = totals models = {} for key, (eligible, mean) in (("qwen", qwen), ("openai", openai)): share = (0.34, 0.38, 0.18, 0.07, 0.03) models[key] = {"eligible_count": eligible, "mean": mean, "median": None if mean is None else round(mean - 1.4, 1), "excluded_reasons": {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": max(0, total - eligible - 2), "incomplete_coverage": min(2, max(0, total - eligible))}, "distribution": {name: round(eligible * fraction) for name, fraction in zip( ("[0,20)", "[20,40)", "[40,60)", "[60,80)", "[80,100]"), share, strict=True)}} evaluated, qualified = review return {"doctor_id": identifier, "doctor_name": name, "total_count": total, "patient_count": patients, "paired_count": paired, "models": models, "review": {"evaluated_count": evaluated, "qualified_count": qualified, "qualified_rate": None if not evaluated else round(100 * qualified / evaluated, 1)}} doctors = [doctor(26, "何医生", (18, 14, 9), (12, 21.4), (9, 18.9), (6, 4)), doctor(31, "李医生", (11, 9, 4), (7, 26.8), (5, 24.1), (3, 1)), doctor(44, "王医生", (6, 5, 1), (2, 15.2), (0, None), (0, 0))] return {"total_count": sum(item["total_count"] for item in doctors), "patient_count": sum(item["patient_count"] for item in doctors), "doctors": doctors} class PreviewRepository: def __init__(self) -> None: self.batch = example_batch() def list_prescription_ai_reports(self, **_params: Any) -> dict[str, Any]: return {"enabled": True, "lists": deepcopy(self.history()), "count": len(self.history())} def history(self) -> list[dict[str, Any]]: """The current batch plus the earlier ones it supersedes, newest first.""" older = [ {"id": 39, "created_at": "2026-09-10 15:18:00", "status": "success", "validity": "superseded", "comparison_type": "non_independent", "models": {"qwen": {"comparison": {"status": "comparable", "score": 48.9}, "algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"}, "openai": {"comparison": {"status": "comparable", "score": 41.7}, "algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"}}}, {"id": 38, "created_at": "2026-09-10 14:05:00", "status": "success", "validity": "superseded", "models": {"qwen": {"comparison": {"status": "comparable", "score": 33.4}, "algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"}, "openai": {"comparison": {"status": "comparable", "score": 31.1}, "algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"}}}, {"id": 37, "created_at": "2026-09-10 13:25:00", "status": "failed", "validity": "superseded", "models": {"qwen": {"error_message": "模型返回未通过校验", "algorithm_version": "prescription-soft-dice-v1.0.1"}, "openai": {"comparison": {"status": "not_comparable"}}}}, {"id": 36, "created_at": "2026-09-10 12:34:00", "status": "success", "validity": "superseded", "models": {"qwen": {"comparison": {"status": "not_comparable"}, "algorithm_version": "prescription-soft-dice-v1.0.0"}, "openai": {"comparison": {"status": "not_comparable"}}}}, ] return [self.batch, *older] def get_prescription_ai_report(self, _batch_id: int) -> dict[str, Any]: return deepcopy(self.batch) def prescription_ai_statistics(self, *_args: Any, **_params: Any) -> dict[str, Any]: return deepcopy(example_statistics()) def main() -> None: application = QApplication.instance() or QApplication([]) apply_theme(application) output = Path(__file__).resolve().parents[1] / "artifacts" / "issued_prescription_redesign" output.mkdir(parents=True, exist_ok=True) def immediate(function: Any, **callbacks: Any) -> None: result = function() if callbacks.get("on_success"): callbacks["on_success"](result) if callbacks.get("on_finished"): callbacks["on_finished"]() ai.run_async = immediate repository = PreviewRepository() dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=7556, current_user={"name": "张医生"}) dialog.show() names = {dialog.tabs.tabText(index): index for index in range(dialog.tabs.count())} for filename, width, height, tab in ( ("prescription-1440.png", 1440, 940, "对比总览"), ("prescription-1280.png", 1280, 860, "对比总览"), ("prescription-1024.png", 1024, 700, "对比总览"), ("prescription-940.png", 940, 640, "对比总览"), ("original-1280.png", 1280, 860, "原方记录"), ("analysis-1280.png", 1280, 860, "完整报告"), ("candidate-1280.png", 1280, 860, "方义与用法"), ("per-herb-1440.png", 1440, 940, "候选与逐味"), ("sources-1440.png", 1440, 940, "资料与缺口"), ("history-1440.png", 1440, 940, "历史与趋势"), ("pipeline-1440.png", 1440, 940, "处理进度"), ): dialog.resize(width, height) dialog.tabs.setCurrentIndex(names[tab]) for _ in range(4): application.processEvents() assert (dialog.width(), dialog.height()) == (width, height), (filename, dialog.size()) assert dialog.grab().save(str(output / filename)) print(filename, "window", width, height, "workspace", dialog.tabs.width(), dialog.tabs.height()) dialog.tabs.setCurrentIndex(names["完整报告"]) dialog._set_report_mode("differences") application.processEvents() assert dialog.grab().save(str(output / "report-differences-1440.png")) dialog._set_report_mode("both") dialog.tabs.setCurrentIndex(names["对比总览"]) dialog.resize(1280, 860) for _ in range(4): application.processEvents() dialog.review_model.setCurrentIndex(1) application.processEvents() assert dialog.grab().save(str(output / "openai-1280.png")) dialog.review_model.setCurrentIndex(0) repository.batch["status"] = "running" repository.batch["models"]["openai"].update(status="running", candidate=None, comparison=None, report=None) dialog.refresh() application.processEvents() assert dialog.grab().save(str(output / "partial-1280.png")) repository.batch.update(validity="superseded", status="success") dialog.refresh() application.processEvents() assert dialog.grab().save(str(output / "historical-1280.png")) repository.batch.update(validity="current", status="running") repository.batch["models"]["qwen"].update(status="running", candidate=None, comparison=None, report=None) dialog.refresh() application.processEvents() assert dialog.grab().save(str(output / "pending-1280.png")) # A failed model must state the reason and offer its own retry on the card itself. repository.batch.update(validity="current", status="partial") repository.batch["models"]["qwen"].update( status="failed", error_code="upstream_timeout", error_message="上游模型超时,未返回结果", candidate=None, comparison=None, report=None, retry_count=1, max_retry=3) repository.batch["models"]["openai"] = deepcopy(example_batch()["models"]["openai"]) dialog.refresh() application.processEvents() assert dialog.grab().save(str(output / "failed-1280.png")) # The light theme is the same window with the other palette; capture it once. dialog.resize(1440, 940) dialog._switch_theme() for _ in range(12): application.processEvents() dialog.repaint() assert dialog.grab().save(str(output / "light-1440.png")) dialog._switch_theme() for _ in range(4): application.processEvents() dialog.close() statistics = ai.PrescriptionAiStatisticsDialog(repository, ["*"]) statistics.resize(1240, 880) statistics.show() for _ in range(4): application.processEvents() assert statistics.grab().save(str(output / "statistics-1240.png")) statistics.close() print("statistics-1240.png", statistics.panel.doctors.rowCount(), "doctors") print(output) if __name__ == "__main__": main()