]*v-model="editForm\.tracking_number"[^>]*>/g)]
+ assert.equal(inputs.length, page === 'order_list.vue' ? 2 : 1)
+ for (const [input] of inputs) assert.match(input, /\sdisabled(?:\s|\/?>)/)
+ assert.match(descriptor.template.content, /v-if="canQuickTrackRow\(row\)"/)
+ })
+}
diff --git a/app/research/issued-prescription-ai-ui-redesign.md b/app/research/issued-prescription-ai-ui-redesign.md
new file mode 100644
index 000000000..80c4a7639
--- /dev/null
+++ b/app/research/issued-prescription-ai-ui-redesign.md
@@ -0,0 +1,26 @@
+# 已开处方 → AI 界面改造
+
+## 功能分析
+
+这是基于已保存资料快照的双模型分析与医生复核工作台。主流程是确认患者和批次、查看模型完成情况、对照原方差异、核查资料缺口、记录所选模型的复核意见。
+
+保留六个入口:对比总览、完整报告、候选与逐味、资料与缺口、处理进度、历史与趋势。历史批次切换、刷新、重新分析、单模型重试、独立复核草稿及保存继续使用现有服务和权限逻辑。医生原方及支持报告仍可访问。
+
+## 设计决定
+
+- 主程序 `shell.py` 将 prescriptions 等业务页列为科技蓝页面。因此继续复用 `reception_style.TECH_BLUE`:主色 #1769E8,背景 #F3F7FD,白色面板,分割线 #DBE5F2。没有采用主程序其他页面的靛蓝令牌,也没有修改全局主题。
+- 深蓝渐变横幅改为白色标题工具栏。批次状态放在顶行,患者及原方信息单独成行,当前导航用浅蓝背景与蓝色底线标识。
+- 缩小一致度圆环和数字,保留共同药味、候选药味与药味重合的计算依据;修复指标区样式误把分隔线应用到每个子标签的问题。
+- 总览由三栏改成剂量差异主区与固定复核侧栏。三方交集和附件覆盖改为可展开的摘要,减少初始屏幕空白和对主图的挤压。
+- 复核模型和复核状态并排显示,意见输入和保存按钮放在一起。顶部保存动作显示当前模型名,切换时同步更新。
+- 短窗口收起次要信息,保留剂量主图和复核输入;展开图表时剂量滚动区域可让出高度。列表内容保持最小高度,避免刷新后条目重叠。
+- 保留未知值与零值区分、不可比/历史状态处理、模型各自失败重试以及现有一致度说明。
+
+## 验证
+
+- 处方 AI 五组回归测试覆盖数据处理、权限、异步读取、逐味比较、页面、模型复核与布局。
+- 新增八种窗口/展开状态组合的控件边界检查,以及展开/收起不改变比较数据和复核清单的检查。
+- 离线模拟数据预览覆盖 1440×940、1280×860、1024×700、940×640,各内容页面、展开图表、运行中、失败、历史及统计窗口。
+- 预览输出:`app/artifacts/issued_prescription_redesign/`。模拟数据仅供 UI 验证,不对应真实患者。
+
+本次改动在现有未提交工作区基础上完成,仅调整桌面呈现与相关回归检查;没有重新打包或发布桌面安装程序。
diff --git a/app/scripts/render_issued_prescription_ai.py b/app/scripts/render_issued_prescription_ai.py
new file mode 100644
index 000000000..6d144879b
--- /dev/null
+++ b/app/scripts/render_issued_prescription_ai.py
@@ -0,0 +1,281 @@
+"""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()
diff --git a/app/src/doctor_workstation/__init__.py b/app/src/doctor_workstation/__init__.py
index eeb331ce5..7c9cfdc43 100644
--- a/app/src/doctor_workstation/__init__.py
+++ b/app/src/doctor_workstation/__init__.py
@@ -3,9 +3,9 @@
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
# Single source of truth for runtime, package, installer, and executable versions.
-__version__ = "1.4.2"
+__version__ = "1.4.5"
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
-DEBUG_MODE = True
+DEBUG_MODE = False
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py
index 574fda8f9..c3ab78b03 100644
--- a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py
+++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py
@@ -8,7 +8,8 @@ from math import isfinite
from time import monotonic
from typing import Any
-from PySide6.QtCore import QDate, Qt, QTimer
+from PySide6.QtCore import QDate, QSize, Qt, QTimer
+from PySide6.QtGui import QFont
from PySide6.QtWidgets import (
QComboBox,
QDateEdit,
@@ -20,8 +21,9 @@ from PySide6.QtWidgets import (
QLineEdit,
QProgressBar,
QPushButton,
- QSizePolicy,
+ QScrollArea,
QSplitter,
+ QStackedWidget,
QTabWidget,
QTextBrowser,
QTextEdit,
@@ -30,15 +32,25 @@ from PySide6.QtWidgets import (
)
from ...core.errors import AuthenticationExpiredError
-from ..widgets import format_record_time, friendly_error, has_permission, run_async
+from ..reception_style import body_family
+from ..widgets import first_value, format_record_time, friendly_error, has_permission, run_async
+from .issued_prescription_ai_console import AgreementBar, StepRail, rail_qss
+from .issued_prescription_ai_glyphs import Glyph, LogoTile, glyph_icon
from .issued_prescription_ai_labels import (
- EXTRA_FIELD_LABELS,
- SYSTEM_LABELS,
+ FIELD_LABELS,
+ STATE_LABELS,
field_text,
plain_text,
system_text,
value_text,
)
+from .issued_prescription_ai_pages import (
+ CandidatesPage,
+ HistoryPage,
+ ProgressPage,
+ SourcesPage,
+ StatisticsPanel,
+)
from .issued_prescription_ai_progress import (
ACTIVE_STATES,
COMPACT_STAGES,
@@ -46,50 +58,231 @@ from .issued_prescription_ai_progress import (
flow_text,
progress_view,
)
+from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE
+from .issued_prescription_ai_theme import apply_window_chrome, on_theme_changed, toggle_theme
+from .issued_prescription_ai_workspace import PrescriptionReviewWorkspace
+
+# The six destinations of the approved design, in order. Raw saved text is reachable from the
+# in-page links, not from the navigation, so the bar stays readable at a glance.
+NAV_PRIMARY = (("overview", "对比总览"), ("report", "完整报告"), ("per_herb", "候选与逐味"),
+ ("gaps", "资料与缺口"), ("pipeline", "处理进度"), ("history", "历史与趋势"))
MODELS = {"qwen": "千问", "openai": "OpenAI"}
# One calm clinical palette. The agreement number is deliberately neutral ink: a high or low
# percentage is not a grade, so it never gets a green or red treatment.
-INK, MUTED, LINE, CARD, PAGE = "#1f2933", "#6b7a8c", "#e3e8ef", "#ffffff", "#f4f6f9"
-CHIP_TONES = {
- "neutral": ("#eef2f7", "#4a5a6b"),
- "info": ("#e8f1fd", "#1b4f9c"),
- "ok": ("#e9f6ee", "#1c6b45"),
- "warn": ("#fdf3e3", "#8a5a06"),
- "risk": ("#fdecec", "#a52222"),
-}
-DIALOG_STYLE = f"""
+INK, MUTED, LINE, CARD, PAGE = (TECH_BLUE[key] for key in ("text", "muted", "line", "surface", "canvas"))
+
+
+@on_theme_changed
+def _rebuild_shades() -> None:
+ """The window's own shorthands follow the active palette."""
+
+ global INK, MUTED, LINE, CARD, PAGE
+ INK, MUTED, LINE, CARD, PAGE = (TECH_BLUE[key] for key in
+ ("text", "muted", "line", "surface", "canvas"))
+ CHIP_TONES.update(_chip_tones())
+def _report_css() -> str:
+ """The report columns' own type scale, in the palette that is active right now.
+
+ A rebuilt body picks the new palette up: the theme switch tears the window's body down
+ and builds it again, so this runs once per theme.
+ """
+
+ return (
+ f"p {{ margin: 8px 0; line-height: 195%; font-size: 13px; color: {TECH_BLUE['text']}; }} "
+ f"li {{ line-height: 195%; font-size: 13px; color: {TECH_BLUE['text']}; }} "
+ "table { border-collapse: collapse; width: 100%; } "
+ f"th {{ background: {TECH_BLUE['surface_2']}; color: {TECH_BLUE['faint']}; font-weight: 500;"
+ " font-size: 10px; text-align: left; } "
+ # Cell text arrives wrapped in , whose own margin sets the row height; the cell
+ # padding only has to add the side gutters.
+ f"td, th {{ border: 1px solid {TECH_BLUE['line_soft']}; padding: 1px 12px; }} "
+ f"td {{ font-size: 12px; color: {TECH_BLUE['text']}; }} "
+ f"p.sec {{ color: {TECH_BLUE['accent_text']}; font-size: 11px; font-weight: 600;"
+ " margin: 18px 0 8px; }"
+ )
+
+
+def _chip_tones() -> dict[str, tuple[str, str]]:
+ return {
+ "neutral": (TECH_BLUE["raised"], TECH_BLUE["text"]),
+ "info": (TECH_BLUE["selection"], TECH_BLUE["accent_text"]),
+ "ok": (TECH_BLUE["selection"], TECH_BLUE["accent_text"]),
+ "warn": (TECH_BLUE["amber_dim"], TECH_BLUE["amber_text"]),
+ "risk": (TECH_BLUE["rose_dim"], TECH_BLUE["rose_text"]),
+ }
+
+
+CHIP_TONES = _chip_tones()
+
+
+def dialog_style() -> str:
+ """The window's stylesheet for the palette that is active right now."""
+
+ return f"""
QDialog {{ background: {PAGE}; }}
-QLabel {{ color: {INK}; }}
-QFrame#AiCard, QFrame#AiHeader, QFrame#AiReview {{
- background: {CARD}; border: 1px solid {LINE}; border-radius: 8px;
+QLabel {{ color: {INK}; background: transparent; }}
+QFrame#AiCard, QFrame#AiMetricCard, QFrame#AiPanel {{
+ background: {CARD}; border: 1px solid {TECH_BLUE['line_soft']}; border-radius: 10px;
}}
-QLabel#AiIdentity {{ font-size: 15px; font-weight: 600; }}
-QLabel#AiScore {{ font-size: 30px; font-weight: 700; color: {INK}; }}
+QFrame#AiHeader, QFrame#AiReview {{ background: transparent; border: 0; }}
+QScrollArea#AiRailScroll {{ background: transparent; border: 0; }}
+QLabel#AiWindowHeading {{ font-size: 24px; font-weight: 600; color: {TECH_BLUE['heading']}; }}
+QLabel#AiIdentity {{ font-size: 12px; color: {MUTED}; }}
+QLabel#AiScore {{ font-size: 22px; font-weight: 600; color: {TECH_BLUE['heading']}; }}
QLabel#AiScoreCaption, QLabel#AiMeta, QLabel#AiNote, QLabel#AiFootnote, QLabel#AiColumn {{
color: {MUTED}; font-size: 12px;
}}
-QLabel#AiFootnote {{ font-size: 11px; }}
-QLabel#AiColumn {{ font-weight: 600; }}
-QLabel#AiModel {{ font-size: 14px; font-weight: 600; }}
-QLabel#AiStage {{ font-size: 13px; font-weight: 600; color: #245983; }}
-QLabel#AiFlow {{ background: #eef4fa; color: #234d73; border-radius: 6px; padding: 7px 10px; }}
-QTabWidget::pane {{ border: 1px solid {LINE}; border-radius: 8px; background: {CARD}; top: -1px; }}
-QTabBar::tab {{ padding: 6px 16px; margin-right: 2px; border: 1px solid transparent; border-bottom: 0;
- border-top-left-radius: 6px; border-top-right-radius: 6px; color: {MUTED}; }}
-QTabBar::tab:selected {{ background: {CARD}; border-color: {LINE}; color: {INK}; font-weight: 600; }}
+QLabel#AiFootnote {{ font-size: 11px; color: {TECH_BLUE['faint']}; }}
+QLabel#AiColumn {{ font-size: 14px; font-weight: 600; color: {TECH_BLUE['heading']}; }}
+QLabel#AiModel {{ font-size: 16px; font-weight: 600; }}
+QLabel#AiStage {{ font-size: 13px; font-weight: 600; color: {TECH_BLUE['accent_text']}; }}
+QLabel#AiFlow {{ background: {TECH_BLUE['surface_2']}; color: {INK}; border-radius: 6px; padding: 10px 12px; }}
+QTabWidget::pane {{ border: 0; background: transparent; }}
+QTextBrowser, QTextEdit {{ color: {INK}; }}
QTextBrowser {{ border: 0; background: transparent; }}
-QTextEdit#AiComment {{ border: 1px solid {LINE}; border-radius: 6px; background: {CARD}; padding: 3px 6px; }}
+QTextEdit#AiComment {{ border: 1px solid {LINE}; border-radius: 8px; background: {TECH_BLUE['surface_2']};
+ padding: 8px; font-size: 13px; color: {INK}; }}
+QPushButton {{ background: {TECH_BLUE['surface_2']}; color: {INK}; border: 1px solid {LINE};
+ border-radius: 7px; padding: 7px 15px; font-size: 12px; }}
+QPushButton:hover {{ background: {TECH_BLUE['raised']}; border-color: {TECH_BLUE['accent']}; }}
+QPushButton:focus, QComboBox:focus, QLineEdit:focus, QTextEdit:focus {{ border-color: {TECH_BLUE['accent']}; }}
+QPushButton:disabled {{ color: {TECH_BLUE['faint']}; background: {TECH_BLUE['surface']};
+ border-color: {TECH_BLUE['line_soft']}; }}
+QPushButton#AiPrimaryAction {{ background: {TECH_BLUE['accent']}; border-color: {TECH_BLUE['accent']};
+ color: #FFFFFF; font-weight: 600; }}
+QPushButton#AiPrimaryAction:hover {{ background: {TECH_BLUE['accent_pressed']}; }}
+QPushButton#AiPrimaryAction:disabled {{ background: {TECH_BLUE['surface_2']};
+ border-color: {TECH_BLUE['line_soft']}; color: {TECH_BLUE['faint']}; }}
+QPushButton#AiPager {{ padding: 6px 0; }}
+QComboBox, QLineEdit {{ border: 1px solid {LINE}; border-radius: 8px; padding: 6px 10px;
+ min-height: 20px; color: {INK}; background: {TECH_BLUE['surface_2']}; font-size: 12px; }}
+QComboBox::drop-down {{ border: 0; width: 22px; }}
+QComboBox QAbstractItemView {{ background: {TECH_BLUE['surface_2']}; color: {INK};
+ border: 1px solid {LINE}; selection-background-color: {TECH_BLUE['selection']};
+ selection-color: {TECH_BLUE['accent_text']}; }}
+QSplitter::handle {{ background: {TECH_BLUE['line_soft']}; width: 8px; }}
+QMenu {{ background: {TECH_BLUE['surface_2']}; color: {INK}; border: 1px solid {LINE}; padding: 5px; }}
+QMenu::item {{ padding: 8px 20px; }}
+QMenu::item:selected {{ background: {TECH_BLUE['selection']}; color: {TECH_BLUE['accent_text']}; }}
+QFrame#AiFacts, QFrame#AiTabsCard {{ background: transparent; border: 0; }}
+QFrame#AiFactDivider {{ background: transparent; border: 0; border-left: 1px dashed {LINE}; }}
+QLabel#AiHeroCrumb {{ color: {TECH_BLUE['faint']}; font-size: 10.5px; letter-spacing: 1.7px; }}
+QLabel#AiHeroTitle {{ color: {TECH_BLUE['heading']}; font-size: 18.9px; font-weight: 600; }}
+QLabel#AiHeroSub {{ color: {MUTED}; font-size: 10.5px; }}
+QLabel#AiFactLabel {{ color: {TECH_BLUE['faint']}; font-size: 9.9px; letter-spacing: 1px; }}
+QLabel#AiFactValue {{ color: {TECH_BLUE['heading']}; font-size: 13.8px; font-weight: 600; }}
+QLabel#AiUserName {{ color: {INK}; font-size: 12px; }}
+QScrollBar:vertical {{ background: transparent; width: 8px; margin: 0; }}
+QScrollBar::handle:vertical {{ background: {TECH_BLUE['raised']}; border-radius: 4px; min-height: 30px; }}
+QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
+QScrollBar:horizontal {{ background: transparent; height: 8px; margin: 0; }}
+QScrollBar::handle:horizontal {{ background: {TECH_BLUE['raised']}; border-radius: 4px; min-width: 30px; }}
+QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }}
+QHeaderView::section {{ background: {TECH_BLUE['surface_2']}; color: {MUTED}; border: 0;
+ border-bottom: 1px solid {LINE}; padding: 7px 8px; font-size: 12px; }}
+QTableWidget {{ background: transparent; color: {INK}; gridline-color: {LINE};
+ alternate-background-color: {TECH_BLUE['zebra']}; }}
+QTableWidget::item:selected {{ background: {TECH_BLUE['selection']}; color: {TECH_BLUE['heading']}; }}
+QTableCornerButton::section {{ background: {TECH_BLUE['surface_2']}; border: 0; }}
+QFrame#AiTopBar {{ background: {TECH_BLUE['canvas_soft']}; border: 0;
+ border-bottom: 1px solid {TECH_BLUE['line']}; }}
"""
-def _chip(text: str, tone: str = "neutral") -> QLabel:
+NAV_ICONS = {"overview": "bars", "report": "doc", "per_herb": "box",
+ "gaps": "image", "pipeline": "clock", "history": "trend"}
+
+
+def _glyph_button(kind: str, text: str, parent: QWidget) -> QPushButton:
+ """An outlined action with its icon, as the design shows them."""
+
+ button = QPushButton(text, parent)
+ button.setObjectName("AiGlyphButton")
+ button.setCursor(Qt.CursorShape.PointingHandCursor)
+ button.setIcon(glyph_icon(kind, TECH_BLUE["muted"], 16))
+ button.setIconSize(QSize(16, 16))
+ return button
+
+
+def _user_chip(current_user: Any, parent: QWidget) -> QWidget:
+ """Who is signed in. Without a session the chip states that rather than inventing a name."""
+
+ holder = QWidget(parent)
+ holder.setObjectName("AiUserChip")
+ row = QHBoxLayout(holder)
+ row.setContentsMargins(6, 2, 10, 2)
+ row.setSpacing(8)
+ name = str(first_value(current_user, "name", "real_name", "nickname", default="")).strip()
+ avatar = LogoTile("check", start=TECH_BLUE["accent"], end=TECH_BLUE["accent_pressed"], size=28,
+ radius=14, circle=True, parent=holder)
+ avatar.setAccessibleName(name or "当前登录医生")
+ row.addWidget(avatar)
+ caption = QLabel(name or "未登录", holder)
+ caption.setObjectName("AiUserName")
+ caption.setTextFormat(Qt.TextFormat.PlainText)
+ row.addWidget(caption)
+ chevron = Glyph("chevron", TECH_BLUE["muted"], 14, holder)
+ row.addWidget(chevron)
+ return holder
+
+
+REVIEW_LIMIT = 500
+
+
+def _count_review(box: QTextEdit, meter: QLabel) -> None:
+ """Keep the saved comment inside the field's stated limit, and show how much is left."""
+
+ text = box.toPlainText()
+ if len(text) > REVIEW_LIMIT:
+ cursor = box.textCursor()
+ position = cursor.position()
+ box.blockSignals(True)
+ box.setPlainText(text[:REVIEW_LIMIT])
+ cursor.setPosition(min(position, REVIEW_LIMIT))
+ box.setTextCursor(cursor)
+ box.blockSignals(False)
+ text = box.toPlainText()
+ meter.setText(f"{len(text)}/{REVIEW_LIMIT}")
+
+
+REPORT_SECTION_ORDER = ("summary", "diagnosis", "tcm_analysis", "analysis", "timeline",
+ "risk_assessment", "missing_information", "treatment_advice", "follow_up",
+ "evidence_references", "sources")
+
+
+def report_sections(report: Any) -> list[tuple[str, str, str]]:
+ """The saved report split into its own fields, each with an anchor the rail can jump to.
+
+ Order follows the reading order of the design; any field the server adds later still shows,
+ after the known ones, rather than being silently dropped.
+ """
+
+ data = mapping(report)
+ if not data:
+ return []
+ keys = [key for key in REPORT_SECTION_ORDER if key in data]
+ keys += [key for key in data if key not in REPORT_SECTION_ORDER]
+ sections = []
+ for key in keys:
+ value = data.get(key)
+ if value in (None, "", [], {}):
+ continue
+ title = field_text(key, FIELD_LABELS, STATE_LABELS)
+ body = f'
{escape(title)}
' + _html(value, key)
+ sections.append((key, title, body))
+ return sections
+
+
+def _chip(text: str, tone: str = "neutral", *, parent: QWidget) -> QLabel:
"""A small status pill. Text is set as plain text; tone never encodes clinical judgement."""
background, colour = CHIP_TONES.get(tone, CHIP_TONES["neutral"])
- chip = QLabel(text)
+ # Assign ownership before visibility; an unparented visible label becomes a window.
+ chip = QLabel(text, parent)
chip.setTextFormat(Qt.TextFormat.PlainText)
- chip.setStyleSheet(f"background: {background}; color: {colour}; border-radius: 9px; padding: 2px 9px; font-size: 12px;")
+ chip.setStyleSheet(f"background: {background}; color: {colour}; border-radius: 9px;"
+ " padding: 2px 9px; font-size: 10.2px;")
chip.setVisible(bool(text))
return chip
@@ -99,61 +292,6 @@ def _status_tone(status: Any) -> str:
"canceled": "neutral", "running": "info", "processing": "info", "queued": "info",
"retrying": "info", "retry_wait": "warn", "waiting_sources": "warn", "preparing": "info",
"partial": "warn", "blocked": "warn"}.get(str(status or ""), "neutral")
-STATE_LABELS = {
- "blank": "尚未开方", "not_generated": "尚无分析记录", "unavailable": "暂无可比结果",
- "not_applicable": "尚未开方", "not_started": "尚未分析", "pending": "待分析",
- "preparing": "准备资料", "waiting_sources": "等待转写/资料", "retry_wait": "等待重试", "blocked": "需完善资料关联",
- "queued": "待分析", "waiting": "等待资料", "waiting_transcript": "等待转写",
- "waiting_transcription": "等待转写", "running": "分析中", "processing": "分析中",
- "retrying": "重试中", "succeeded": "已完成", "completed": "已完成", "success": "已完成",
- "partial": "部分完成", "failed": "需重试", "cancelled": "已取消", "canceled": "已取消",
- "stale": "处方已变更", "superseded": "处方已变更", "invalid": "已失效",
- "prescription_changed": "处方已变更", "source_updated": "资料已更新", "voided": "处方已作废", "deleted": "处方已删除", "revoked": "权限已撤销",
- "current": "当前版本", "valid": "当前有效", "complete": "资料清单完整",
- "incomplete": "资料不全", "missing": "资料缺失", "unknown": "未确认",
- "needs_patient_link": "需完善患者关联", "patient_unlinked": "需完善患者关联",
- "independent_baseline": "独立基线", "baseline": "独立基线",
- "latest_context": "最新资料对照", "supplemental": "最新资料对照",
- "assisted_revision": "AI 辅助后修订", "ai_assisted": "AI 辅助后修订",
- "non_independent": "非独立对照", "auxiliary": "辅助复核",
- "comparable": "可比", "not_comparable": "不可比",
- "available_for_review": "供医生复核", "insufficient_data": "资料不足,暂不提供候选用药",
- "withheld_for_risk": "因风险暂缓候选用药", "viewed": "已查看", "needs_information": "需补充资料",
- "not_adopted": "不采纳", "reviewed": "已复核",
- "per_dose": "每剂", "per_day": "每日", "matched": "共同药味", "doctor_only": "仅医生方", "candidate_only": "仅模型方",
- "insufficient_sample": "样本不足", "descriptive_only": "仅作描述性统计",
-}
-FIELD_LABELS = {
- "summary": "概要", "timeline": "病程", "analysis": "综合分析", "tcm_analysis": "中医辨证",
- "diagnosis": "辨证分析", "treatment_advice": "治疗与随访建议", "risk_assessment": "需复核风险",
- "evidence_references": "证据来源编号", "missing_information": "待补充资料", "level": "风险等级", "label": "说明",
- "risk_warnings": "需复核风险", "risks": "风险", "follow_up": "随访建议", "evidence": "依据",
- "sources": "来源", "manifest": "来源清单", "missing": "资料缺口", "status": "状态",
- "reason": "原因", "name": "药名", "herb_name": "规范药名", "canonical_name": "规范药名",
- "processing": "炮制", "dosage": "剂量", "dose": "剂量", "unit": "单位", "dose_basis": "剂量基准",
- "formula_type": "主辅方", "doctor_dosage": "医生剂量", "candidate_dosage": "模型剂量",
- "doctor_dose": "医生剂量", "candidate_dose": "模型剂量", "ai_dose": "模型剂量",
- "contribution": "匹配贡献", "ratio": "匹配贡献", "match_ratio": "匹配贡献", "match": "匹配情况",
- "prescription_name": "候选方名称", "prescription_type": "剂型", "herbs": "药味",
- "dose_count": "剂数", "usage_days": "疗程(天)", "times_per_day": "每日服次",
- "usage_instruction": "服法", "usage_time": "服药时间", "usage_way": "给药途径",
- "rationale": "方义与依据", "usage_differences": "用法、疗程与风险差异", "normalization": "规范化记录",
- "algorithm_version": "算法版本", "dictionary_version": "药材字典版本", "model_version": "模型版本",
- "prompt_version": "提示词版本", "doctor_count": "医生药项数", "candidate_count": "候选药项数",
- "matched_count": "共同药项数", "coverage": "模型资料覆盖", "source_summary": "来源汇总",
- "cutoff_at": "资料截止时间", "generated_at": "报告生成时间", "comment": "复核意见",
- "match_type": "增减药项", "administration_route": "给药途径", "group": "用药组",
- "delivered": "已送达", "unreadable": "不可读", "unsupported": "不支持", "parsed": "已解析",
- "diagnosis_count": "病历数", "prescription_count": "历史处方数", "chat_count": "聊天记录数",
- "daily_record_count": "日常记录数", "transcript_count": "转写数", "attachment_count": "附件数",
- "files": "附件处理清单", "source_ids": "已读取来源编号", "source_id": "来源编号", "file_id": "附件编号",
- "complete": "资料清单完整", "source_complete": "文字来源齐全", "transmitted": "附件已送达", "critical": "关键资料缺口",
- "baseline_eligible": "独立基线统计资格", "baseline_exclusion_reasons": "基线排除原因", "instructions": "特殊煎服说明",
- "versions": "版本信息", "strata": "按版本分层", "count": "样本数", "mean": "均值", "median": "中位数",
- "distribution": "一致度分布", "sample_status": "样本说明",
-}
-STATE_LABELS.update(SYSTEM_LABELS)
-FIELD_LABELS.update(EXTRA_FIELD_LABELS)
DISCLAIMER = "药味与剂量一致度衡量结构接近程度;不代表医疗准确率、安全性或疗效。候选建议仅供医生复核。"
@@ -325,10 +463,62 @@ def _comparison_rows(rows: Any) -> Any:
return compact
+def _candidate_html(candidate: Any) -> str:
+ """Keep the full candidate, with its medicine list ahead of supporting detail."""
+ data = mapping(candidate)
+ herbs = data.get("herbs")
+ if not isinstance(herbs, list) or not herbs or not all(isinstance(herb, Mapping) for herb in herbs):
+ return _html(candidate)
+ columns = ("name", "dosage", "unit", "dose_basis", "formula_type")
+ rows = []
+ notes = []
+ for herb in herbs:
+ row = {key: herb.get(key) for key in columns}
+ for key in ("unit", "dose_basis", "formula_type"):
+ if key not in herb and key in data:
+ row[key] = data[key]
+ rows.append(row)
+ extra = {key: value for key, value in herb.items() if key not in columns}
+ if extra:
+ notes.append(f"{escape(plain_text(herb.get('name')))}
" + _html(extra))
+ title = escape(plain_text(data.get("prescription_name") or "候选药方"))
+ supporting = {key: value for key, value in data.items() if key not in {"herbs", "prescription_name"}}
+ herb_table = _html(rows).replace('{title}" + herb_table + "方义与用法
" + _html(supporting)
+ + ("逐味说明与来源
" + "".join(notes) if notes else ""))
+
+
+BASIS_LABELS = {"per_dose": "每剂", "per_day": "每日", "per_bag": "每包"}
+
+
+def _comparison_basis(batch: Any) -> str:
+ """One unit and one basis across every compared row, or nothing: never a guessed scale."""
+
+ units: set[str] = set()
+ bases: set[str] = set()
+ for key in MODELS:
+ comparison = mapping(mapping(mapping(batch).get("models")).get(key)).get("comparison")
+ rows = mapping(comparison).get("rows")
+ if not isinstance(rows, list):
+ continue
+ for row in rows:
+ value = mapping(row)
+ if value.get("candidate") is None and value.get("doctor") is None:
+ continue
+ units.add(str(value.get("unit") or "").strip())
+ bases.add(str(value.get("dose_basis") or "").strip())
+ if len(units) != 1 or len(bases) != 1:
+ return ""
+ unit, basis = units.pop(), bases.pop()
+ label = BASIS_LABELS.get(basis)
+ return f"同单位 · {label}" if unit and label else ""
+
+
class IssuedPrescriptionAiDialog(QDialog):
"""Shared prescription/patient entry point; all initial and polling calls are GETs."""
- def __init__(self, repository: Any, permissions: Any, parent: QWidget | None = None, *, prescription_id: int = 0, diagnosis_id: int = 0) -> None:
+ def __init__(self, repository: Any, permissions: Any, parent: QWidget | None = None, *,
+ prescription_id: int = 0, diagnosis_id: int = 0, current_user: Any = None) -> None:
super().__init__(parent)
self.repository, self.permissions = repository, permissions
self.prescription_id, self.diagnosis_id = prescription_id, diagnosis_id
@@ -345,59 +535,153 @@ class IssuedPrescriptionAiDialog(QDialog):
self._progress_frozen_seconds = 0
self._progress_stale = False
self._read_access_denied = False
- self.setWindowTitle("处方 AI 综合分析与用药对照")
- self.resize(1200, 860)
- self.setStyleSheet(DIALOG_STYLE)
+ self.setWindowTitle("诊断与药方 · AI 分析报告")
+ self.resize(1440, 940)
+ self.setMinimumSize(940, 640)
+ font = QFont(body_family())
+ font.setPixelSize(13)
+ self.setFont(font)
+ self.setStyleSheet(dialog_style() + rail_qss())
+ self._permissions_snapshot = permissions
+ self._current_user = current_user
+ self._pending_drafts: dict[str, tuple[str, int]] = {}
+ self._build_ui(permissions, current_user)
+
+ def _build_ui(self, permissions: Any, current_user: Any) -> None:
+ """Construct the whole window body. Called again when the theme changes."""
+
root = QVBoxLayout(self)
- root.setContentsMargins(16, 14, 16, 12)
+ root.setContentsMargins(16, 12, 16, 10)
root.setSpacing(10)
- # 1) Header: which prescription this is, its state chips, and the batch actions.
- header = QFrame()
- header.setObjectName("AiHeader")
- header_layout = QVBoxLayout(header)
- header_layout.setContentsMargins(14, 10, 14, 10)
- header_layout.setSpacing(6)
- identity_row = QHBoxLayout()
- identity_row.setSpacing(8)
- self.identity = QLabel("尚无报告")
- self.identity.setObjectName("AiIdentity")
- self.identity.setTextFormat(Qt.TextFormat.PlainText)
- identity_row.addWidget(self.identity)
+ # The console's own chrome: a dark top bar with the batch's identity and actions, then
+ # the agreement comparison, then the numbered steps beside the page they open.
+ top = self.top_bar = QFrame(self)
+ top.setObjectName("AiTopBar")
+ # The band wraps rather than truncates: the fact strip drops to its own line when the
+ # window is too narrow to hold it beside the title, as the design's own grid does.
+ self.top_column = QVBoxLayout(top)
+ self.top_column.setContentsMargins(24, 10, 24, 10)
+ self.top_column.setSpacing(8)
+ top_row = QHBoxLayout()
+ top_row.setContentsMargins(0, 0, 0, 0)
+ top_row.setSpacing(14)
+ self.top_column.addLayout(top_row)
+ identity_block = QVBoxLayout()
+ identity_block.setSpacing(2)
+ self.breadcrumb = QLabel("甄养医生工作站 / AI 分析报告", top)
+ self.breadcrumb.setObjectName("AiHeroCrumb")
+ identity_block.addWidget(self.breadcrumb)
+ title_row = QHBoxLayout()
+ title_row.setSpacing(10)
+ heading = QLabel("诊断与药方对照", top)
+ heading.setObjectName("AiHeroTitle")
+ title_row.addWidget(heading)
self.chip_row = QHBoxLayout()
self.chip_row.setSpacing(6)
- identity_row.addLayout(self.chip_row)
- identity_row.addStretch(1)
- identity_row.addWidget(QLabel("历史批次"))
- self.history = QComboBox()
- self.history.setMinimumContentsLength(24)
+ title_row.addLayout(self.chip_row)
+ title_row.addStretch(1)
+ identity_block.addLayout(title_row)
+ self.identity = QLabel("尚无报告", top)
+ self.identity.setObjectName("AiHeroSub")
+ self.identity.setTextFormat(Qt.TextFormat.PlainText)
+ identity_block.addWidget(self.identity)
+ top_row.addLayout(identity_block, 1)
+
+ self.fact_values: dict[str, QLabel] = {}
+ self.fact_captions: list[QLabel] = []
+ self.fact_strip = QWidget(top)
+ strip_row = QHBoxLayout(self.fact_strip)
+ strip_row.setContentsMargins(0, 0, 0, 0)
+ strip_row.setSpacing(22)
+ # `label` is the module level translator; never shadow it inside this constructor.
+ for key, caption_text in (("prescription", "处方 / 诊单"), ("basis", "剂量口径"),
+ ("cutoff", "资料截止"), ("batch", "批次")):
+ block = QVBoxLayout()
+ block.setSpacing(1)
+ caption = QLabel(caption_text, self.fact_strip)
+ caption.setObjectName("AiFactLabel")
+ value = QLabel("—", self.fact_strip)
+ value.setObjectName("AiFactValue")
+ value.setTextFormat(Qt.TextFormat.PlainText)
+ block.addWidget(caption)
+ block.addWidget(value)
+ strip_row.addLayout(block)
+ self.fact_values[key] = value
+ self.fact_captions.append(caption)
+ strip_row.addStretch(1)
+ self.top_row = top_row
+ self._fact_strip_wrapped = False
+ top_row.addWidget(self.fact_strip)
+
+ self.history = QComboBox(top)
+ self.history.setObjectName("AiVersionSelect")
+ self.history.setAccessibleName("历史分析批次")
+ self.history.setMinimumWidth(180)
+ self.history.setMaximumWidth(240)
+ self.history.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
+ self.history.setMinimumContentsLength(12)
self.history.currentIndexChanged.connect(self._history_selected)
- identity_row.addWidget(self.history)
- self.previous = QPushButton("上一页")
- self.next = QPushButton("下一页")
+ top_row.addWidget(self.history)
+ self.previous = QPushButton("‹", top)
+ self.next = QPushButton("›", top)
+ for button, description in ((self.previous, "上一页批次"), (self.next, "下一页批次")):
+ button.setObjectName("AiPager")
+ button.setFixedWidth(28)
+ button.setToolTip(description)
+ button.setAccessibleName(description)
self.previous.clicked.connect(lambda: self._history_page(-1))
self.next.clicked.connect(lambda: self._history_page(1))
- self.refresh_button = QPushButton("刷新")
+ top_row.addWidget(self.previous)
+ top_row.addWidget(self.next)
+ self.theme_button = QPushButton("◐", top)
+ self.theme_button.setObjectName("AiPager")
+ self.theme_button.setFixedWidth(34)
+ self.theme_button.setToolTip("切换深色 / 浅色")
+ self.theme_button.setAccessibleName("切换深色或浅色主题")
+ self.theme_button.clicked.connect(self._switch_theme)
+ top_row.addWidget(self.theme_button)
+ self.refresh_button = _glyph_button("refresh", "刷新", top)
self.refresh_button.clicked.connect(self.refresh)
- self.regenerate_button = QPushButton("重新分析")
+ top_row.addWidget(self.refresh_button)
+ self.regenerate_button = _glyph_button("bars", "重新分析", top)
self.regenerate_button.clicked.connect(self._regenerate)
self.regenerate_button.setVisible(has_permission(permissions, "tcm.prescriptionAi/regenerate", default=False))
self.regenerate_button.setEnabled(False)
- for button in (self.previous, self.next, self.refresh_button, self.regenerate_button):
- identity_row.addWidget(button)
- header_layout.addLayout(identity_row)
+ top_row.addWidget(self.regenerate_button)
+ self.live_status = QLabel("正在读取报告", top)
+ self.live_status.setObjectName("AiHeroCrumb")
+ top_row.addWidget(self.live_status)
+ self.user_chip = _user_chip(current_user, top)
+ top_row.addWidget(self.user_chip)
+ root.addWidget(top)
+
+ body = QWidget(self)
+ body_layout = QVBoxLayout(body)
+ body_layout.setContentsMargins(18, 14, 18, 10)
+ body_layout.setSpacing(10)
+ self.agreement = AgreementBar(body)
+ body_layout.addWidget(self.agreement)
+ self.agreement_note = QLabel(
+ "一致率怎么读:候选方与医方原方在「药味是否收录 × 剂量接近程度」上的加权吻合度,0–100%。"
+ "两侧同算法、同提示词,数值可直接横向比较。", body)
+ self.agreement_note.setObjectName("AiFootnote")
+ self.agreement_note.setWordWrap(True)
+ body_layout.addWidget(self.agreement_note)
+ self.body_row = QHBoxLayout()
+ self.body_row.setSpacing(14)
+ body_layout.addLayout(self.body_row, 1)
+ root.addWidget(body, 1)
+
self.batch_summary = QLabel("尚无报告")
self.batch_summary.setObjectName("AiMeta")
self.batch_summary.setTextFormat(Qt.TextFormat.PlainText)
self.batch_summary.setWordWrap(True)
- header_layout.addWidget(self.batch_summary)
- root.addWidget(header)
self.message = QLabel("正在读取已保存的报告…")
self.message.setObjectName("AiMeta")
self.message.setTextFormat(Qt.TextFormat.PlainText)
self.message.setWordWrap(True)
- root.addWidget(self.message)
# 2) Batch-level progress. Hidden once nothing is running, so a finished report is not
# buried under stage text that no longer changes.
@@ -405,145 +689,224 @@ class IssuedPrescriptionAiDialog(QDialog):
self.progress_flow.setObjectName("AiFlow")
self.progress_flow.setTextFormat(Qt.TextFormat.PlainText)
self.progress_flow.setWordWrap(True)
- root.addWidget(self.progress_flow)
self.batch_progress = QLabel()
self.batch_progress.setObjectName("AiNote")
self.batch_progress.setTextFormat(Qt.TextFormat.PlainText)
self.batch_progress.setWordWrap(True)
self.batch_progress.hide()
- root.addWidget(self.batch_progress)
- # 3) The headline: one card per model with its agreement number and live stage.
- cards = QHBoxLayout()
- cards.setSpacing(10)
+ # Model agreement, herb composition and source coverage as charts above the workspace.
+
self.model_views: dict[str, dict[str, Any]] = {}
- for key, name in MODELS.items():
- card = QFrame()
- card.setObjectName("AiCard")
- card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
- card_layout = QVBoxLayout(card)
- card_layout.setContentsMargins(14, 10, 14, 12)
- card_layout.setSpacing(4)
- head = QHBoxLayout()
- head.setSpacing(8)
- model_label = QLabel(name)
- model_label.setObjectName("AiModel")
- head.addWidget(model_label)
- status_chip = _chip("尚无报告")
- head.addWidget(status_chip)
- head.addStretch(1)
- card_layout.addLayout(head)
- score_row = QHBoxLayout()
- score_row.setSpacing(8)
- score = QLabel("—")
- score.setObjectName("AiScore")
- score.setTextFormat(Qt.TextFormat.PlainText)
- score_row.addWidget(score)
- caption = QLabel("药味与剂量一致度")
- caption.setObjectName("AiScoreCaption")
- caption.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom)
- score_row.addWidget(caption)
- score_row.addStretch(1)
- card_layout.addLayout(score_row)
- title = QLabel("尚无报告")
+ for key in MODELS:
+ views = dict(self.agreement.views(key))
+ # Stage detail keeps its own widgets: the progress tab re-parents them, and a card
+ # must not lose its charts when that happens.
+ title = QLabel("尚无报告", self)
title.setObjectName("AiNote")
title.setTextFormat(Qt.TextFormat.PlainText)
title.setWordWrap(True)
- card_layout.addWidget(title)
- progress_title = QLabel("等待处理进度")
+ progress_title = QLabel("等待处理进度", self)
progress_title.setObjectName("AiStage")
progress_title.setTextFormat(Qt.TextFormat.PlainText)
progress_title.setWordWrap(True)
- card_layout.addWidget(progress_title)
- progress_bar = QProgressBar()
+ progress_bar = QProgressBar(self)
progress_bar.setFixedHeight(6)
progress_bar.setTextVisible(False)
progress_bar.setRange(0, 1)
progress_bar.setValue(0)
- progress_bar.setStyleSheet("QProgressBar {border: 0; background: #e4ecf3; border-radius: 3px;} QProgressBar::chunk {background: #347db3; border-radius: 3px;}")
- card_layout.addWidget(progress_bar)
- progress_detail = QLabel()
+ progress_detail = QLabel("", self)
progress_detail.setObjectName("AiNote")
progress_detail.setTextFormat(Qt.TextFormat.PlainText)
progress_detail.setWordWrap(True)
- card_layout.addWidget(progress_detail)
- card_layout.addStretch(1)
- cards.addWidget(card, 1)
- self.model_views[key] = {"card": card, "model_label": model_label, "status_chip": status_chip,
- "score": score, "title": title, "progress_title": progress_title,
- "progress_bar": progress_bar, "progress_detail": progress_detail}
- root.addLayout(cards)
+ views.update({"title": title, "progress_title": progress_title,
+ "progress_bar": progress_bar, "progress_detail": progress_detail})
+ self.model_views[key] = views
+ cards = QHBoxLayout()
+ cards.setSpacing(10)
- # 4) One tab bar for both models: the same section is always compared side by side.
- self.tabs = QTabWidget()
- for tab_key, tab_name in (("report", "综合分析"), ("candidate", "候选用药"), ("comparison", "逐味对照"), ("sources", "来源与缺口")):
+ # Navigation stays compact; supporting reports remain available in the menu.
+ self.tabs = QTabWidget(self)
+ self.tabs.setAccessibleName("药方分析内容")
+ self.tabs.tabBar().hide()
+ self._current_page = "overview"
+ self.tab_pages: dict[str, QWidget] = {}
+ self.rail = StepRail(NAV_PRIMARY, self)
+ self.rail.selected.connect(self._goto)
+ self.rail.save_requested.connect(lambda: self._review(self.review_model.currentData()))
+ self.nav_buttons = self.rail.buttons
+ # A short window must scroll the rail, not squeeze it: below its minimum the focus rows
+ # collapse to nothing and the card reads as four empty bars.
+ self.rail_scroll = QScrollArea(self)
+ self.rail_scroll.setObjectName("AiRailScroll")
+ self.rail_scroll.setWidget(self.rail)
+ self.rail_scroll.setWidgetResizable(True)
+ self.rail_scroll.setFrameShape(QFrame.Shape.NoFrame)
+ self.rail_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.rail_scroll.setFixedWidth(self.rail.width())
+ self.body_row.addWidget(self.rail_scroll)
+
+ self.tabs.currentChanged.connect(self._navigation_changed)
+ self.comparison_panel = PrescriptionReviewWorkspace(self.tabs)
+ self.comparison_panel.open_report.connect(self._open_supporting_report)
+ self.tabs.addTab(self.comparison_panel, "对比总览")
+ self.tab_pages["overview"] = self.comparison_panel
+ self.report_pages: dict[str, QWidget] = {}
+ self.report_sections: dict[str, list[tuple[str, str, str]]] = {key: [] for key in MODELS}
+ for tab_key, tab_name in (("candidate", "方义与用法"), ("report", "完整报告"), ("comparison", "逐味原文"), ("sources", "来源原文")):
page = QWidget()
- page_layout = QVBoxLayout(page)
- page_layout.setContentsMargins(10, 8, 10, 10)
+ page_row = QHBoxLayout(page)
+ page_row.setContentsMargins(10, 8, 10, 10)
+ page_row.setSpacing(10)
+ if tab_key == "report":
+ page_row.addWidget(self._report_rail(page))
+ page_body = QWidget(page)
+ page_layout = QVBoxLayout(page_body)
+ page_layout.setContentsMargins(0, 0, 0, 0)
page_layout.setSpacing(6)
+ page_row.addWidget(page_body, 1)
splitter = QSplitter(Qt.Orientation.Horizontal)
for key, name in MODELS.items():
column = QWidget()
column_layout = QVBoxLayout(column)
column_layout.setContentsMargins(0, 0, 0, 0)
column_layout.setSpacing(4)
+ # Each column names its model, its state and when that text was generated, so a
+ # half-finished batch never reads as two comparable reports.
+ head = QHBoxLayout()
+ head.setSpacing(8)
column_title = QLabel(name)
column_title.setObjectName("AiColumn")
- column_layout.addWidget(column_title)
+ head.addWidget(column_title)
+ state = QLabel("")
+ state.setTextFormat(Qt.TextFormat.PlainText)
+ head.addWidget(state)
+ head.addStretch(1)
+ generated = QLabel("")
+ generated.setObjectName("AiNote")
+ generated.setTextFormat(Qt.TextFormat.PlainText)
+ head.addWidget(generated)
+ column_layout.addLayout(head)
+ self.model_views[key][f"{tab_key}_state"] = state
+ self.model_views[key][f"{tab_key}_generated"] = generated
browser = _browser()
+ browser.document().setDefaultFont(self.font())
+ browser.document().setDefaultStyleSheet(_report_css())
column_layout.addWidget(browser, 1)
splitter.addWidget(column)
self.model_views[key][tab_key] = browser
page_layout.addWidget(splitter, 1)
+ if tab_key == "report":
+ self.report_columns = {key: self.model_views[key]["report"].parentWidget() for key in MODELS}
self.tabs.addTab(page, tab_name)
- root.addWidget(self.tabs, 1)
+ self.report_pages[tab_key] = page
+ self.tab_pages[{"comparison": "comparison_raw", "sources": "sources_raw"}.get(tab_key, tab_key)] = page
+ self.candidates_page = CandidatesPage(self.tabs)
+ self.tab_pages["per_herb"] = self._add_scrolled_tab(self.candidates_page, "候选与逐味")
+ self.sources_page = SourcesPage(self.tabs)
+ self.tab_pages["gaps"] = self._add_scrolled_tab(self.sources_page, "资料与缺口")
+ self.history_page = HistoryPage(self.tabs)
+ self.tab_pages["history"] = self._add_scrolled_tab(self.history_page, "历史与趋势")
+ self.body_row.addWidget(self.tabs, 1)
- # 5) Review actions for both models in one compact strip.
- review = QFrame()
+ progress_page = QWidget(self.tabs)
+ progress_layout = QVBoxLayout(progress_page)
+ progress_layout.setContentsMargins(20, 18, 20, 18)
+ progress_layout.setSpacing(14)
+ progress_layout.addLayout(cards)
+ progress_layout.addWidget(self.batch_summary)
+ progress_layout.addWidget(self.progress_flow)
+ progress_layout.addWidget(self.batch_progress)
+ # One progress tab only: the batch flow above, then per-model stages with the call records.
+ self.pipeline_page = ProgressPage(progress_page)
+ for key in MODELS:
+ self.pipeline_page.attach_live(
+ key, [self.model_views[key][field]
+ for field in ("title", "progress_title", "progress_bar", "progress_detail")])
+ progress_layout.addWidget(self.pipeline_page)
+ # Like the other pages: state the height the stages and the call log need, and scroll.
+ self.tab_pages["pipeline"] = self._add_scrolled_tab(progress_page, "处理进度")
+ self.original_view = _browser()
+ self.original_view.setAccessibleName("当前批次原方完整记录")
+ self.original_view.document().setDefaultFont(self.font())
+ self.tabs.addTab(self.original_view, "原方记录")
+ self.tab_pages["original"] = self.original_view
+
+ # Keep each model's draft independent while editing in the annotation rail.
+ review = QFrame(self.tabs)
review.setObjectName("AiReview")
- review_layout = QHBoxLayout(review)
- review_layout.setContentsMargins(14, 8, 14, 8)
- review_layout.setSpacing(10)
- for index, (key, name) in enumerate(MODELS.items()):
- if index:
- separator = QFrame()
- separator.setFrameShape(QFrame.Shape.VLine)
- separator.setStyleSheet(f"color: {LINE};")
- review_layout.addWidget(separator)
- row = QHBoxLayout()
+ review_layout = QVBoxLayout(review)
+ review_layout.setContentsMargins(0, 0, 0, 0)
+ review_layout.setSpacing(6)
+ self.review_model = QComboBox(review)
+ self.review_model.setObjectName("AiReviewModel")
+ self.review_model.setAccessibleName("选择复核模型")
+ self.review_model.setFixedWidth(116)
+ for key, name in MODELS.items():
+ self.review_model.addItem(f"{name}复核", key)
+ self.review_stack = QStackedWidget(review)
+ review_layout.addWidget(self.review_stack)
+ for key, name in MODELS.items():
+ page = QWidget(self.review_stack)
+ row = QVBoxLayout(page)
+ row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(6)
- caption = QLabel(f"{name}复核")
- caption.setObjectName("AiColumn")
- row.addWidget(caption)
- review_state = QComboBox()
+ review_state = QComboBox(page)
+ review_state.setObjectName("AiReviewState")
+ review_state.setAccessibleName(f"{name}复核状态")
for state in ("viewed", "needs_information", "not_adopted", "reviewed"):
review_state.addItem(label(state), state)
- row.addWidget(review_state)
- comment = QTextEdit()
+ comment = QTextEdit(page)
comment.setObjectName("AiComment")
- comment.setPlaceholderText("复核意见(独立保存,不改写 AI 报告)")
- comment.setFixedHeight(32)
- comment.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
- row.addWidget(comment, 1)
- save = QPushButton("保存")
+ comment.setAccessibleName(f"{name}复核意见")
+ comment.setPlaceholderText("请填写您的复核意见…")
+ comment.setFixedHeight(86)
+ row.addWidget(comment)
+ counter = QLabel("0/500", page)
+ counter.setObjectName("AiMeta")
+ counter.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+ row.addWidget(counter)
+ comment.textChanged.connect(
+ lambda box=comment, meter=counter: _count_review(box, meter))
+ review_actions = QHBoxLayout()
+ review_actions.setSpacing(8)
+ review_hint = QLabel("复核状态", page)
+ review_hint.setObjectName("AiMeta")
+ review_actions.addWidget(review_hint)
+ review_actions.addWidget(review_state)
+ review_actions.addStretch(1)
+ save = QPushButton("保存复核结果", review)
+ save.setIcon(glyph_icon("save", "#FFFFFF", 15))
+ save.setIconSize(QSize(15, 15))
+ save.setObjectName("AiPrimaryAction")
save.setVisible(has_permission(permissions, "tcm.prescriptionAi/review", default=False))
save.clicked.connect(lambda _checked=False, model=key: self._review(model))
- row.addWidget(save)
- retry = QPushButton("重试")
- retry.setToolTip(f"仅重试 {name},使用原资料快照")
+ retry = QPushButton(f"重试 {name}", self)
+ retry.setObjectName("AiRetryAction")
+ retry.setToolTip(f"仅重试 {name},使用原资料快照,不重新读取资料")
retry.setVisible(has_permission(permissions, "tcm.prescriptionAi/retry", default=False))
retry.clicked.connect(lambda _checked=False, model=key: self._retry(model))
- row.addWidget(retry)
- review_layout.addLayout(row, 1)
+ # The failure and its remedy belong together, on the model card.
+ self.agreement.attach_action(key, retry)
+ review_actions.addWidget(save)
+ row.addLayout(review_actions)
+ row.addStretch(1)
+ self.review_stack.addWidget(page)
self.model_views[key].update({"review_state": review_state, "comment": comment, "save": save, "retry": retry})
- root.addWidget(review)
+ self.review_model.currentIndexChanged.connect(self._review_model_changed)
+ self.comparison_panel.set_review_selector(self.review_model)
+ self.comparison_panel.set_review_widget(review)
# 6) Footnote: the metric's meaning stays available without competing with the numbers.
footer = QHBoxLayout()
+ footer_text = QVBoxLayout()
+ footer_text.setSpacing(4)
+ footer_text.addWidget(self.message)
note = QLabel(DISCLAIMER)
note.setObjectName("AiFootnote")
note.setWordWrap(True)
- footer.addWidget(note, 1)
+ footer_text.addWidget(note)
+ footer.addLayout(footer_text, 1)
close = QPushButton("关闭")
close.clicked.connect(self.reject)
footer.addWidget(close, 0, Qt.AlignmentFlag.AlignBottom)
@@ -556,9 +919,245 @@ class IssuedPrescriptionAiDialog(QDialog):
self._progress_timer.setInterval(1000)
self._progress_timer.timeout.connect(self._render_progress)
self._sync_actions()
+ self._navigation_changed(self.tabs.currentIndex())
+
+ REPORT_MODES = (("both", "并排对照"), ("qwen", "仅千问"), ("openai", "仅 OpenAI"),
+ ("differences", "只看不同段落"))
+
+ def _switch_theme(self) -> None:
+ """Swap the palette and rebuild the body; unsaved review drafts survive the switch."""
+
+ drafts = {key: (views["comment"].toPlainText(), views["review_state"].currentIndex())
+ for key, views in self.model_views.items()}
+ page, batch_id = self._current_page, self.history.currentData()
+ toggle_theme()
+ apply_window_chrome(self)
+ self.setStyleSheet(dialog_style() + rail_qss())
+ layout = self.layout()
+ while layout.count():
+ item = layout.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.setParent(None)
+ widget.deleteLater()
+ child = item.layout()
+ if child is not None:
+ child.deleteLater()
+ QWidget().setLayout(layout)
+ self._pending_drafts = drafts
+ self._preferred_batch_id = batch_id if isinstance(batch_id, int) else None
+ self._build_ui(self._permissions_snapshot, self._current_user)
+ self._goto(page)
+ self.refresh()
+
+ def _restore_drafts(self) -> None:
+ """Put back what the doctor had typed before the theme switch, once only."""
+
+ drafts, self._pending_drafts = self._pending_drafts, {}
+ for key, (text, state) in drafts.items():
+ views = self.model_views.get(key)
+ if views is None:
+ continue
+ views["comment"].setPlainText(text)
+ views["review_state"].setCurrentIndex(max(0, state))
+
+ def _report_rail(self, parent: QWidget) -> QWidget:
+ """Sections on the left, as the design puts them: jump to one, or narrow what is shown."""
+
+ rail = QFrame(parent)
+ rail.setObjectName("AiPanel")
+ rail.setFixedWidth(158)
+ layout = QVBoxLayout(rail)
+ layout.setContentsMargins(10, 10, 10, 10)
+ layout.setSpacing(4)
+ caption = QLabel("报告章节", rail)
+ caption.setObjectName("AiFactLabel")
+ layout.addWidget(caption)
+ self.report_toc = QVBoxLayout()
+ self.report_toc.setSpacing(2)
+ layout.addLayout(self.report_toc)
+ self.report_empty = QLabel("尚无已完成的报告", rail)
+ self.report_empty.setObjectName("AiMeta")
+ self.report_empty.setWordWrap(True)
+ layout.addWidget(self.report_empty)
+ layout.addSpacing(10)
+ display = QLabel("显示", rail)
+ display.setObjectName("AiFactLabel")
+ layout.addWidget(display)
+ self.report_mode = "both"
+ self.report_mode_buttons: dict[str, QPushButton] = {}
+ for key, text in self.REPORT_MODES:
+ button = QPushButton(text, rail)
+ button.setObjectName("AiStep")
+ button.setCheckable(True)
+ button.setChecked(key == "both")
+ button.setFixedHeight(28)
+ button.setCursor(Qt.CursorShape.PointingHandCursor)
+ button.setStyleSheet(
+ f"QPushButton#AiStep {{ text-align: left; padding: 0 8px; color: {MUTED}; font-size: 12.5px; }}"
+ f"QPushButton#AiStep:checked {{ color: #FFFFFF; font-weight: 600; }}")
+ button.clicked.connect(lambda _checked=False, target=key: self._set_report_mode(target))
+ self.report_mode_buttons[key] = button
+ layout.addWidget(button)
+ layout.addStretch(1)
+ return rail
+
+ def _set_report_mode(self, mode: str) -> None:
+ self.report_mode = mode
+ for key, button in self.report_mode_buttons.items():
+ button.setChecked(key == mode)
+ for key in MODELS:
+ self.report_columns[key].setVisible(mode in {"both", "differences", key})
+ self._render_report_bodies()
+
+ def _render_report_bodies(self) -> None:
+ """Rebuild both report columns for the current mode; sections keep their own anchors."""
+
+ shared = {key: dict((section[0], section[2]) for section in sections)
+ for key, sections in self.report_sections.items()}
+ for key in MODELS:
+ sections = self.report_sections[key]
+ if self.report_mode == "differences":
+ other = shared["openai" if key == "qwen" else "qwen"]
+ sections = [section for section in sections if other.get(section[0]) != section[2]]
+ body = "".join(section[2] for section in sections)
+ _set_html(self.model_views[key]["report"], body or "本模型本批次没有可展示的报告段落。
")
+
+ def _render_report_toc(self) -> None:
+ while self.report_toc.count():
+ item = self.report_toc.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.setParent(None)
+ widget.deleteLater()
+ seen: dict[str, str] = {}
+ for sections in self.report_sections.values():
+ for key, title, _body in sections:
+ seen.setdefault(key, title)
+ for key, title in seen.items():
+ button = QPushButton(title, self.report_empty.parentWidget())
+ button.setObjectName("AiStep")
+ button.setFixedHeight(26)
+ button.setCursor(Qt.CursorShape.PointingHandCursor)
+ button.setStyleSheet(
+ f"QPushButton#AiStep {{ text-align: left; padding: 0 8px; color: {INK}; font-size: 12.5px; }}"
+ f"QPushButton#AiStep:hover {{ color: {TECH_BLUE['accent_text']}; }}")
+ button.clicked.connect(lambda _checked=False, anchor=key: self._scroll_reports(anchor))
+ self.report_toc.addWidget(button)
+ self.report_empty.setVisible(not seen)
+
+ def _scroll_reports(self, anchor: str) -> None:
+ for key in MODELS:
+ self.model_views[key]["report"].scrollToAnchor(f"s-{anchor}")
+
+ def _add_scrolled_tab(self, page: QWidget, title: str) -> QWidget:
+ """Pages state the height their cards need; a short window scrolls instead of squeezing."""
+
+ host = QScrollArea(self.tabs)
+ host.setWidgetResizable(True)
+ host.setFrameShape(QFrame.Shape.NoFrame)
+ host.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ host.setWidget(page)
+ self.tabs.addTab(host, title)
+ return host
+
+ def _goto(self, key: str) -> None:
+ page = self.tab_pages.get(key)
+ if page is not None:
+ self.tabs.setCurrentWidget(page)
+
+ def _navigation_changed(self, index: int) -> None:
+ widget = self.tabs.widget(index)
+ current = next((key for key, page in self.tab_pages.items() if page is widget), "")
+ self.rail.set_current(current)
+ self._current_page = current
+ self._apply_density()
+
+ def _apply_density(self) -> None:
+ """A short window keeps the numbers and drops the fact strip and the derived counts."""
+
+ compact = self.height() < 780
+ # Wrapping first, so the visibility the compact pass sets is the one that survives.
+ self._wrap_fact_strip(not self._fact_strip_fits())
+ self._set_hero_compact(compact)
+ # The design's own breakpoint: below 1240 the band's extras give way before the title.
+ self._set_hero_narrow(self.width() < 1240)
+ # The comment box is the tallest thing in the review card; a short window shrinks it
+ # rather than letting the card push its own controls past its edge.
+ for views in self.model_views.values():
+ comment = views.get("comment")
+ if comment is not None:
+ comment.setFixedHeight(54 if compact else 86)
+ checklist = self.comparison_panel.checklist
+ checklist.scroll.setMinimumHeight(40 if compact else 80)
+ if hasattr(self, "_progress_timer"):
+ self._render_progress()
+
+ def _open_supporting_report(self, field: str) -> None:
+ self._goto({"doctor": "original", "candidate": "candidate", "report": "report", "diagnosis": "report",
+ "evidence": "report", "comparison": "per_herb", "sources": "gaps",
+ "progress": "pipeline"}.get(field, "report"))
+ if field in MODELS:
+ self.model_views[field]["report"].setFocus()
+
+ @property
+ def save_review_button(self) -> QPushButton:
+ """The review card's own save action for the model currently being annotated."""
+
+ return self.model_views[self.review_model.currentData()]["save"]
+
+ def _review_model_changed(self, index: int) -> None:
+ self.review_stack.setCurrentIndex(index)
+ self._sync_actions()
+
+ def _set_hero_compact(self, compact: bool) -> None:
+ """A short window keeps the band's identity line and drops the six-column fact strip."""
+
+ self.fact_strip.setVisible(not compact)
+ for widget in (*self.fact_captions, *self.fact_values.values()):
+ widget.setVisible(not compact)
+ self.identity.hide()
+
+ def _set_hero_narrow(self, narrow: bool) -> None:
+ """A narrow window drops the band's optional controls so the title is never cut."""
+
+ for widget in (self.previous, self.next, self.live_status):
+ widget.setVisible(not narrow)
+ self.breadcrumb.setVisible(not narrow)
+ self.history.setMinimumWidth(132 if narrow else 180)
+
+ def _fact_strip_fits(self) -> bool:
+ """Whether the band still has room for the fact strip beside the title and the actions."""
+
+ margins = self.top_column.contentsMargins()
+ available = self.top_bar.width() - margins.left() - margins.right()
+ inline = self.top_row.sizeHint().width()
+ if self._fact_strip_wrapped:
+ inline += self.fact_strip.sizeHint().width() + self.top_row.spacing()
+ return inline <= available
+
+ def _wrap_fact_strip(self, wrapped: bool) -> None:
+ """Move the fact strip onto its own line, or back beside the title."""
+
+ if wrapped == self._fact_strip_wrapped:
+ return
+ self._fact_strip_wrapped = wrapped
+ self.top_row.removeWidget(self.fact_strip)
+ self.top_column.removeWidget(self.fact_strip)
+ if wrapped:
+ self.top_column.addWidget(self.fact_strip)
+ else:
+ self.top_row.insertWidget(1, self.fact_strip)
+
+ def resizeEvent(self, event: Any) -> None:
+ super().resizeEvent(event)
+ # The prescription workspace owns the first screen; band and charts yield when short.
+ self._apply_density()
def showEvent(self, event: Any) -> None:
super().showEvent(event)
+ apply_window_chrome(self)
+ self._apply_density()
self.refresh()
def hideEvent(self, event: Any) -> None:
@@ -608,6 +1207,7 @@ class IssuedPrescriptionAiDialog(QDialog):
data = mapping(result)
self._feature_enabled = data.get("enabled", True) is not False
rows = data.get("lists") or []
+ self.history_page.set_history([mapping(row) for row in rows])
self.history.blockSignals(True)
self.history.clear()
self._history_scope = {}
@@ -617,7 +1217,13 @@ class IssuedPrescriptionAiDialog(QDialog):
if batch_id <= 0:
continue
self._history_scope[batch_id] = batch
- self.history.addItem(f"处方 {batch.get('prescription_id', '—')} · 版本 {batch.get('prescription_revision', '—')} · {format_record_time(batch.get('created_at'))} · {label(batch.get('comparison_type'))} · {label(batch.get('validity'))}", batch_id)
+ created = format_record_time(batch.get("created_at"))
+ short_time = created[5:16] if len(created) >= 16 and created[4:5] == "-" else created
+ revision = batch.get("prescription_revision", "—")
+ caption = f"版本 {revision} · {short_time}" if self.prescription_id else f"处方 {batch.get('prescription_id', '—')} · V{revision} · {short_time}"
+ self.history.addItem(caption, batch_id)
+ self.history.setItemData(self.history.count() - 1,
+ f"处方 {batch.get('prescription_id', '—')} · 版本 {revision} · {created} · {label(batch.get('comparison_type'))} · {label(batch.get('validity'))}", Qt.ItemDataRole.ToolTipRole)
index = self.history.findData(selected)
if index >= 0:
self.history.setCurrentIndex(index)
@@ -626,6 +1232,9 @@ class IssuedPrescriptionAiDialog(QDialog):
page_size = max(1, int(data.get("page_size") or 20))
self.previous.setEnabled(self._page > 1 and not self._busy)
self.next.setEnabled(self._page * page_size < int(data.get("count") or 0) and not self._busy)
+ multiple_pages = self._page > 1 or int(data.get("count") or 0) > page_size
+ self.previous.setVisible(multiple_pages)
+ self.next.setVisible(multiple_pages)
self.message.setText("已读取保存的历史报告。" if rows else "尚无已保存报告;查看页面不会触发分析。")
if not self._feature_enabled:
self.message.setText("自动分析当前未启用;可查看已保存的历史报告。")
@@ -636,6 +1245,7 @@ class IssuedPrescriptionAiDialog(QDialog):
self.refresh()
def _history_selected(self, *_args: Any) -> None:
+ self.history.setToolTip(self.history.currentData(Qt.ItemDataRole.ToolTipRole) or "")
self._generation += 1
self._detail_pending = False
self._timer.stop()
@@ -678,10 +1288,22 @@ class IssuedPrescriptionAiDialog(QDialog):
self._progress_stale = False
self.batch_summary.setText("尚无报告")
self.identity.setText("尚无报告")
+ self.live_status.setText("尚无报告")
self._set_chips([])
+ self.candidates_page.set_batch({})
+ self.sources_page.set_batch({})
+ self.pipeline_page.set_batch({})
+ for key in MODELS:
+ self.agreement.set_failure(key, "", False)
+ self.agreement.apply({})
+ self.comparison_panel.set_batch({})
+ self.original_view.clear()
for views in self.model_views.values():
views["title"].setText("尚无报告")
views["score"].setText("—")
+ self.report_sections[key] = []
+ views["agreement_bar"].hide()
+ views["coverage_chip"].clear()
self._set_chip(views["status_chip"], "尚无报告", "neutral")
views["comment"].clear()
for field in ("report", "candidate", "comparison", "sources"):
@@ -741,12 +1363,14 @@ class IssuedPrescriptionAiDialog(QDialog):
if (first_load or recovered) and self._feature_enabled:
self.message.setText("已读取最新保存的报告;处理进度每 5 秒同步。" if batch_running(batch) else "已读取最新保存的报告。")
compact_state = state_text(batch).replace("\n", " · ")
+ self.live_status.setText("分析中 · 自动同步" if batch_running(batch) else "已读取报告")
self.identity.setText(f"处方 {batch.get('prescription_id')} · 诊单 {batch.get('diagnosis_id')} · 版本 {batch.get('prescription_revision', '—')}")
self._set_chips([
(label(batch.get("validity")), "info" if current_batch(batch) else "warn"),
(label(batch.get("comparison_type")), "neutral"),
])
self.batch_summary.setText(f"{compact_state} {_reason(batch.get('error_message') or batch.get('error_code'))}\n资料截止:{format_record_time(batch.get('cutoff_at'))} · 批次建立:{format_record_time(batch.get('created_at'))}")
+ self._render_facts(batch)
for key in MODELS:
model = mapping(mapping(batch.get("models")).get(key))
views = self.model_views[key]
@@ -757,11 +1381,22 @@ class IssuedPrescriptionAiDialog(QDialog):
reason = model.get("error_message") or comparison.get("reason") or model.get("reason") or model.get("error_code") or comparison.get("reason_code")
status = model.get("status") or batch.get("status")
views["score"].setText(percentage(score, 1))
+ score_available = percentage(score) != "—"
+ # The painted gauge is the visible chart; this bar only carries the value.
+ views["agreement_bar"].setVisible(False)
+ if score_available:
+ views["agreement_bar"].setValue(round(float(score) * 10))
+ views["agreement_bar"].setToolTip(f"药味与剂量一致度:{percentage(score, 1)};刻度 0–100%。")
+ views["coverage_chip"].setText(coverage_text)
+ views["coverage_chip"].setToolTip(coverage_text)
views["score"].setStyleSheet("" if score is not None else f"color: {MUTED};")
views["score"].setToolTip("药味与剂量一致度:结构接近程度,不代表医疗准确率、安全性或疗效。" if score is not None else "本模型本批次没有可比较的候选处方。")
self._set_chip(views["status_chip"], label(status), _status_tone(status))
+ for tab_key in ("candidate", "report", "comparison", "sources"):
+ self._set_chip(views[f"{tab_key}_state"], label(status), _status_tone(status))
+ views[f"{tab_key}_generated"].setText(format_record_time(model.get("generated_at")) or "")
views["title"].setText(f"{coverage_text} {_reason(reason)}".strip())
- _set_html(views["report"], _html(model.get("report") or "尚无已完成的报告。"))
+ self.report_sections[key] = report_sections(model.get("report"))
candidate = mapping(model.get("candidate"))
if candidate:
candidate_content: Any = candidate
@@ -774,17 +1409,35 @@ class IssuedPrescriptionAiDialog(QDialog):
"missing_information": report.get("missing_information"),
"risk_assessment": report.get("risk_assessment"),
}
- _set_html(views["candidate"], _html(candidate_content))
+ _set_html(views["candidate"], _candidate_html(candidate_content))
comparison_html = f"药味与剂量一致度:{percentage(score, 1)} 纯药味重合度:{percentage(comparison.get('herb_score'), 1)}
"
- comparison_html += "逐味贡献
" + _html(_comparison_rows(comparison.get("rows")) or "没有可展示的逐味对照。")
+ comparison_html += "逐味贡献
" + _html(_comparison_rows(comparison.get("rows")) or "没有可展示的逐味对照。")
comparison_html += _html({field: value for field, value in comparison.items() if field not in {"score", "herb_score", "rows"}})
_set_html(views["comparison"], comparison_html)
_set_html(views["sources"], _html({"source_summary": batch.get("source_summary"), "missing": batch.get("missing"), "coverage": model.get("coverage"), "baseline_eligible": batch.get("baseline_eligible"), "baseline_exclusion_reasons": batch.get("baseline_exclusion_reasons"), "cutoff_at": format_record_time(batch.get("cutoff_at")), "generated_at": format_record_time(model.get("generated_at"))}))
- if first_load:
+ if first_load and not self._pending_drafts:
review = mapping(model.get("review"))
views["comment"].setPlainText(str(review.get("comment") or ""))
index = views["review_state"].findData(review.get("status"))
views["review_state"].setCurrentIndex(max(0, index))
+ self.candidates_page.set_batch(batch)
+ self.sources_page.set_batch(batch)
+ self.pipeline_page.set_batch(batch)
+ self._sync_failures(batch)
+ self.comparison_panel.set_batch(batch)
+ snapshot = mapping(batch.get("doctor_snapshot"))
+ patient = mapping(snapshot.get("patient"))
+ diagnosis = mapping(snapshot.get("diagnosis"))
+ prescription = mapping(snapshot.get("prescription"))
+ original = {"患者": patient.get("name"), "性别": patient.get("gender_label"), "年龄": patient.get("age"),
+ "医生诊断": diagnosis.get("clinical_diagnosis") or diagnosis,
+ "药材": prescription.get("herbs"),
+ "用法记录": {key: value for key, value in prescription.items() if key != "herbs"}}
+ _set_html(self.original_view, "本批次保存的医生原方
" + _html(original if snapshot else "未保存完整原方。"))
+ self._restore_drafts()
+ self._render_report_toc()
+ self._render_report_bodies()
+ self._render_console(batch)
self._sync_actions()
self._render_progress()
if self._progress_live():
@@ -794,21 +1447,113 @@ class IssuedPrescriptionAiDialog(QDialog):
self._timer.stop()
self._progress_timer.stop()
+ def _render_console(self, batch: Any) -> None:
+ """Feed the agreement bar and the rail: both read the batch, never the widgets above."""
+
+ data = mapping(batch)
+ coverage, elapsed = {}, {}
+ for key in MODELS:
+ views = self.model_views[key]
+ coverage[key] = views["coverage_chip"].text()
+ elapsed[key] = views["elapsed"].text()
+ self.agreement.apply(data, coverage=coverage, elapsed=elapsed)
+ panel = self.comparison_panel
+ missing = data.get("missing")
+ gaps = panel.gap_counts(data) if hasattr(panel, "gap_counts") else {}
+ self.rail.set_badges({
+ "overview": panel.difference_count() if hasattr(panel, "difference_count") else "",
+ "report": sum(1 for key in MODELS if mapping(mapping(data.get("models")).get(key)).get("report")),
+ "per_herb": self.candidates_page.row_count() if hasattr(self.candidates_page, "row_count") else "",
+ "gaps": len(missing) if isinstance(missing, list) else "",
+ "pipeline": sum(len(mapping(mapping(mapping(data.get("models")).get(key)).get("usage")).get("calls") or [])
+ for key in MODELS),
+ "history": self.history.count() or "",
+ }, tones={
+ # Severity picks the badge's colour: what still needs a decision reads rose, the
+ # source gaps read amber, everything else stays quiet.
+ "overview": "hot" if gaps.get("critical") else "",
+ "gaps": "warn" if missing else "",
+ })
+ self.rail.set_focus({
+ "differences": panel.difference_count() if hasattr(panel, "difference_count") else None,
+ "critical": gaps.get("critical"),
+ "restricted": panel.restricted_count(data) if hasattr(panel, "restricted_count") else None,
+ "consensus": panel.consensus_count() if hasattr(panel, "consensus_count") else None,
+ })
+
+ def _render_facts(self, batch: Any) -> None:
+ snapshot = mapping(mapping(batch).get("doctor_snapshot"))
+ patient = mapping(snapshot.get("patient"))
+ prescription = mapping(snapshot.get("prescription"))
+ age = str(patient.get("age") or "").strip()
+ person = " · ".join(part for part in (
+ str(patient.get("name") or "").strip(),
+ str(patient.get("gender_label") or "").strip(),
+ f"{age} 岁" if age else "") if part)
+ doses = str(prescription.get("dose_count") or "").strip()
+ formulation = " · ".join(part for part in (
+ str(prescription.get("prescription_type") or "").strip(),
+ f"{doses} {str(prescription.get('dose_unit') or '剂').strip()}" if doses else "") if part)
+ # The rows say what was actually compared; the frozen prescription is the fallback.
+ basis = _comparison_basis(batch) or (
+ "同单位 · 每剂" if str(prescription.get("dose_unit") or "").strip() in {"剂", "付"} else "")
+ diagnosis_text = str(mapping(snapshot.get("diagnosis")).get("clinical_diagnosis") or "").strip()
+ complaint = str(mapping(snapshot.get("diagnosis")).get("chief_complaint") or "").strip()
+ self.identity.setText(" · ".join(part for part in (
+ person, f"主诉 {complaint}" if complaint else "",
+ " ".join(diagnosis_text.split()), formulation) if part) or "尚无冻结快照")
+ values = {
+ "prescription": f"{batch.get('prescription_id') or '—'} / {batch.get('diagnosis_id') or '—'}",
+ "batch": f"#{batch.get('id')}" if mapping(batch).get("id") else "—",
+ "diagnosis": str(mapping(snapshot.get("diagnosis")).get("clinical_diagnosis") or "").strip(),
+ "formulation": formulation,
+ "cutoff": format_record_time(mapping(batch).get("cutoff_at")),
+ "basis": basis,
+ }
+ for key, widget in self.fact_values.items():
+ text = str(values.get(key) or "").strip()
+ widget.setText(text or "—")
+ widget.setToolTip(text)
+
+ def _sync_failures(self, batch: Any) -> None:
+ """Show why a model stopped and whether a retry is still available."""
+
+ data = mapping(batch)
+ retry_allowed = has_permission(self.permissions, "tcm.prescriptionAi/retry", default=False)
+ for key, name in MODELS.items():
+ model = mapping(mapping(data.get("models")).get(key))
+ if model.get("status") != "failed":
+ self.agreement.set_failure(key, "", False)
+ continue
+ reason = _reason(model.get("error_message") or model.get("error_code")) or "模型未能完成本次生成"
+ retryable = bool(retry_allowed and current_batch(data) and not self._progress_stale and self._feature_enabled)
+ exhausted = int(mapping(model.get("progress")).get("manual_retries") or model.get("manual_retries") or 0) >= 2
+ if exhausted:
+ self.agreement.set_failure(
+ key, f"{name} 失败:{reason}。手动重试次数已用完,请使用“重新分析”生成新批次。", False)
+ else:
+ self.agreement.set_failure(
+ key, f"{name} 失败:{reason}。可仅重试本模型,使用原资料快照。", retryable)
+
def _set_chip(self, chip: QLabel, text: str, tone: str) -> None:
background, colour = CHIP_TONES.get(tone, CHIP_TONES["neutral"])
chip.setText(text)
- chip.setStyleSheet(f"background: {background}; color: {colour}; border-radius: 9px; padding: 2px 9px; font-size: 12px;")
+ chip.setStyleSheet(f"background: {background}; color: {colour}; border-radius: 9px;"
+ " padding: 2px 9px; font-size: 10.2px;")
chip.setVisible(bool(text))
def _set_chips(self, chips: Any) -> None:
- while self.chip_row.count():
- item = self.chip_row.takeAt(0)
- widget = item.widget()
+ visible = [(text, tone) for text, tone in chips if text]
+ for index, (text, tone) in enumerate(visible):
+ if index < self.chip_row.count():
+ self._set_chip(self.chip_row.itemAt(index).widget(), text, tone)
+ else:
+ self.chip_row.addWidget(_chip(text, tone, parent=self.chip_row.parentWidget()))
+ while self.chip_row.count() > len(visible):
+ widget = self.chip_row.takeAt(self.chip_row.count() - 1).widget()
if widget is not None:
+ widget.hide()
widget.deleteLater()
- for text, tone in chips:
- if text:
- self.chip_row.addWidget(_chip(text, tone))
def _progress_live(self) -> bool:
return self.isVisible() and self._feature_enabled and not self._busy and not self._progress_stale and batch_running(self._batch) and can_open_issued_ai(self.permissions)
@@ -824,7 +1569,7 @@ class IssuedPrescriptionAiDialog(QDialog):
# A finished batch does not need a stage strip competing with the results.
self.progress_flow.setVisible(bool(self._batch) and (live or batch_running(self._batch) or historical))
batch_progress = progress_view(self._batch, seconds=seconds, live=live)
- self.batch_progress.setVisible(bool(self._batch.get("progress")) and self.progress_flow.isVisible())
+ self.batch_progress.setVisible(bool(self._batch.get("progress")) and not self.progress_flow.isHidden())
per_model = batch_progress.stage == "unknown" and batch_running(self._batch) and bool(self._batch.get("models"))
batch_headline = "双模型分别处理中" if per_model else batch_progress.headline
self.batch_progress.setText(prefix + batch_headline + ("\n" + batch_progress.detail if batch_progress.detail else ""))
@@ -850,6 +1595,9 @@ class IssuedPrescriptionAiDialog(QDialog):
bar.setValue(0)
bar.hide()
views["progress_title"].setVisible(progress.busy or bool(progress.detail))
+ if "stage" in views:
+ views["stage"].setText(progress.headline)
+ views["stage"].setVisible(progress.busy or bool(progress.headline) and live)
views["progress_detail"].setVisible(bool(progress.detail))
bar.setAccessibleName(progress.headline)
bar.setToolTip("仅显示本阶段已完成的资料组数,不代表整体完成比例。" if progress.total is not None else progress.headline)
@@ -882,6 +1630,8 @@ class IssuedPrescriptionAiDialog(QDialog):
views["save"].setEnabled(active and not self._progress_stale and reviewable)
views["review_state"].setEnabled(active and reviewable)
views["comment"].setEnabled(active and reviewable)
+ selected = self.review_model.currentData()
+ self.save_review_button.setToolTip(f"保存 {MODELS[selected]} 的复核意见")
def _mutate(self, operation: Any, success: str) -> None:
if self._busy:
@@ -950,7 +1700,8 @@ class PrescriptionAiStatisticsDialog(QDialog):
self.repository, self.permissions = repository, permissions
self._generation = 0
self.setWindowTitle("医生处方 AI 一致度统计")
- self.resize(1060, 700)
+ self.setStyleSheet(dialog_style() + rail_qss())
+ self.resize(1180, 860)
root = QVBoxLayout(self)
note = QLabel("按首次独立基线汇总;后续修订与重试不增加样本。" + DISCLAIMER)
note.setWordWrap(True)
@@ -975,11 +1726,18 @@ class PrescriptionAiStatisticsDialog(QDialog):
self.summary.setTextFormat(Qt.TextFormat.PlainText)
self.summary.setWordWrap(True)
root.addWidget(self.summary)
+ # Headline counts, sample funnel and per-doctor rows first; the saved breakdown with the
+ # version strata stays below, because that detail is what makes a number interpretable.
+ self.panel = StatisticsPanel(self)
+ root.addWidget(self.panel, 4)
self.report = _browser()
+ self.report.document().setDefaultStyleSheet(_report_css())
+ self.report.setMinimumHeight(130)
root.addWidget(self.report, 1)
def showEvent(self, event: Any) -> None:
super().showEvent(event)
+ apply_window_chrome(self)
self.refresh()
def hideEvent(self, event: Any) -> None:
@@ -1015,13 +1773,15 @@ class PrescriptionAiStatisticsDialog(QDialog):
data = mapping(result)
if data.get("enabled") is False:
self.summary.setText("当前未启用 AI 分析统计。")
+ self.panel.set_statistics({})
self.report.clear()
return
self.summary.setText(f"范围内开方事件:{label(data.get('total_count'))} · 患者数:{label(data.get('patient_count'))}。小样本应谨慎解读,未按分数排名。")
+ self.panel.set_statistics(data)
sections = []
for row in data.get("doctors") or []:
doctor = mapping(row)
- sections.append(f"{escape(str(doctor.get('doctor_name') or '未命名医生'))}
开方事件:{escape(label(doctor.get('total_count')))} · 患者数:{escape(label(doctor.get('patient_count')))} · 双模型共同有效样本:{escape(label(doctor.get('paired_count')))}
")
+ sections.append(f"{escape(str(doctor.get('doctor_name') or '未命名医生'))}
开方事件:{escape(label(doctor.get('total_count')))} · 患者数:{escape(label(doctor.get('patient_count')))} · 双模型共同有效样本:{escape(label(doctor.get('paired_count')))}
")
for key, name in MODELS.items():
model = mapping(mapping(doctor.get("models")).get(key))
sections.append(f"{name} 有效比较数:{escape(label(model.get('eligible_count')))} 覆盖率:{percentage(model.get('coverage_rate'), 1)} 均值:{percentage(model.get('mean'), 1)} 中位数:{percentage(model.get('median'), 1)}
")
@@ -1045,10 +1805,14 @@ class PrescriptionAiStatisticsDialog(QDialog):
self.report.setHtml("".join(sections) or "当前范围内没有可展示的统计记录。
")
-def present_issued_prescription_ai(repository: Any, permissions: Any, parent: QWidget, *, prescription_id: int = 0, diagnosis_id: int = 0) -> IssuedPrescriptionAiDialog | None:
+def present_issued_prescription_ai(repository: Any, permissions: Any, parent: QWidget, *, prescription_id: int = 0,
+ diagnosis_id: int = 0, current_user: Any = None) -> IssuedPrescriptionAiDialog | None:
if not can_open_issued_ai(permissions):
return None
- dialog = IssuedPrescriptionAiDialog(repository, permissions, parent, prescription_id=prescription_id, diagnosis_id=diagnosis_id)
+ dialog = IssuedPrescriptionAiDialog(repository, permissions, parent, prescription_id=prescription_id,
+ diagnosis_id=diagnosis_id,
+ current_user=current_user if current_user is not None
+ else getattr(parent, "current_user", None))
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
dialog.show()
return dialog
diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_charts.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_charts.py
new file mode 100644
index 000000000..adb5c40ec
--- /dev/null
+++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_charts.py
@@ -0,0 +1,375 @@
+"""Painted charts for the prescription analysis window, in the workstation's tech blue.
+
+Every widget draws only what the saved report contains: a value that is missing stays visibly
+absent instead of being drawn as zero, and no chart implies a medical judgement by colour.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+from PySide6.QtCore import QPointF, QRectF, QSize, Qt
+from PySide6.QtGui import QColor, QFont, QPainter, QPaintEvent
+from PySide6.QtWidgets import QSizePolicy, QWidget
+
+from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE
+
+# Colour names resolve through the active palette at paint time, so a theme switch needs no
+# rebuild here: the next repaint already draws in the new colours.
+
+
+class _Palette:
+ """Attribute access into the live palette: ``COLOUR.qwen`` is always the current hue."""
+
+ __slots__ = ("_keys",)
+
+ def __init__(self, **keys: str) -> None:
+ object.__setattr__(self, "_keys", dict(keys))
+
+ def __getattr__(self, name: str) -> str:
+ return TECH_BLUE[self._keys[name]]
+
+
+COLOUR = _Palette(doctor="muted", qwen="qwen", openai="openai", track="raised",
+ limited="amber", ink="heading", muted="muted")
+
+
+def _mapping(value: Any) -> dict[str, Any]:
+ return dict(value) if isinstance(value, Mapping) else {}
+
+
+def _count(value: Any) -> int:
+ if value is None or isinstance(value, bool):
+ return 0
+ try:
+ number = int(float(value))
+ except (TypeError, ValueError):
+ return 0
+ return max(0, number)
+
+
+class DonutGauge(QWidget):
+ """A ring reading one percentage. An unavailable value leaves the track empty, never zero."""
+
+ def __init__(self, accent: str, parent: QWidget | None = None, *, diameter: int = 88,
+ thickness: int = 11, track: str = COLOUR.track) -> None:
+ super().__init__(parent)
+ self._accent, self._track = accent, track
+ self._thickness = thickness
+ self._value: float | None = None
+ self.setFixedSize(diameter, diameter)
+ self.setAccessibleName("一致度环形图")
+ self.set_value(None)
+
+ def set_value(self, value: Any) -> None:
+ parsed = None
+ try:
+ parsed = None if value is None or isinstance(value, bool) else float(value)
+ except (TypeError, ValueError):
+ parsed = None
+ self._value = None if parsed is None or parsed < 0 or parsed > 100 else parsed
+ self.setAccessibleDescription("暂无可比结果" if self._value is None else f"{self._value:.1f}%")
+ self.update()
+
+ def value(self) -> float | None:
+ return self._value
+
+ def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ inset = self._thickness / 2 + 1
+ box = QRectF(inset, inset, self.width() - 2 * inset, self.height() - 2 * inset)
+ pen = painter.pen()
+ pen.setWidthF(self._thickness)
+ pen.setCapStyle(Qt.PenCapStyle.FlatCap)
+ pen.setColor(QColor(self._track))
+ painter.setPen(pen)
+ painter.setBrush(Qt.BrushStyle.NoBrush)
+ painter.drawEllipse(box)
+ if self._value:
+ pen.setColor(QColor(self._accent))
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
+ painter.setPen(pen)
+ # Qt angles are sixteenths of a degree; start at twelve o'clock and run clockwise.
+ painter.drawArc(box, 90 * 16, -int(360 * 16 * self._value / 100))
+ painter.end()
+
+
+class VennChart(QWidget):
+ """Doctor / model-A / model-B herb sets with their real intersection counts."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._sets: dict[str, int] = {}
+ self._labels = ("医生原方", "千问", "OpenAI")
+ self.setMinimumHeight(96)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+
+ def set_counts(self, counts: Mapping[str, int], labels: tuple[str, str, str] | None = None) -> None:
+ """Regions: doctor_only, qwen_only, openai_only, doctor_qwen, doctor_openai, qwen_openai, all."""
+
+ self._sets = {key: _count(value) for key, value in _mapping(counts).items()}
+ if labels:
+ self._labels = labels
+ total = sum(self._sets.values())
+ self.setAccessibleDescription(
+ "三方用药交集:" + " · ".join(f"{key} {value}" for key, value in self._sets.items()) if total
+ else "尚无可比较的候选药方")
+ self.update()
+
+ def has_data(self) -> bool:
+ return any(self._sets.values())
+
+ def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
+ if not self.has_data():
+ return
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ # The three set names sit above and below the circles, so their bands are reserved first.
+ top_band, bottom_band = 18.0, 18.0
+ available = max(40.0, self.height() - top_band - bottom_band)
+ side = min(self.width() * 0.62, available / 1.34)
+ radius = side / 2
+ offset = radius * 0.52
+ centre_x = self.width() / 2
+ centre_y = top_band + available / 2 - offset * 0.1
+ circles = (
+ (centre_x - offset, centre_y - offset * 0.55, COLOUR.doctor),
+ (centre_x + offset, centre_y - offset * 0.55, COLOUR.qwen),
+ (centre_x, centre_y + offset * 0.75, COLOUR.openai),
+ )
+ painter.setPen(Qt.PenStyle.NoPen)
+ for x, y, colour in circles:
+ fill = QColor(colour)
+ fill.setAlpha(52)
+ painter.setBrush(fill)
+ painter.drawEllipse(QPointF(x, y), radius, radius)
+
+ font = QFont(self.font())
+ font.setPixelSize(13)
+ font.setBold(True)
+ painter.setFont(font)
+ regions = (
+ ("doctor_only", centre_x - offset * 1.5, centre_y - offset * 0.75, COLOUR.doctor),
+ ("qwen_only", centre_x + offset * 1.5, centre_y - offset * 0.75, COLOUR.qwen),
+ ("openai_only", centre_x, centre_y + offset * 1.45, COLOUR.openai),
+ ("doctor_qwen", centre_x, centre_y - offset * 0.95, COLOUR.ink),
+ ("doctor_openai", centre_x - offset * 0.85, centre_y + offset * 0.55, COLOUR.ink),
+ ("qwen_openai", centre_x + offset * 0.85, centre_y + offset * 0.55, COLOUR.ink),
+ ("all", centre_x, centre_y + offset * 0.1, COLOUR.ink),
+ )
+ for key, x, y, colour in regions:
+ value = self._sets.get(key, 0)
+ if not value:
+ continue
+ painter.setPen(QColor(colour))
+ painter.drawText(QRectF(x - 22, y - 10, 44, 20), Qt.AlignmentFlag.AlignCenter, str(value))
+
+ font.setPixelSize(11)
+ font.setBold(False)
+ painter.setFont(font)
+ painter.setPen(QColor(COLOUR.muted))
+ top = centre_y - offset * 0.55 - radius - 17
+ painter.setPen(QColor(COLOUR.doctor))
+ painter.drawText(QRectF(0, top, centre_x - 6, 16),
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._labels[0])
+ painter.setPen(QColor(COLOUR.qwen))
+ painter.drawText(QRectF(centre_x + 6, top, centre_x - 6, 16),
+ Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, self._labels[1])
+ painter.setPen(QColor(COLOUR.openai))
+ painter.drawText(QRectF(0, centre_y + offset * 0.75 + radius + 1, self.width(), 16),
+ Qt.AlignmentFlag.AlignCenter, self._labels[2])
+ painter.end()
+
+ def minimumSizeHint(self) -> QSize:
+ return QSize(200, 96)
+
+
+class DivergingDoses(QWidget):
+ """Per-herb dose difference against the doctor's prescription, one row per herb."""
+
+ ROW = 30
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._rows: list[dict[str, Any]] = []
+ self._span = 1.0
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+
+ def set_rows(self, rows: list[Mapping[str, Any]]) -> None:
+ """Each row: name, doctor, qwen, openai (floats or None), unit."""
+
+ self._rows = []
+ for row in rows:
+ value = _mapping(row)
+ entry = {"name": str(value.get("name") or ""), "unit": str(value.get("unit") or "")}
+ for key in ("doctor", "qwen", "openai"):
+ raw = value.get(key)
+ entry[key] = None if raw is None or isinstance(raw, bool) else float(raw)
+ self._rows.append(entry)
+ deltas = [abs((row[key] or 0) - (row["doctor"] or 0))
+ for row in self._rows for key in ("qwen", "openai")
+ if row[key] is not None and row["doctor"] is not None]
+ self._span = max(1.0, max(deltas, default=1.0))
+ self.setAccessibleDescription("剂量差异:" + " · ".join(
+ f"{row['name']} 千问 {self._delta_text(row, 'qwen')} OpenAI {self._delta_text(row, 'openai')}"
+ for row in self._rows) if self._rows else "暂无可比药味")
+ self.setMinimumHeight(self.ROW * max(1, len(self._rows)) + 18)
+ self.updateGeometry()
+ self.update()
+
+ def _delta_text(self, row: Mapping[str, Any], key: str) -> str:
+ if row.get(key) is None or row.get("doctor") is None:
+ return "—"
+ delta = row[key] - row["doctor"]
+ return "一致" if abs(delta) < 1e-9 else f"{delta:+g}{row.get('unit') or ''}"
+
+ def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
+ if not self._rows:
+ return
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ label_width, value_width = 96, 96
+ left = label_width + 10
+ right = self.width() - value_width - 10
+ middle = (left + right) / 2
+ scale = max(1.0, (right - left) / 2) / self._span
+ font = QFont(self.font())
+ font.setPixelSize(12)
+ painter.setFont(font)
+ for index, row in enumerate(self._rows):
+ top = index * self.ROW + 4
+ painter.setPen(QColor(COLOUR.ink))
+ painter.drawText(QRectF(0, top, label_width, self.ROW - 8),
+ Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, row["name"])
+ painter.setPen(QColor(COLOUR.track))
+ painter.drawLine(QPointF(middle, top), QPointF(middle, top + self.ROW - 10))
+ for offset, key, colour in ((0, "qwen", COLOUR.qwen), (10, "openai", COLOUR.openai)):
+ if row[key] is None or row["doctor"] is None:
+ continue
+ delta = (row[key] - row["doctor"]) * scale
+ bar = QRectF(min(middle, middle + delta), top + offset, max(abs(delta), 2.0), 8)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor(colour))
+ painter.drawRoundedRect(bar, 3, 3)
+ painter.setPen(QColor(COLOUR.muted))
+ painter.drawText(QRectF(right + 8, top, value_width - 8, self.ROW - 8),
+ Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
+ f"{self._delta_text(row, 'qwen')} / {self._delta_text(row, 'openai')}")
+ painter.end()
+
+
+class WaffleCoverage(QWidget):
+ """One square per attachment: read, limited, or not delivered."""
+
+ def __init__(self, parent: QWidget | None = None, *, columns: int = 11) -> None:
+ super().__init__(parent)
+ self._columns = max(4, columns)
+ self._read = self._limited = self._total = 0
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+
+ def set_counts(self, read: int, limited: int, total: int | None = None) -> None:
+ self._read, self._limited = _count(read), _count(limited)
+ self._total = max(_count(total), self._read + self._limited)
+ self.setAccessibleDescription(
+ f"附件 {self._total} 个:已读 {self._read},受限或不支持 {self._limited}" if self._total else "本次没有附件")
+ rows = max(1, -(-self._total // self._columns)) if self._total else 0
+ self.setMinimumHeight(rows * 16 + max(0, rows - 1) * 4)
+ self.updateGeometry()
+ self.update()
+
+ def has_data(self) -> bool:
+ return self._total > 0
+
+ def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
+ if not self._total:
+ return
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ painter.setPen(Qt.PenStyle.NoPen)
+ gap = 4
+ size = max(8.0, min(16.0, (self.width() - gap * (self._columns - 1)) / self._columns))
+ for index in range(self._total):
+ column, row = index % self._columns, index // self._columns
+ colour = COLOUR.qwen if index < self._read else (COLOUR.limited if index < self._read + self._limited else COLOUR.track)
+ painter.setBrush(QColor(colour))
+ painter.drawRoundedRect(QRectF(column * (size + gap), row * (size + gap), size, size), 4, 4)
+ painter.end()
+
+
+class TrendBars(QWidget):
+ """Grouped bars per batch: one column per model, oldest batch first."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._points: list[dict[str, Any]] = []
+ self.setMinimumHeight(170)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+
+ def set_points(self, points: list[Mapping[str, Any]]) -> None:
+ """Each point: label plus qwen/openai scores; None keeps the slot visibly empty."""
+
+ self._points = []
+ for point in points:
+ value = _mapping(point)
+ entry = {"label": str(value.get("label") or "")}
+ for key in ("qwen", "openai"):
+ raw = value.get(key)
+ entry[key] = None if raw is None or isinstance(raw, bool) else float(raw)
+ self._points.append(entry)
+ described = [f"{entry['label']} 千问 {self._text(entry['qwen'])} OpenAI {self._text(entry['openai'])}"
+ for entry in self._points]
+ self.setAccessibleDescription("一致度趋势:" + (" · ".join(described) or "暂无批次"))
+ self.setToolTip("\n".join(described) or "暂无批次")
+ self.update()
+
+ @staticmethod
+ def _text(value: float | None) -> str:
+ return "—" if value is None else f"{value:.1f}%"
+
+ def has_data(self) -> bool:
+ return any(point.get(key) is not None for point in self._points for key in ("qwen", "openai"))
+
+ def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
+ if not self._points:
+ return
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ # The top band is left free so each bar can print its own figure above it.
+ left, right, top, bottom = 44.0, self.width() - 10.0, 24.0, self.height() - 24.0
+ values = [point[key] for point in self._points for key in ("qwen", "openai") if point[key] is not None]
+ ceiling = max(10.0, max(values, default=10.0))
+ font = QFont(self.font())
+ font.setPixelSize(10)
+ painter.setFont(font)
+ for fraction in (0.0, 0.5, 1.0):
+ y = bottom - (bottom - top) * fraction
+ painter.setPen(QColor(COLOUR.track))
+ painter.drawLine(QPointF(left, y), QPointF(right, y))
+ painter.setPen(QColor(COLOUR.muted))
+ painter.drawText(QRectF(0, y - 8, left - 6, 16),
+ Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
+ f"{ceiling * fraction:.0f}%")
+ slot = (right - left) / max(1, len(self._points))
+ width = min(26.0, slot / 3.6)
+ # The two bars of a batch sit side by side, far enough apart for each figure to fit above.
+ gap = width / 2 + 4
+ for index, point in enumerate(self._points):
+ centre = left + slot * (index + 0.5)
+ for offset, key, colour in ((-gap, "qwen", COLOUR.qwen), (gap, "openai", COLOUR.openai)):
+ value = point[key]
+ if value is None:
+ continue
+ height = (bottom - top) * min(1.0, value / ceiling)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor(colour))
+ painter.drawRoundedRect(QRectF(centre + offset - width / 2, bottom - height, width, height), 3, 3)
+ # The design prints each figure above its bar, in that model's own colour.
+ painter.setPen(QColor(colour))
+ painter.drawText(QRectF(centre + offset - 21, bottom - height - 17, 42, 14),
+ Qt.AlignmentFlag.AlignCenter, f"{value:.1f}%")
+ painter.setPen(QColor(COLOUR.muted))
+ painter.drawText(QRectF(centre - slot / 2, bottom + 4, slot, 16),
+ Qt.AlignmentFlag.AlignCenter, point["label"])
+ painter.end()
diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_comparison.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_comparison.py
new file mode 100644
index 000000000..71f2325ad
--- /dev/null
+++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_comparison.py
@@ -0,0 +1,644 @@
+"""Native, read-only prescription comparison using saved report snapshots only."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from copy import deepcopy
+from dataclasses import dataclass, replace
+from decimal import Decimal, InvalidOperation
+from typing import Any
+
+from PySide6.QtCore import QRectF, QSize, Qt
+from PySide6.QtGui import QColor, QFont, QPainter, QPaintEvent
+from PySide6.QtWidgets import (
+ QAbstractItemView,
+ QButtonGroup,
+ QFrame,
+ QHBoxLayout,
+ QHeaderView,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QScrollArea,
+ QSizePolicy,
+ QTableWidget,
+ QTableWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+from .issued_prescription_ai_labels import EXTRA_FIELD_LABELS, SYSTEM_LABELS, system_text
+from .issued_prescription_ai_progress import ACTIVE_STATES, SUCCESS_STATES
+
+MODEL_NAMES = {"qwen": "千问", "openai": "OpenAI"}
+MODEL_COLORS = {"qwen": "#3676C8", "openai": "#268578"}
+DOCTOR_COLOR = "#526479"
+INK, MUTED, LINE = "#23384C", "#758395", "#E6EDF3"
+_UNITS = {
+ "g": "克", "克": "克", "mg": "毫克", "毫克": "毫克", "kg": "千克", "千克": "千克",
+ "ml": "毫升", "毫升": "毫升", "l": "升", "升": "升", "iu": "国际单位", "国际单位": "国际单位",
+ **{unit: unit for unit in ("片", "丸", "粒", "袋", "包", "支", "滴", "枚", "个", "条", "付", "帖", "两", "钱")},
+}
+_BASES = {"per_dose": "每剂", "每剂": "每剂", "per_day": "每日", "每日": "每日", "每天": "每日"}
+_FORMULAS = {"main": "主方", "1": "主方", "主方": "主方", "aux": "辅方", "auxiliary": "辅方", "2": "辅方", "辅方": "辅方"}
+_STALE = {
+ "stale": "处方已变更", "superseded": "处方已变更", "prescription_changed": "处方已变更",
+ "source_updated": "资料已更新", "invalid": "报告已失效", "voided": "处方已作废",
+ "deleted": "处方已删除", "revoked": "资料权限已变更",
+}
+
+
+def _mapping(value: Any) -> dict[str, Any]:
+ return dict(value) if isinstance(value, Mapping) else {}
+
+
+def _text(value: Any) -> str:
+ """Never stringify a structured payload, which could expose internal identifiers."""
+ return str(value).strip() if isinstance(value, (str, int, float, Decimal)) and not isinstance(value, bool) else ""
+
+
+def _reason(value: Any) -> str:
+ if isinstance(value, Mapping):
+ return _reason(value.get("reason") or value.get("message") or value.get("code"))
+ if isinstance(value, list):
+ return ";".join(filter(None, (_reason(item) for item in value)))
+ return system_text(value, SYSTEM_LABELS, EXTRA_FIELD_LABELS, strict=True) if _text(value) else ""
+
+
+def _number(value: Any) -> Decimal | None:
+ if not _text(value):
+ return None
+ try:
+ number = Decimal(str(value))
+ except (InvalidOperation, ValueError):
+ return None
+ return number if number.is_finite() and number >= 0 else None
+
+
+def _formula(value: Any) -> str:
+ return _FORMULAS.get(_text(value), "主辅方未注明")
+
+
+def _metadata(value: Any) -> str:
+ return system_text(value, SYSTEM_LABELS, EXTRA_FIELD_LABELS, strict=True) if _text(value) else ""
+
+
+def _join_instructions(values: list[str]) -> str:
+ parts = [part.strip() for value in values for part in value.split(";")]
+ return ";".join(dict.fromkeys(part for part in parts if part and part.lower() not in {"无", "明确无", "none"}))
+
+
+def _instructions(snapshot: dict[str, Any]) -> str:
+ """Read both original herb fields and the service's normalized usage snapshot."""
+ fields = ("instructions", "decoction_instruction", "special_usage", "usage_instruction", "usage_time", "usage_way")
+ usage = _mapping(snapshot.get("usage"))
+ values = [_text(owner.get(field)) for owner in (snapshot, usage) for field in fields]
+ if not usage:
+ values.append(_text(snapshot.get("usage")))
+ return _join_instructions(values)
+
+
+@dataclass(frozen=True)
+class _Dose:
+ raw: Any
+ unit: str
+ basis: str
+ formula: str
+ processing: str
+ route: str
+ group: str
+ instructions: str = ""
+
+ @property
+ def number(self) -> Decimal | None:
+ return _number(self.raw)
+
+ @property
+ def scale(self) -> tuple[str, str] | None:
+ unit, basis = _UNITS.get(self.unit.lower()), _BASES.get(self.basis)
+ return (unit, basis) if unit and basis else None
+
+ @property
+ def label(self) -> str:
+ amount = _text(self.raw)
+ if not amount:
+ return "—"
+ unit = _UNITS.get(self.unit.lower()) or _metadata(self.unit) or "单位未注明"
+ basis = _BASES.get(self.basis) or "基准未确认"
+ return f"{amount} {unit} / {basis}"
+
+ @property
+ def identity(self) -> tuple[str, str, str, str]:
+ # Unknown enum values must remain distinct even when their display label is generic.
+ return (_FORMULAS.get(self.formula, self.formula), SYSTEM_LABELS.get(self.processing, self.processing), SYSTEM_LABELS.get(self.route, self.route), self.group)
+
+
+@dataclass(frozen=True)
+class _Row:
+ name: str
+ context: str
+ doctor: _Dose | None
+ candidate: _Dose | None
+ match_type: str
+ origin: str = "comparison"
+ source_details: str = ""
+ source_names: tuple[str, ...] = ()
+
+ @property
+ def scale(self) -> tuple[str, str] | None:
+ if self.origin != "comparison":
+ return None
+ doses = [dose for dose in (self.doctor, self.candidate) if dose is not None]
+ if not doses or any(dose.scale is None for dose in doses):
+ return None
+ if len({dose.scale for dose in doses}) != 1 or len({dose.identity for dose in doses}) != 1:
+ return None
+ return doses[0].scale
+
+ @property
+ def incompatibility(self) -> str:
+ if self.origin == "uncompared":
+ return "未纳入对比,仅保留候选原方;医生剂量未知"
+ if self.origin == "original":
+ return "候选原方附列;对应关系未保存,不推断同药"
+ if self.doctor is not None and self.candidate is not None:
+ if self.doctor.identity != self.candidate.identity:
+ return "主辅方、炮制或给药分组不同,不作条形比较"
+ if self.doctor.scale and self.candidate.scale and self.doctor.scale != self.candidate.scale:
+ return "单位或剂量基准不同,不作条形比较"
+ return "单位或每剂/每日基准未明确,不作条形比较"
+
+ def usage_description(self, model_name: str) -> str:
+ return ";".join(filter(None, ("医生:" + self.doctor.instructions if self.doctor and self.doctor.instructions else "", model_name + ":" + self.candidate.instructions if self.candidate and self.candidate.instructions else "")))
+
+ def description(self, model_name: str) -> str:
+ dosage = f"{self.name},{self.context};医生:{self.doctor.label if self.doctor else '—'};{model_name}:{self.candidate.label if self.candidate else '—'}"
+ return ";".join(filter(None, (dosage, self.usage_description(model_name), self.source_details)))
+
+
+def _dose(row: dict[str, Any], side: str) -> _Dose | None:
+ # An explicit null snapshot takes precedence over contradictory legacy flat fields.
+ if side in row and row[side] is None:
+ return None
+ nested = _mapping(row.get(side))
+ keys = ("doctor_dosage", "doctor_dose") if side == "doctor" else ("candidate_dosage", "candidate_dose", "ai_dose")
+ raw = nested.get("dosage") if "dosage" in nested else next((row[key] for key in keys if key in row), None)
+ if not nested and raw is None:
+ return None
+ values = {key: _text(nested[key] if key in nested else row.get(key)) for key in ("unit", "dose_basis", "formula_type", "processing", "administration_route", "group")}
+ instructions = _instructions(nested) or _instructions(row)
+ return _Dose(raw, values["unit"], values["dose_basis"], values["formula_type"], values["processing"], values["administration_route"], values["group"], instructions)
+
+
+def _row(value: dict[str, Any]) -> _Row:
+ doctor, candidate = _dose(value, "doctor"), _dose(value, "candidate")
+ details = []
+ for dose in (doctor, candidate):
+ if dose is not None:
+ detail = " · ".join(filter(None, (_formula(dose.formula), _metadata(dose.processing), _metadata(dose.route), _metadata(dose.group))))
+ if detail not in details:
+ details.append(detail)
+ context = " / ".join(details) or _formula(value.get("formula_type"))
+ if len(details) > 1:
+ context = "医生与模型:" + context
+ return _Row(_text(value.get("name") or value.get("canonical_name") or value.get("herb_name")) or "药名未保存", context, doctor, candidate, _text(value.get("match_type")))
+
+
+def _original_row(herb: Any, *, origin: str) -> _Row:
+ saved = dict(herb) if isinstance(herb, Mapping) else {"name": _text(herb) or "药材记录需核对"}
+ row = _row({"name": saved.get("name"), "doctor": None, "candidate": saved, "match_type": "candidate_only"})
+ note = "未纳入对比" if origin == "uncompared" else "候选原方附列 · 对应关系未保存"
+ return replace(row, origin=origin, context=note + " · " + row.context)
+
+
+def _saved_rows(candidate: dict[str, Any], comparison: dict[str, Any]) -> list[_Row]:
+ saved = comparison.get("rows")
+ comparison_rows = [dict(value) for value in saved if isinstance(value, Mapping)] if isinstance(saved, list) else []
+ herbs = candidate.get("herbs")
+ herbs = herbs if isinstance(herbs, list) else []
+ result: list[_Row] = []
+ covered: set[int] = set()
+ missing_correspondence = False
+ for value in comparison_rows:
+ row = _row(value)
+ if row.candidate is not None and herbs:
+ indices = _mapping(value.get("candidate")).get("source_rows")
+ # The service uses zero-based array_values indices; never infer correspondence
+ # from names, doses, order, or the number of normalized rows.
+ valid_trace = isinstance(indices, list) and bool(indices) and all(isinstance(index, int) and not isinstance(index, bool) and 0 <= index < len(herbs) for index in indices)
+ if valid_trace:
+ indices = list(dict.fromkeys(indices))
+ covered.update(indices)
+ originals = [_original_row(herbs[index], origin="original") for index in indices]
+ details = ";".join(f"第 {index + 1} 项 {original.name} {original.candidate.label if original.candidate else '—'}" + ("," + original.candidate.instructions if original.candidate and original.candidate.instructions else "") for index, original in zip(indices, originals, strict=True))
+ instructions = _join_instructions([row.candidate.instructions, *[original.candidate.instructions for original in originals if original.candidate]])
+ row = replace(row, candidate=replace(row.candidate, instructions=instructions), source_details="候选原方记录:" + details, source_names=tuple(original.name for original in originals))
+ else:
+ missing_correspondence = True
+ result.append(row)
+ # Normalization can omit unknown names, processing conflicts, or incompatible duplicate
+ # entries. Keep every unaccounted original, but do not claim its doctor counterpart.
+ origin = "original" if missing_correspondence else "uncompared"
+ result.extend(_original_row(herb, origin=origin) for index, herb in enumerate(herbs) if index not in covered)
+ return result
+
+
+class _DoseChart(QWidget):
+ """A scrollable painted chart; the adjacent table provides native accessibility."""
+
+ ROW_HEIGHT = 80
+ GROUP_HEIGHT = 38
+
+ def __init__(self, parent: QWidget) -> None:
+ super().__init__(parent)
+ self.setObjectName("PrescriptionDoseChart")
+ self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
+ self.setAccessibleName("药方逐味剂量对比图")
+ self.rows: list[_Row] = []
+ self.groups: list[tuple[tuple[str, str] | None, list[_Row], Decimal]] = []
+ self.model_key = "qwen"
+ self.message = "暂无药方数据"
+ self.bars_enabled = False
+
+ def set_rows(self, rows: list[_Row], model_key: str, *, bars_enabled: bool, message: str) -> None:
+ self.rows, self.model_key, self.bars_enabled, self.message = rows, model_key, bars_enabled, message
+ grouped: dict[tuple[str, str] | None, list[_Row]] = {}
+ for row in rows:
+ grouped.setdefault(row.scale if bars_enabled else None, []).append(row)
+ self.groups = []
+ for scale, members in grouped.items():
+ numbers = [dose.number for row in members for dose in (row.doctor, row.candidate) if dose is not None and dose.number is not None]
+ self.groups.append((scale, members, max(numbers, default=Decimal(0))))
+ color_name = "蓝色" if model_key == "qwen" else "青绿色"
+ descriptions = [message, f"医生为深灰蓝;{MODEL_NAMES[model_key]}为{color_name}。各单位与基准组独立标尺,组间长度不可比较。缺失值为—,不按零计算。"]
+ for row in rows:
+ descriptions.append(row.description(MODEL_NAMES[model_key]))
+ if bars_enabled and row.scale is None:
+ descriptions.append(row.incompatibility)
+ if any(dose is not None and _text(dose.raw) and dose.number is None for dose in (row.doctor, row.candidate)):
+ descriptions.append("非数值、负数及非有限剂量保留原值,未绘制条形。")
+ self.setAccessibleDescription("\n".join(descriptions))
+ height = 16 + sum(self.GROUP_HEIGHT + len(members) * self.ROW_HEIGHT for _, members, _ in self.groups)
+ self.setMinimumHeight(max(120, height))
+ self.updateGeometry()
+ self.update()
+
+ def sizeHint(self) -> QSize:
+ return QSize(440, self.minimumHeight())
+
+ def minimumSizeHint(self) -> QSize:
+ return QSize(0, 0)
+
+ def paintEvent(self, event: QPaintEvent) -> None:
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ painter.fillRect(self.rect(), QColor("#FFFFFF"))
+ painter.setFont(self.font())
+ if not self.rows:
+ painter.setPen(QColor(MUTED))
+ painter.drawText(self.rect().adjusted(24, 12, -24, -12), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, self.message)
+ return
+ width, y = max(0, self.width() - 32), 8
+ normal = QFont(self.font())
+ bold = QFont(normal)
+ bold.setBold(True)
+ for scale, members, maximum in self.groups:
+ painter.fillRect(QRectF(16, y, width, self.GROUP_HEIGHT - 8), QColor("#F3F6F9"))
+ painter.setFont(bold)
+ painter.setPen(QColor(INK))
+ heading = f"{scale[0]} · {scale[1]} 独立标尺 0–{maximum}" if scale else "原始剂量 · 不作条形比较"
+ painter.drawText(QRectF(24, y, max(0, width - 16), self.GROUP_HEIGHT - 8), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(heading, Qt.TextElideMode.ElideRight, max(0, width - 16)))
+ y += self.GROUP_HEIGHT
+ for row in members:
+ if y + self.ROW_HEIGHT >= event.rect().top() and y <= event.rect().bottom():
+ painter.setFont(bold)
+ painter.setPen(QColor(INK))
+ title = f"{row.name} · {row.context}"
+ painter.drawText(QRectF(16, y, width, 21), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(title, Qt.TextElideMode.ElideRight, width))
+ painter.setFont(normal)
+ if scale is None:
+ painter.setPen(QColor(MUTED))
+ detail = ";".join(("医生 " + (row.doctor.label if row.doctor else "—"), MODEL_NAMES[self.model_key] + " " + (row.candidate.label if row.candidate else "—")))
+ painter.drawText(QRectF(16, y + 24, width, 22), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(detail, Qt.TextElideMode.ElideRight, width))
+ note = row.incompatibility if self.bars_enabled else "按已保存原值列示,详见左侧药材表"
+ painter.drawText(QRectF(16, y + 49, width, 22), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(note, Qt.TextElideMode.ElideRight, width))
+ else:
+ for index, (dose, color, name) in enumerate(((row.doctor, DOCTOR_COLOR, "医生"), (row.candidate, MODEL_COLORS[self.model_key], MODEL_NAMES[self.model_key]))):
+ bar_y = y + 24 + index * 20
+ painter.setPen(QColor(color))
+ painter.drawText(QRectF(16, bar_y - 5, 54, 22), Qt.AlignmentFlag.AlignVCenter, name)
+ value = dose.label if dose else "—"
+ label_width = min(max(118, painter.fontMetrics().horizontalAdvance(value) + 8), max(118, width // 2))
+ bar_x, bar_width = 76, max(8, width - 66 - label_width - 12)
+ if dose is not None and dose.number is not None:
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor("#F0F3F7"))
+ painter.drawRoundedRect(QRectF(bar_x, bar_y, bar_width, 9), 3, 3)
+ fraction = float(dose.number / maximum) if maximum else 0.0
+ if fraction > 0:
+ painter.setBrush(QColor(color))
+ painter.drawRoundedRect(QRectF(bar_x, bar_y, bar_width * fraction, 9), 3, 3)
+ painter.setPen(QColor(INK))
+ painter.drawText(QRectF(bar_x + bar_width + 10, bar_y - 5, label_width, 22), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(value, Qt.TextElideMode.ElideRight, int(label_width)))
+ painter.setPen(QColor(LINE))
+ painter.drawLine(16, y + self.ROW_HEIGHT - 6, self.width() - 16, y + self.ROW_HEIGHT - 6)
+ y += self.ROW_HEIGHT
+
+
+class PrescriptionComparisonPanel(QWidget):
+ """Switch between saved candidate prescriptions without issuing any requests."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("PrescriptionComparisonPanel")
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+ self._batch: dict[str, Any] = {}
+ self._rows: list[_Row] = []
+ self._model_key = "qwen"
+ self._bars_enabled = False
+ self._chart_message = "暂无药方数据"
+ self._candidate_count: int | None = None
+ self._updating_rows = False
+ self.setStyleSheet(f"""
+ QWidget#PrescriptionComparisonPanel {{ background: transparent; }}
+ QFrame#PrescriptionPane {{ background: white; border: 1px solid {LINE}; border-radius: 9px; }}
+ QLabel {{ color: {INK}; background: transparent; }}
+ QLabel#PrescriptionTitle {{ font-size: 20px; font-weight: 700; }}
+ QLabel#PrescriptionCaption {{ color: {MUTED}; font-size: 12px; }}
+ QLabel#PrescriptionStatus {{ color: #5F7287; font-size: 12px; }}
+ QPushButton#PrescriptionModel {{ border: 1px solid #DAE4EE; background: white; color: #607388;
+ border-radius: 6px; padding: 6px 19px; font-weight: 600; }}
+ QPushButton#PrescriptionModel[model="qwen"]:checked {{ background: #EDF4FE; color: #3676C8; border-color: #AAC7EB; }}
+ QPushButton#PrescriptionModel[model="openai"]:checked {{ background: #EDF7F4; color: #268578; border-color: #A5D2C6; }}
+ QLineEdit#PrescriptionSearch {{ background: white; border: 1px solid #DAE4EE; border-radius: 6px; padding: 6px 10px; }}
+ QTableWidget#PrescriptionHerbs {{ background: white; border: 0; color: {INK}; gridline-color: {LINE}; }}
+ QTableWidget#PrescriptionHerbs::item {{ padding: 4px 7px; border-bottom: 1px solid #EDF1F5; }}
+ QTableWidget#PrescriptionHerbs::item:selected {{ background: #EDF4FC; color: {INK}; }}
+ QHeaderView::section {{ background: #F4F7FA; color: #687C91; border: 0; padding: 7px; font-weight: 600; }}
+ QScrollArea {{ background: white; border: 0; }}
+ """)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(10, 8, 10, 8)
+ layout.setSpacing(10)
+ toolbar = QHBoxLayout()
+ toolbar.setSpacing(7)
+ self.model_buttons: dict[str, QPushButton] = {}
+ self.model_group = QButtonGroup(self)
+ for model_key, name in MODEL_NAMES.items():
+ button = QPushButton(name, self)
+ button.setObjectName("PrescriptionModel")
+ button.setProperty("model", model_key)
+ button.setCheckable(True)
+ button.setChecked(model_key == self._model_key)
+ button.setAccessibleName(f"查看{name}候选方与医生方对比")
+ button.clicked.connect(lambda _checked=False, key=model_key: self._select_model(key))
+ self.model_group.addButton(button)
+ self.model_buttons[model_key] = button
+ toolbar.addWidget(button)
+ toolbar.addStretch(1)
+ self.search = QLineEdit(self)
+ self.search.setObjectName("PrescriptionSearch")
+ self.search.setPlaceholderText("搜索药名")
+ self.search.setAccessibleName("搜索药名,同时筛选药材表和图表")
+ self.search.setClearButtonEnabled(True)
+ self.search.setMaximumWidth(240)
+ self.search.textChanged.connect(self._filter_rows)
+ toolbar.addWidget(self.search)
+ layout.addLayout(toolbar)
+ panes = QHBoxLayout()
+ panes.setSpacing(12)
+ left = QFrame(self)
+ left.setObjectName("PrescriptionPane")
+ left.setMinimumWidth(0)
+ left.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
+ left_layout = QVBoxLayout(left)
+ left_layout.setContentsMargins(15, 13, 15, 10)
+ left_layout.setSpacing(6)
+ self.caption_label = self._label("千问 · 候选药方", left, "PrescriptionCaption")
+ self.name_label = self._label("暂无候选药方", left, "PrescriptionTitle")
+ self.name_label.setWordWrap(True)
+ self.name_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
+ self.usage_label = self._label("", left, "PrescriptionCaption")
+ self.usage_label.setWordWrap(True)
+ left_layout.addWidget(self.caption_label)
+ left_layout.addWidget(self.name_label)
+ left_layout.addWidget(self.usage_label)
+ self.herb_table = QTableWidget(0, 3, left)
+ self.herb_table.setObjectName("PrescriptionHerbs")
+ self.herb_table.setAccessibleName("候选药方药材与医生剂量对照表")
+ self.herb_table.setHorizontalHeaderLabels(["药材 / 主辅方", "医生剂量", "千问剂量"])
+ self.herb_table.verticalHeader().hide()
+ self.herb_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
+ self.herb_table.horizontalHeader().setMinimumSectionSize(36)
+ self.herb_table.setShowGrid(False)
+ self.herb_table.setWordWrap(True)
+ self.herb_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
+ self.herb_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+ self.herb_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
+ self.herb_table.itemSelectionChanged.connect(self._focus_chart_row)
+ self.herb_table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
+ self.herb_table.setMinimumSize(0, 0)
+ self.herb_table.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
+ left_layout.addWidget(self.herb_table, 1)
+ self.empty_label = self._label("选择报告后显示已保存的候选药方", left, "PrescriptionCaption")
+ self.empty_label.setWordWrap(True)
+ left_layout.addWidget(self.empty_label)
+ self.count_label = self._label("", left, "PrescriptionCaption")
+ self.count_label.setWordWrap(True)
+ left_layout.addWidget(self.count_label)
+ panes.addWidget(left, 5)
+ right = QFrame(self)
+ right.setObjectName("PrescriptionPane")
+ right.setMinimumWidth(0)
+ right.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
+ right_layout = QVBoxLayout(right)
+ right_layout.setContentsMargins(15, 13, 15, 10)
+ right_layout.setSpacing(6)
+ title = self._label("逐味剂量对比", right)
+ title.setStyleSheet("font-size: 15px; font-weight: 700;")
+ legend_row = QHBoxLayout()
+ legend_row.addWidget(title)
+ legend_row.addStretch()
+ self.legend = self._label("● 医生 ● 千问", right, "PrescriptionCaption")
+ self.legend.setTextFormat(Qt.TextFormat.RichText)
+ legend_row.addWidget(self.legend)
+ right_layout.addLayout(legend_row)
+ self.status_label = self._label("", right, "PrescriptionStatus")
+ self.status_label.setWordWrap(True)
+ self.status_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
+ right_layout.addWidget(self.status_label)
+ self.chart_scroll = QScrollArea(right)
+ self.chart_scroll.setWidgetResizable(True)
+ self.chart_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.chart_scroll.setMinimumSize(0, 0)
+ self.chart_scroll.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
+ self.chart_scroll.setAccessibleName("可滚动查看全部药味的剂量图")
+ self.chart = _DoseChart(self.chart_scroll)
+ self.chart_scroll.setWidget(self.chart)
+ right_layout.addWidget(self.chart_scroll, 1)
+ footnote = self._label("同组同标尺,组间勿比较;剂量差异不代表医疗优劣。", right, "PrescriptionCaption")
+ footnote.setWordWrap(True)
+ right_layout.addWidget(footnote)
+ panes.addWidget(right, 5)
+ layout.addLayout(panes, 1)
+ self._render()
+
+ @staticmethod
+ def _label(text: str, parent: QWidget, name: str = "") -> QLabel:
+ label = QLabel(text, parent)
+ label.setTextFormat(Qt.TextFormat.PlainText)
+ label.setObjectName(name)
+ return label
+
+ @property
+ def selected_model(self) -> str:
+ return self._model_key
+
+ def minimumSizeHint(self) -> QSize:
+ return QSize(0, 0)
+
+ def set_batch(self, batch: dict) -> None:
+ data = _mapping(batch)
+ if data == self._batch:
+ return
+ self._batch = deepcopy(data)
+ self._render()
+
+ def _select_model(self, model_key: str) -> None:
+ if model_key == self._model_key:
+ return
+ self._model_key = model_key
+ self._render()
+
+ def _state(self, model: dict[str, Any], candidate: dict[str, Any], comparison: dict[str, Any]) -> tuple[bool, str]:
+ if not self._batch:
+ return False, "选择一份报告后,查看已保存的候选药方与剂量对比。"
+ validity = _text(self._batch.get("validity"))
+ if validity and validity not in {"current", "valid"}:
+ return False, (_STALE.get(validity) or "报告有效性未确认") + ";仅查看历史原值,暂停条形比较。"
+ status = _text(model.get("status"))
+ candidate_status = _text(candidate.get("status"))
+ if status in ACTIVE_STATES or candidate_status in ACTIVE_STATES:
+ return False, f"{MODEL_NAMES[self._model_key]}正在生成候选方,完成后显示剂量对比。"
+ if status in _STALE:
+ return False, _STALE[status] + ";仅查看历史原值。"
+ if status in {"failed", "cancelled", "canceled", "blocked"}:
+ return False, "候选方分析未完成;已保存内容仅供查看。"
+ if candidate_status in {"insufficient_data", "withheld_for_risk", "no_medication", "no_medication_recommended"}:
+ headline = {"insufficient_data": "资料不足,暂未提供候选药方", "withheld_for_risk": "因风险暂缓候选用药", "no_medication": "建议暂不使用药物", "no_medication_recommended": "建议暂不使用药物"}[candidate_status]
+ return False, headline + (";" + _reason(candidate.get("reason")) if candidate.get("reason") else "。")
+ if status and status not in SUCCESS_STATES | {"partial"}:
+ return False, "模型状态未确认;仅列示已保存原值。"
+ if candidate_status and candidate_status not in {"available_for_review", "success", "succeeded", "completed"}:
+ return False, "候选方状态未确认;仅列示已保存原值。"
+ if comparison.get("status") == "not_comparable":
+ return False, "本报告不可比:" + (_reason(comparison.get("reason") or comparison.get("reason_code")) or "未通过单位、剂量或资料完整性核验。")
+ saved_rows = comparison.get("rows")
+ if not isinstance(saved_rows, list) or not any(isinstance(row, Mapping) for row in saved_rows):
+ return False, "尚无已保存的逐味对比;医生剂量以—表示,不推算历史处方。"
+ if comparison.get("status") != "comparable":
+ return False, "可比状态未确认;仅列示已保存原值。"
+ prefix = "报告有效性未注明;" if not validity else ""
+ return True, prefix + "按明确单位与每剂/每日基准分组;缺失剂量不按零计算。"
+
+ def _render(self) -> None:
+ model = _mapping(_mapping(self._batch.get("models")).get(self._model_key))
+ candidate, comparison = _mapping(model.get("candidate")), _mapping(model.get("comparison"))
+ self._rows = _saved_rows(candidate, comparison)
+ self._bars_enabled, self._chart_message = self._state(model, candidate, comparison)
+ name = MODEL_NAMES[self._model_key]
+ self.caption_label.setText(f"{name} · 候选药方")
+ candidate_herbs = candidate.get("herbs")
+ self._candidate_count = len(candidate_herbs) if isinstance(candidate_herbs, list) else None
+ has_herbs = isinstance(candidate_herbs, list) and bool(candidate_herbs)
+ if any(row.origin == "original" for row in self._rows):
+ self._chart_message += " 候选原方另行附列;历史对应关系未保存,不推断同药。"
+ elif comparison.get("rows") and any(row.origin == "uncompared" for row in self._rows):
+ self._chart_message += " 未纳入对比的候选药材已补列原值。"
+ empty_title = "候选方生成中" if model.get("status") in ACTIVE_STATES else "暂无候选药方"
+ self.name_label.setText(_text(candidate.get("prescription_name")) or ("已保存候选方" if has_herbs else empty_title))
+ self.name_label.setToolTip(self.name_label.text())
+ usage = []
+ if _text(candidate.get("usage_instruction")):
+ usage.append(_text(candidate["usage_instruction"]))
+ if _number(candidate.get("times_per_day")) is not None:
+ usage.append(f"每日 {candidate['times_per_day']} 次")
+ if _number(candidate.get("usage_days")) is not None:
+ usage.append(f"共 {candidate['usage_days']} 天")
+ usage_text = " · ".join(usage)
+ self.usage_label.setText(usage_text[:100] + ("…" if len(usage_text) > 100 else ""))
+ self.usage_label.setToolTip(usage_text)
+ self.usage_label.setVisible(bool(usage_text))
+ self.status_label.setText(self._chart_message)
+ self.legend.setText(f'● 医生 ● {name}')
+ self.herb_table.setHorizontalHeaderLabels(["药材 / 主辅方", "医生剂量", f"{name}剂量"])
+ self._filter_rows()
+
+ def _filter_rows(self) -> None:
+ self._updating_rows = True
+ query = self.search.text().strip().casefold()
+ rows = [row for row in self._rows if any(query in name.casefold() for name in (row.name, *row.source_names))]
+ table_scroll = self.herb_table.verticalScrollBar().value()
+ chart_scroll = self.chart_scroll.verticalScrollBar().value()
+ selected_row = self.herb_table.currentRow()
+ selected_name = self.herb_table.item(selected_row, 0).text() if selected_row >= 0 and self.herb_table.item(selected_row, 0) else ""
+ self.herb_table.setRowCount(len(rows))
+ for index, row in enumerate(rows):
+ name_text = row.name + "\n" + row.context
+ instructions = row.usage_description(MODEL_NAMES[self._model_key])
+ if instructions:
+ name_text += "\n" + instructions
+ values = (name_text, row.doctor.label if row.doctor else "—", row.candidate.label if row.candidate else "—")
+ for column, value in enumerate(values):
+ item = QTableWidgetItem(value)
+ item.setToolTip(row.description(MODEL_NAMES[self._model_key]))
+ item.setData(Qt.ItemDataRole.AccessibleTextRole, value)
+ if column == 0:
+ item.setData(Qt.ItemDataRole.UserRole, row)
+ item.setData(Qt.ItemDataRole.AccessibleDescriptionRole, row.description(MODEL_NAMES[self._model_key]))
+ item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
+ if column == 1:
+ item.setForeground(QColor(DOCTOR_COLOR))
+ if column == 2:
+ item.setForeground(QColor(MODEL_COLORS[self._model_key]))
+ self.herb_table.setItem(index, column, item)
+ self.herb_table.setRowHeight(index, max(48, self.herb_table.fontMetrics().height() * (3 if instructions else 2) + 12))
+ if name_text == selected_name:
+ self.herb_table.selectRow(index)
+ self.herb_table.verticalScrollBar().setValue(table_scroll)
+ self.herb_table.setVisible(bool(rows))
+ self.empty_label.setText("没有匹配的药名,请调整搜索。" if self._rows and not rows else self._chart_message)
+ self.empty_label.setVisible(not rows)
+ comparison_count = sum(row.origin == "comparison" for row in self._rows)
+ uncompared_count = sum(row.origin == "uncompared" for row in self._rows)
+ original_count = sum(row.origin == "original" for row in self._rows)
+ counts = [f"候选原方 {self._candidate_count} 项" if self._candidate_count is not None else "候选原方未保存", f"对比 {comparison_count} 项"]
+ if uncompared_count:
+ counts.append(f"未纳入 {uncompared_count} 项")
+ if original_count:
+ counts.append(f"原方附列 {original_count} 项")
+ if query:
+ counts.append(f"搜索显示 {len(rows)} 项")
+ self.count_label.setText(" · ".join(counts))
+ self.count_label.setToolTip("原方项数来自候选药材完整清单;对比项数来自已保存的标准化记录。附列药材不推断与对比记录的对应关系;— 表示该侧未保存剂量。")
+ self.count_label.setVisible(bool(self._rows))
+ empty_message = "没有匹配的药名" if self._rows and not rows else self._chart_message
+ self.chart.set_rows(rows, self._model_key, bars_enabled=self._bars_enabled, message=empty_message)
+ self.chart_scroll.verticalScrollBar().setValue(chart_scroll)
+ self._updating_rows = False
+
+ def _focus_chart_row(self) -> None:
+ if self._updating_rows:
+ return
+ item = self.herb_table.item(self.herb_table.currentRow(), 0)
+ if item is None:
+ return
+ target = item.data(Qt.ItemDataRole.UserRole)
+ y = 8
+ for _scale, members, _maximum in self.chart.groups:
+ y += self.chart.GROUP_HEIGHT
+ for row in members:
+ if row is target:
+ self.chart_scroll.verticalScrollBar().setValue(y)
+ return
+ y += self.chart.ROW_HEIGHT
diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_console.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_console.py
new file mode 100644
index 000000000..4c99c2f5b
--- /dev/null
+++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_console.py
@@ -0,0 +1,558 @@
+"""The console chrome: the agreement comparison bar and the numbered step rail.
+
+Both widgets read only what the saved batch carries. A model without a comparable candidate keeps
+an empty track instead of a zero-length bar, and the rail's counters stay blank until the batch
+actually reports them.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
+from PySide6.QtGui import (
+ QBrush,
+ QColor,
+ QFont,
+ QFontMetricsF,
+ QLinearGradient,
+ QPainter,
+ QPainterPath,
+ QPaintEvent,
+ QPalette,
+)
+from PySide6.QtWidgets import (
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QProgressBar,
+ QPushButton,
+ QSizePolicy,
+ QVBoxLayout,
+ QWidget,
+)
+
+from .issued_prescription_ai_theme import CONSOLE, MODEL_HUE, MODEL_TEXT, num_font
+
+MODEL_NAMES = {"qwen": "千问", "openai": "OpenAI"}
+
+
+def _number(value: Any) -> float | None:
+ if value is None or isinstance(value, bool):
+ return None
+ try:
+ parsed = float(value)
+ except (TypeError, ValueError):
+ return None
+ return parsed
+
+
+def _mapping(value: Any) -> dict[str, Any]:
+ return dict(value) if isinstance(value, Mapping) else {}
+
+
+def _score(model: Mapping[str, Any]) -> float | None:
+ comparison = _mapping(_mapping(model).get("comparison"))
+ status = comparison.get("status") or _mapping(model).get("comparison_status")
+ if status != "comparable":
+ return None
+ value = _number(comparison.get("score", _mapping(model).get("score")))
+ return None if value is None or value < 0 or value > 100 else value
+
+
+class ScaleTrack(QWidget):
+ """A 0–100 track: the model's own fill, and a grey mark where the other model stands."""
+
+ # The design's scale: a 12px band for the rival's label, a 13px rail, then the axis row.
+ LABEL_BAND = 13.0
+ RAIL_TOP = 16.0
+ RAIL_HEIGHT = 13.0
+ AXIS_TOP = 31.0
+
+ def __init__(self, model_key: str, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.model_key = model_key
+ self._value: float | None = None
+ self._other: float | None = None
+ self._other_label = ""
+ self.setFixedHeight(44)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+
+ def set_values(self, value: float | None, other: float | None, other_label: str) -> None:
+ self._value, self._other, self._other_label = value, other, other_label
+ self.setAccessibleDescription("暂无可比结果" if value is None else f"{value:.1f}%")
+ self.setToolTip("" if other is None else f"{other_label} 在 {other:.1f}%")
+ self.update()
+
+ def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ top, height = self.RAIL_TOP, self.RAIL_HEIGHT
+ radius = height / 2
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor(CONSOLE["raised"]))
+ painter.drawRoundedRect(QRectF(0, top, self.width(), height), radius, radius)
+ if self._value:
+ filled = self.width() * self._value / 100
+ hue = QColor(MODEL_HUE[self.model_key])
+ faded = QColor(hue)
+ faded.setAlphaF(0.45)
+ gradient = QLinearGradient(QPointF(0, top), QPointF(filled, top))
+ gradient.setColorAt(0.0, faded)
+ gradient.setColorAt(1.0, hue)
+ painter.setBrush(QBrush(gradient))
+ painter.drawRoundedRect(QRectF(0, top, filled, height), radius, radius)
+ # Quarter dividers sit on the rail itself, as the design draws them.
+ painter.setPen(QColor(CONSOLE["grid_line"]))
+ for fraction in (0.25, 0.5, 0.75):
+ x = self.width() * fraction
+ painter.drawLine(QPointF(x, top), QPointF(x, top + height))
+ axis = QFont(self.font())
+ axis.setPixelSize(9)
+ painter.setFont(axis)
+ painter.setPen(QColor(CONSOLE["faint"]))
+ for fraction in (0.0, 0.25, 0.5, 0.75, 1.0):
+ label = f"{int(fraction * 100)}%"
+ width = 44.0
+ left = self.width() * fraction - width / 2
+ align = Qt.AlignmentFlag.AlignCenter
+ if fraction == 0.0:
+ left, align = 0.0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
+ elif fraction == 1.0:
+ left, align = self.width() - width, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
+ painter.drawText(QRectF(left, self.AXIS_TOP, width, 12), align, label)
+ if self._other is not None:
+ x = self.width() * self._other / 100
+ marker = QColor(CONSOLE["muted"])
+ marker.setAlphaF(0.8)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(marker)
+ painter.drawRoundedRect(QRectF(x - 1, top - 4, 2, height + 8), 1, 1)
+ painter.setPen(QColor(CONSOLE["faint"]))
+ painter.drawText(QRectF(max(0.0, min(x - 60, self.width() - 120)), 0, 120, self.LABEL_BAND),
+ Qt.AlignmentFlag.AlignCenter, f"{self._other_label} 在此")
+ painter.end()
+
+
+class ScoreLabel(QLabel):
+ """The big figure with its unit set small and raised, the way the design prints it.
+
+ ``text()`` still returns the whole string, so callers and tests read one plain value.
+ """
+
+ SIZE = 27
+ UNIT_SIZE = 13
+
+ def _parts(self) -> tuple[str, str]:
+ text = self.text()
+ for unit in ("%", "pt"):
+ if text.endswith(unit) and len(text) > len(unit):
+ return text[: -len(unit)], unit
+ return text, ""
+
+ def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
+ figure, unit = self._parts()
+ width = QFontMetricsF(num_font(self.SIZE, weight=QFont.Weight.DemiBold)).horizontalAdvance(figure)
+ if unit:
+ width += 2 + QFontMetricsF(num_font(self.UNIT_SIZE)).horizontalAdvance(unit)
+ return QSize(int(width) + 1, int(self.SIZE * 1.1) + 1)
+
+ def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual
+ return self.sizeHint()
+
+ def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
+ figure, unit = self._parts()
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ figure_font = num_font(self.SIZE, weight=QFont.Weight.DemiBold)
+ painter.setFont(figure_font)
+ painter.setPen(self.palette().color(QPalette.ColorRole.WindowText))
+ metrics = QFontMetricsF(figure_font)
+ baseline = (self.height() + metrics.capHeight()) / 2
+ painter.drawText(QPointF(0, baseline), figure)
+ if unit:
+ unit_font = num_font(self.UNIT_SIZE)
+ painter.setFont(unit_font)
+ painter.setPen(QColor(CONSOLE["muted"]))
+ painter.drawText(QPointF(metrics.horizontalAdvance(figure) + 2,
+ baseline - metrics.capHeight() + QFontMetricsF(unit_font).capHeight()),
+ unit)
+ painter.end()
+
+
+class AgreementBar(QFrame):
+ """Both models' agreement on one scale, with the gap between them stated in points."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("AiAgreement")
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(22, 16, 22, 16)
+ layout.setSpacing(24)
+ self.blocks: dict[str, dict[str, Any]] = {}
+ for index, key in enumerate(("qwen", "openai")):
+ if index:
+ divider = QFrame(self)
+ divider.setObjectName("AiFactDivider")
+ divider.setFixedWidth(1)
+ layout.addWidget(divider)
+ layout.addWidget(self._delta_block())
+ divider = QFrame(self)
+ divider.setObjectName("AiFactDivider")
+ divider.setFixedWidth(1)
+ layout.addWidget(divider)
+ # Both heads line up at the top, so a model that failed does not slide its column down.
+ layout.addWidget(self._model_block(key), 1, Qt.AlignmentFlag.AlignTop)
+
+ def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
+ """The design runs a blue-to-violet bar down the card's left edge, inside its rounding."""
+
+ super().paintEvent(event)
+ card = QPainterPath()
+ card.addRoundedRect(QRectF(1, 1, self.width() - 2, self.height() - 2), 9, 9)
+ strip = QPainterPath()
+ strip.addRect(QRectF(0, 0, 4, self.height()))
+ gradient = QLinearGradient(QPointF(0, 0), QPointF(0, self.height()))
+ gradient.setColorAt(0.0, QColor(CONSOLE["accent"]))
+ gradient.setColorAt(1.0, QColor(CONSOLE["openai"]))
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.fillPath(card.intersected(strip), QBrush(gradient))
+ painter.end()
+
+ def _model_block(self, key: str) -> QWidget:
+ holder = QWidget(self)
+ column = QVBoxLayout(holder)
+ column.setContentsMargins(0, 0, 0, 0)
+ column.setSpacing(9)
+ head = QHBoxLayout()
+ head.setSpacing(9)
+ dot = QLabel(holder)
+ dot.setFixedSize(9, 9)
+ dot.setStyleSheet(f"background: {MODEL_HUE[key]}; border-radius: 3px;")
+ head.addWidget(dot)
+ name = QLabel(MODEL_NAMES[key], holder)
+ name.setStyleSheet(f"color: {CONSOLE['heading']}; font-size: 13.8px; font-weight: 600;")
+ head.addWidget(name)
+ status = QLabel("尚无报告", holder)
+ status.setTextFormat(Qt.TextFormat.PlainText)
+ head.addWidget(status)
+ coverage = QLabel("", holder)
+ coverage.setTextFormat(Qt.TextFormat.PlainText)
+ coverage.setStyleSheet(
+ f"background: {CONSOLE['amber_dim']}; color: {CONSOLE['amber_text']};"
+ f" border: 1px solid {CONSOLE['amber']}; border-radius: 9px; padding: 1px 8px; font-size: 9.9px;")
+ head.addWidget(coverage)
+ elapsed = QLabel("", holder)
+ elapsed.setTextFormat(Qt.TextFormat.PlainText)
+ elapsed.setStyleSheet(
+ f"background: transparent; color: {CONSOLE['faint']};"
+ f" border: 1px solid {CONSOLE['line']}; border-radius: 9px; padding: 1px 8px; font-size: 9.9px;")
+ head.addWidget(elapsed)
+ head.addStretch(1)
+ column.addLayout(head)
+ score = ScoreLabel("—", holder)
+ score.setObjectName(f"AiAgreementScore{key.capitalize()}")
+ score.setTextFormat(Qt.TextFormat.PlainText)
+ column.addWidget(score)
+ caption = QLabel("药味与剂量一致率", holder)
+ caption.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.5px;")
+ column.addWidget(caption)
+ track = ScaleTrack(key, holder)
+ column.addWidget(track)
+
+ # A failed model states why it stopped and offers its retry on its own block, which is
+ # the only place in the console that belongs to that model alone.
+ failure = QWidget(holder)
+ failure_row = QHBoxLayout(failure)
+ failure_row.setContentsMargins(0, 2, 0, 0)
+ failure_row.setSpacing(8)
+ failure_text = QLabel("", failure)
+ failure_text.setTextFormat(Qt.TextFormat.PlainText)
+ failure_text.setWordWrap(True)
+ failure_text.setStyleSheet(f"color: {CONSOLE['rose_text']}; font-size: 12px;")
+ failure_row.addWidget(failure_text, 1)
+ retry_slot = QHBoxLayout()
+ retry_slot.setContentsMargins(0, 0, 0, 0)
+ retry_slot.setSpacing(6)
+ failure_row.addLayout(retry_slot)
+ failure.setVisible(False)
+ column.addWidget(failure)
+
+ stage = QLabel("等待处理进度", holder)
+ stage.setTextFormat(Qt.TextFormat.PlainText)
+ stage.setWordWrap(True)
+ stage.setStyleSheet(f"color: {MODEL_TEXT[key]}; font-size: 12px;")
+ stage.hide()
+ # The plain bar stays as a value carrier for callers and accessibility tools; the painted
+ # track above is the visual, so it is never added to the layout.
+ carrier = QProgressBar(holder)
+ carrier.setRange(0, 1000)
+ carrier.setTextVisible(False)
+ carrier.setFixedHeight(4)
+ carrier.setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
+ carrier.setVisible(False)
+ self.blocks[key] = {"score": score, "coverage": coverage, "elapsed": elapsed, "track": track,
+ "status": status, "failure": failure, "failure_text": failure_text,
+ "retry_slot": retry_slot, "stage": stage, "carrier": carrier}
+ return holder
+
+ def views(self, key: str) -> dict[str, Any]:
+ """Widget map kept stable for the dialog and its regression tests."""
+
+ block = self.blocks[key]
+ return {"card": self, "model_label": block["score"], "status_chip": block["status"],
+ "coverage_chip": block["coverage"], "score": block["score"], "elapsed": block["elapsed"],
+ "agreement_bar": block["carrier"], "gauge": block["track"], "stage": block["stage"],
+ "failure": block["failure"], "failure_text": block["failure_text"]}
+
+ def attach_action(self, key: str, button: QWidget) -> None:
+ """Host a dialog-owned action (retry) inside that model's failure row."""
+
+ self.blocks[key]["retry_slot"].addWidget(button)
+
+ def set_failure(self, key: str, message: str, retryable: bool) -> None:
+ block = self.blocks[key]
+ block["failure_text"].setText(message)
+ block["failure"].setVisible(bool(message))
+ slot = block["retry_slot"]
+ for index in range(slot.count()):
+ widget = slot.itemAt(index).widget()
+ if widget is not None:
+ widget.setVisible(retryable)
+
+ def _delta_block(self) -> QWidget:
+ holder = QWidget(self)
+ holder.setFixedWidth(180)
+ column = QVBoxLayout(holder)
+ column.setContentsMargins(0, 6, 0, 0)
+ column.setSpacing(2)
+ column.addStretch(1)
+ self.delta = QLabel("—", holder)
+ self.delta.setTextFormat(Qt.TextFormat.PlainText)
+ self.delta.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.delta.setFont(num_font(20, weight=QFont.Weight.DemiBold))
+ self.delta.setStyleSheet(f"color: {CONSOLE['accent_text']}; font-size: 19.5px; font-weight: 600;")
+ column.addWidget(self.delta)
+ self.delta_note = QLabel("等待两个模型", holder)
+ self.delta_note.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.delta_note.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.2px;")
+ column.addWidget(self.delta_note)
+ self.overlap = QLabel("", holder)
+ self.overlap.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.overlap.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.2px;")
+ column.addWidget(self.overlap)
+ column.addStretch(1)
+ return holder
+
+ def apply(self, batch: Mapping[str, Any] | None, *, coverage: Mapping[str, str] | None = None,
+ elapsed: Mapping[str, str] | None = None) -> None:
+ data = _mapping(batch)
+ models = _mapping(data.get("models"))
+ scores = {key: _score(_mapping(models.get(key))) for key in ("qwen", "openai")}
+ for key in ("qwen", "openai"):
+ block = self.blocks[key]
+ value = scores[key]
+ block["score"].setText("—" if value is None else f"{value:.1f}%")
+ other = scores["openai" if key == "qwen" else "qwen"]
+ block["track"].set_values(value, other, MODEL_NAMES["openai" if key == "qwen" else "qwen"])
+ text = _mapping(coverage).get(key, "")
+ block["coverage"].setText(text)
+ block["coverage"].setVisible(bool(text))
+ spent = _mapping(elapsed).get(key, "")
+ block["elapsed"].setText(spent)
+ block["elapsed"].setVisible(bool(spent))
+ if scores["qwen"] is None or scores["openai"] is None:
+ self.delta.setText("—")
+ self.delta_note.setText("两个模型都可比后才给差值")
+ self.overlap.setText("")
+ return
+ gap = scores["qwen"] - scores["openai"]
+ leader = MODEL_NAMES["qwen"] if gap >= 0 else MODEL_NAMES["openai"]
+ self.delta.setText(f"{'+' if gap >= 0 else '−'}{abs(gap):.1f}pt")
+ self.delta_note.setText(f"{leader}领先" if gap else "两模型持平")
+ overlaps = []
+ for key in ("qwen", "openai"):
+ herb = _number(_mapping(_mapping(models.get(key)).get("comparison")).get("herb_score"))
+ overlaps.append("—" if herb is None else f"{herb:.1f}%")
+ self.overlap.setText("药味重合 " + " / ".join(overlaps))
+
+
+class _StepButton(QPushButton):
+ """A step row; the design marks the selected one with a rounded bar down its left edge."""
+
+ BAR_INSET = 9
+ BAR_WIDTH = 3
+
+ def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
+ super().paintEvent(event)
+ if not self.isChecked():
+ return
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor(CONSOLE["accent"]))
+ painter.drawRoundedRect(
+ QRectF(0, self.BAR_INSET, self.BAR_WIDTH, self.height() - self.BAR_INSET * 2), 1.5, 1.5)
+ painter.end()
+
+
+class StepRail(QFrame):
+ """The six destinations as numbered steps, with what this batch needs looked at below them."""
+
+ selected = Signal(str)
+ save_requested = Signal()
+
+ FOCUS_ROWS = (("differences", "剂量差异", "项", "amber_text"),
+ ("critical", "关键缺口", "项", "rose_text"),
+ ("restricted", "附件受限", "个", "text"),
+ ("consensus", "三方共识", "味", "accent_text"))
+
+ def __init__(self, steps: tuple[tuple[str, str], ...], parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("AiRail")
+ self.setFixedWidth(232)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(12)
+
+ nav = QFrame(self)
+ nav.setObjectName("AiPanel")
+ nav_layout = QVBoxLayout(nav)
+ nav_layout.setContentsMargins(6, 6, 6, 6)
+ nav_layout.setSpacing(0)
+ self._tones: dict[str, str] = {}
+ self.buttons: dict[str, QPushButton] = {}
+ self.badges: dict[str, QLabel] = {}
+ self.numbers: dict[str, QLabel] = {}
+ self.names: dict[str, QLabel] = {}
+ for index, (key, text) in enumerate(steps, start=1):
+ button = _StepButton(nav)
+ button.setObjectName("AiStep")
+ button.setCheckable(True)
+ button.setChecked(index == 1)
+ button.setCursor(Qt.CursorShape.PointingHandCursor)
+ button.setFixedHeight(42)
+ button.clicked.connect(lambda _checked=False, target=key: self.selected.emit(target))
+ row = QHBoxLayout(button)
+ row.setContentsMargins(11, 0, 11, 0)
+ row.setSpacing(10)
+ number = QLabel(f"{index:02d}", button)
+ number.setObjectName("AiStepNumber")
+ number.setFixedSize(22, 22)
+ number.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ number.setFont(num_font(10, weight=QFont.Weight.Bold))
+ row.addWidget(number)
+ name = QLabel(text, button)
+ name.setObjectName("AiStepName")
+ row.addWidget(name, 1)
+ badge = QLabel("", button)
+ badge.setObjectName("AiStepBadge")
+ badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ badge.setMinimumWidth(20)
+ badge.setFixedHeight(17)
+ badge.setFont(num_font(10))
+ row.addWidget(badge, 0, Qt.AlignmentFlag.AlignVCenter)
+ self.buttons[key] = button
+ self.badges[key] = badge
+ self.numbers[key] = number
+ self.names[key] = name
+ nav_layout.addWidget(button)
+ layout.addWidget(nav)
+ self.set_current(steps[0][0] if steps else "")
+
+ focus = QFrame(self)
+ focus.setObjectName("AiPanel")
+ focus_layout = QVBoxLayout(focus)
+ focus_layout.setContentsMargins(12, 13, 12, 14)
+ focus_layout.setSpacing(7)
+ caption = QLabel("本次关注", focus)
+ caption.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.2px; letter-spacing: 1.4px;"
+ " padding-left: 4px;")
+ focus_layout.addWidget(caption)
+ self.focus_values: dict[str, QLabel] = {}
+ for key, text, unit, tone in self.FOCUS_ROWS:
+ row = QFrame(focus)
+ row.setObjectName("AiFocusRow")
+ row_layout = QHBoxLayout(row)
+ row_layout.setContentsMargins(10, 8, 10, 8)
+ row_layout.setSpacing(8)
+ name = QLabel(text, row)
+ name.setStyleSheet(f"color: {CONSOLE['muted']}; font-size: 11.25px;")
+ row_layout.addWidget(name)
+ row_layout.addStretch(1)
+ value = QLabel(f"— {unit}", row)
+ value.setTextFormat(Qt.TextFormat.PlainText)
+ value.setFont(num_font(14))
+ value.setStyleSheet(f"color: {CONSOLE[tone]};")
+ row_layout.addWidget(value)
+ self.focus_values[key] = value
+ focus_layout.addWidget(row)
+ self.save_button = QPushButton("保存本次复核", focus)
+ self.save_button.setObjectName("AiPrimaryAction")
+ self.save_button.setFixedHeight(34)
+ self.save_button.clicked.connect(self.save_requested.emit)
+ focus_layout.addWidget(self.save_button)
+ layout.addWidget(focus)
+ layout.addStretch(1)
+
+ BADGE_TONES = {"hot": ("rose_dim", "rose_text"), "warn": ("amber_dim", "amber_text")}
+
+ def set_current(self, key: str) -> None:
+ """The selected step is a tinted row with a blue index chip, not a solid blue button."""
+
+ for target, button in self.buttons.items():
+ selected = target == key
+ button.setChecked(selected)
+ self.numbers[target].setStyleSheet(
+ f"background: {CONSOLE['accent'] if selected else CONSOLE['raised']};"
+ f" color: {'#F2F7FF' if selected else CONSOLE['faint']}; border-radius: 6px;")
+ self.names[target].setStyleSheet(
+ f"color: {CONSOLE['heading'] if selected else CONSOLE['muted']}; font-size: 12.6px;"
+ + (" font-weight: 600;" if selected else ""))
+ self._paint_badge(target)
+
+ def _paint_badge(self, key: str) -> None:
+ background, colour = self.BADGE_TONES.get(self._tones.get(key, ""), ("raised", "faint"))
+ self.badges[key].setStyleSheet(
+ f"background: {CONSOLE[background]}; color: {CONSOLE[colour]};"
+ " border-radius: 8px; padding: 0 6px;")
+
+ def set_badges(self, counts: Mapping[str, Any], tones: Mapping[str, str] | None = None) -> None:
+ """Counts, and the severity that decides whether one reads as rose, amber or quiet."""
+
+ self._tones = dict(tones or {})
+ for key, badge in self.badges.items():
+ value = counts.get(key)
+ badge.setText("" if value in (None, "") else str(value))
+ badge.setVisible(bool(badge.text()))
+ self._paint_badge(key)
+
+ def set_focus(self, counts: Mapping[str, Any]) -> None:
+ for key, _text, unit, _tone in self.FOCUS_ROWS:
+ value = counts.get(key)
+ self.focus_values[key].setText(f"{'—' if value is None else value} {unit}")
+
+
+def rail_qss() -> str:
+ """The rail and agreement styles for the palette that is active right now."""
+
+ return f"""
+QFrame#AiRail {{ background: transparent; border: 0; }}
+QFrame#AiFocusRow {{ background: {CONSOLE['surface_2']}; border: 0; border-radius: 7px; }}
+QPushButton#AiStep {{ background: transparent; border: 1px solid transparent; border-radius: 7px;
+ text-align: left; }}
+QPushButton#AiStep:hover {{ background: {CONSOLE['surface_2']}; }}
+QPushButton#AiStep:checked {{ background: {CONSOLE['selection']};
+ border-color: {CONSOLE['selection_line']}; }}
+QLabel#AiStepNumber {{ color: {CONSOLE['faint']}; }}
+QLabel#AiStepName {{ color: {CONSOLE['muted']}; font-size: 12.6px; }}
+QLabel#AiStepBadge {{ color: {CONSOLE['faint']}; background: {CONSOLE['raised']};
+ border-radius: 8px; padding: 0 6px; }}
+QFrame#AiAgreement {{ background: {CONSOLE['surface']}; border: 1px solid {CONSOLE['line_soft']};
+ border-radius: 10px; }}
+QLabel#AiAgreementScoreQwen {{ color: {MODEL_TEXT['qwen']}; font-size: 27px; font-weight: 600; }}
+QLabel#AiAgreementScoreOpenai {{ color: {MODEL_TEXT['openai']}; font-size: 27px; font-weight: 600; }}
+"""
diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_glyphs.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_glyphs.py
new file mode 100644
index 000000000..00597d166
--- /dev/null
+++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_glyphs.py
@@ -0,0 +1,254 @@
+"""Painted glyphs for the prescription analysis window.
+
+The window ships no image assets, so every icon in the design is drawn here with QPainter at the
+size it is used. Each glyph is line art on a transparent background; the colour is supplied by the
+caller, which keeps a glyph usable on a card, inside a tinted tile, or on the navigation bar.
+"""
+
+from __future__ import annotations
+
+from PySide6.QtCore import QPointF, QRectF, QSize, Qt
+from PySide6.QtGui import (
+ QBrush,
+ QColor,
+ QIcon,
+ QLinearGradient,
+ QPainter,
+ QPaintEvent,
+ QPen,
+ QPixmap,
+ QPolygonF,
+)
+from PySide6.QtWidgets import QSizePolicy, QWidget
+
+from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE
+
+
+def _pen(painter: QPainter, colour: str, width: float) -> QPen:
+ pen = QPen(QColor(colour))
+ pen.setWidthF(width)
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
+ pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
+ painter.setPen(pen)
+ painter.setBrush(Qt.BrushStyle.NoBrush)
+ return pen
+
+
+def paint_glyph(painter: QPainter, kind: str, box: QRectF, colour: str) -> None:
+ """Draw one glyph inside ``box``. Unknown names draw nothing rather than a placeholder."""
+
+ painter.save()
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ x, y, w, h = box.x(), box.y(), box.width(), box.height()
+ stroke = max(1.2, min(w, h) / 11)
+ if kind == "bars":
+ _pen(painter, colour, stroke)
+ for index, height in enumerate((0.45, 0.75, 0.6)):
+ left = x + w * (0.22 + index * 0.28)
+ painter.drawLine(QPointF(left, y + h * 0.82), QPointF(left, y + h * (0.82 - height)))
+ elif kind == "doc":
+ _pen(painter, colour, stroke)
+ painter.drawRoundedRect(QRectF(x + w * 0.22, y + h * 0.12, w * 0.56, h * 0.76), w * 0.08, w * 0.08)
+ for index in range(2):
+ top = y + h * (0.38 + index * 0.2)
+ painter.drawLine(QPointF(x + w * 0.34, top), QPointF(x + w * 0.66, top))
+ elif kind == "box":
+ _pen(painter, colour, stroke)
+ top, bottom, middle = y + h * 0.2, y + h * 0.8, y + h * 0.5
+ left, right = x + w * 0.16, x + w * 0.84
+ painter.drawPolygon(QPolygonF([QPointF(x + w * 0.5, top), QPointF(right, middle * 0.75 + top * 0.25),
+ QPointF(right, bottom - h * 0.12), QPointF(x + w * 0.5, bottom),
+ QPointF(left, bottom - h * 0.12), QPointF(left, middle * 0.75 + top * 0.25)]))
+ painter.drawLine(QPointF(x + w * 0.5, y + h * 0.5), QPointF(x + w * 0.5, bottom))
+ elif kind == "image":
+ _pen(painter, colour, stroke)
+ painter.drawRoundedRect(QRectF(x + w * 0.16, y + h * 0.22, w * 0.68, h * 0.56), w * 0.08, w * 0.08)
+ painter.drawPolyline(QPolygonF([QPointF(x + w * 0.24, y + h * 0.7), QPointF(x + w * 0.42, y + h * 0.48),
+ QPointF(x + w * 0.58, y + h * 0.66), QPointF(x + w * 0.68, y + h * 0.56)]))
+ painter.setBrush(QColor(colour))
+ painter.drawEllipse(QPointF(x + w * 0.64, y + h * 0.36), stroke * 0.9, stroke * 0.9)
+ elif kind == "clock":
+ _pen(painter, colour, stroke)
+ painter.drawEllipse(QRectF(x + w * 0.16, y + h * 0.16, w * 0.68, h * 0.68))
+ centre = QPointF(x + w * 0.5, y + h * 0.5)
+ painter.drawLine(centre, QPointF(x + w * 0.5, y + h * 0.3))
+ painter.drawLine(centre, QPointF(x + w * 0.66, y + h * 0.58))
+ elif kind == "trend":
+ _pen(painter, colour, stroke)
+ painter.drawPolyline(QPolygonF([QPointF(x + w * 0.18, y + h * 0.68), QPointF(x + w * 0.4, y + h * 0.46),
+ QPointF(x + w * 0.56, y + h * 0.58), QPointF(x + w * 0.82, y + h * 0.28)]))
+ painter.drawPolyline(QPolygonF([QPointF(x + w * 0.62, y + h * 0.28), QPointF(x + w * 0.82, y + h * 0.28),
+ QPointF(x + w * 0.82, y + h * 0.48)]))
+ elif kind == "bell":
+ _pen(painter, colour, stroke)
+ painter.drawPolyline(QPolygonF([
+ QPointF(x + w * 0.24, y + h * 0.68), QPointF(x + w * 0.3, y + h * 0.58),
+ QPointF(x + w * 0.3, y + h * 0.42), QPointF(x + w * 0.5, y + h * 0.2),
+ QPointF(x + w * 0.7, y + h * 0.42), QPointF(x + w * 0.7, y + h * 0.58),
+ QPointF(x + w * 0.76, y + h * 0.68), QPointF(x + w * 0.24, y + h * 0.68)]))
+ painter.drawArc(QRectF(x + w * 0.4, y + h * 0.66, w * 0.2, h * 0.18), 0, -180 * 16)
+ elif kind == "clipboard":
+ _pen(painter, colour, stroke)
+ painter.drawRoundedRect(QRectF(x + w * 0.22, y + h * 0.2, w * 0.56, h * 0.66), w * 0.08, w * 0.08)
+ painter.drawLine(QPointF(x + w * 0.36, y + h * 0.48), QPointF(x + w * 0.64, y + h * 0.48))
+ painter.drawLine(QPointF(x + w * 0.36, y + h * 0.64), QPointF(x + w * 0.56, y + h * 0.64))
+ elif kind == "pencil":
+ _pen(painter, colour, stroke)
+ painter.drawPolyline(QPolygonF([QPointF(x + w * 0.24, y + h * 0.76), QPointF(x + w * 0.28, y + h * 0.6),
+ QPointF(x + w * 0.64, y + h * 0.24), QPointF(x + w * 0.78, y + h * 0.38),
+ QPointF(x + w * 0.42, y + h * 0.74), QPointF(x + w * 0.24, y + h * 0.76)]))
+ elif kind == "bulb":
+ _pen(painter, colour, stroke)
+ painter.drawArc(QRectF(x + w * 0.28, y + h * 0.18, w * 0.44, h * 0.46), 0, 180 * 16)
+ painter.drawLine(QPointF(x + w * 0.28, y + h * 0.41), QPointF(x + w * 0.38, y + h * 0.62))
+ painter.drawLine(QPointF(x + w * 0.72, y + h * 0.41), QPointF(x + w * 0.62, y + h * 0.62))
+ painter.drawLine(QPointF(x + w * 0.38, y + h * 0.66), QPointF(x + w * 0.62, y + h * 0.66))
+ painter.drawLine(QPointF(x + w * 0.42, y + h * 0.78), QPointF(x + w * 0.58, y + h * 0.78))
+ elif kind == "flask":
+ _pen(painter, colour, stroke)
+ painter.drawLine(QPointF(x + w * 0.38, y + h * 0.2), QPointF(x + w * 0.62, y + h * 0.2))
+ painter.drawPolyline(QPolygonF([QPointF(x + w * 0.44, y + h * 0.2), QPointF(x + w * 0.44, y + h * 0.44),
+ QPointF(x + w * 0.24, y + h * 0.78), QPointF(x + w * 0.76, y + h * 0.78),
+ QPointF(x + w * 0.56, y + h * 0.44), QPointF(x + w * 0.56, y + h * 0.2)]))
+ elif kind == "alert":
+ _pen(painter, colour, stroke)
+ painter.drawEllipse(QRectF(x + w * 0.16, y + h * 0.16, w * 0.68, h * 0.68))
+ painter.drawLine(QPointF(x + w * 0.5, y + h * 0.33), QPointF(x + w * 0.5, y + h * 0.56))
+ painter.setBrush(QColor(colour))
+ painter.drawEllipse(QPointF(x + w * 0.5, y + h * 0.68), stroke * 0.7, stroke * 0.7)
+ elif kind == "paperclip":
+ _pen(painter, colour, stroke)
+ painter.drawRoundedRect(QRectF(x + w * 0.18, y + h * 0.22, w * 0.64, h * 0.5), w * 0.1, w * 0.1)
+ painter.drawLine(QPointF(x + w * 0.3, y + h * 0.72), QPointF(x + w * 0.3, y + h * 0.84))
+ elif kind == "info":
+ _pen(painter, colour, stroke)
+ painter.drawEllipse(QRectF(x + w * 0.16, y + h * 0.16, w * 0.68, h * 0.68))
+ painter.drawLine(QPointF(x + w * 0.5, y + h * 0.46), QPointF(x + w * 0.5, y + h * 0.68))
+ painter.setBrush(QColor(colour))
+ painter.drawEllipse(QPointF(x + w * 0.5, y + h * 0.34), stroke * 0.7, stroke * 0.7)
+ elif kind == "refresh":
+ _pen(painter, colour, stroke)
+ painter.drawArc(QRectF(x + w * 0.2, y + h * 0.2, w * 0.6, h * 0.6), 40 * 16, 280 * 16)
+ painter.setBrush(QColor(colour))
+ painter.drawPolygon(QPolygonF([QPointF(x + w * 0.72, y + h * 0.12), QPointF(x + w * 0.86, y + h * 0.34),
+ QPointF(x + w * 0.6, y + h * 0.32)]))
+ elif kind == "save":
+ _pen(painter, colour, stroke)
+ painter.drawRoundedRect(QRectF(x + w * 0.2, y + h * 0.2, w * 0.6, h * 0.6), w * 0.08, w * 0.08)
+ painter.drawLine(QPointF(x + w * 0.36, y + h * 0.2), QPointF(x + w * 0.36, y + h * 0.42))
+ painter.drawLine(QPointF(x + w * 0.36, y + h * 0.42), QPointF(x + w * 0.64, y + h * 0.42))
+ painter.drawLine(QPointF(x + w * 0.64, y + h * 0.42), QPointF(x + w * 0.64, y + h * 0.2))
+ painter.drawRect(QRectF(x + w * 0.36, y + h * 0.56, w * 0.28, h * 0.24))
+ elif kind == "chevron":
+ _pen(painter, colour, stroke)
+ painter.drawPolyline(QPolygonF([QPointF(x + w * 0.4, y + h * 0.28), QPointF(x + w * 0.62, y + h * 0.5),
+ QPointF(x + w * 0.4, y + h * 0.72)]))
+ elif kind == "shield":
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor(colour))
+ painter.drawPolygon(QPolygonF([QPointF(x + w * 0.5, y + h * 0.16), QPointF(x + w * 0.82, y + h * 0.3),
+ QPointF(x + w * 0.82, y + h * 0.56), QPointF(x + w * 0.5, y + h * 0.84),
+ QPointF(x + w * 0.18, y + h * 0.56), QPointF(x + w * 0.18, y + h * 0.3)]))
+ elif kind == "check":
+ _pen(painter, colour, stroke * 1.2)
+ painter.drawPolyline(QPolygonF([QPointF(x + w * 0.32, y + h * 0.52), QPointF(x + w * 0.45, y + h * 0.65),
+ QPointF(x + w * 0.7, y + h * 0.37)]))
+ elif kind == "spark":
+ # The 千问 mark: three crossing strokes forming a six-pointed star.
+ _pen(painter, colour, stroke * 1.1)
+ centre = QPointF(x + w * 0.5, y + h * 0.5)
+ radius = min(w, h) * 0.3
+ for angle in (90, 30, -30):
+ from math import cos, radians, sin
+ dx, dy = cos(radians(angle)) * radius, -sin(radians(angle)) * radius
+ painter.drawLine(QPointF(centre.x() - dx, centre.y() - dy), QPointF(centre.x() + dx, centre.y() + dy))
+ elif kind == "knot":
+ # The OpenAI mark, reduced to the interlocking hexagon it is built from.
+ _pen(painter, colour, stroke)
+ from math import cos, radians, sin
+ centre = QPointF(x + w * 0.5, y + h * 0.5)
+ radius = min(w, h) * 0.3
+ points = [QPointF(centre.x() + cos(radians(angle)) * radius, centre.y() + sin(radians(angle)) * radius)
+ for angle in range(0, 360, 60)]
+ painter.drawPolygon(QPolygonF(points))
+ painter.drawLine(points[0], points[3])
+ painter.restore()
+
+
+class Glyph(QWidget):
+ """A single painted icon at a fixed size."""
+
+ def __init__(self, kind: str, colour: str, size: int = 18, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.kind, self.colour = kind, colour
+ self.setFixedSize(size, size)
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
+
+ def set_colour(self, colour: str) -> None:
+ if colour != self.colour:
+ self.colour = colour
+ self.update()
+
+ def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
+ painter = QPainter(self)
+ paint_glyph(painter, self.kind, QRectF(0, 0, self.width(), self.height()), self.colour)
+ painter.end()
+
+
+class LogoTile(QWidget):
+ """A rounded tile with a glyph on it: the window mark and the two model marks."""
+
+ def __init__(self, kind: str, *, start: str, end: str, glyph: str = "#FFFFFF",
+ size: int = 34, radius: float = 10.0, circle: bool = False,
+ parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.kind, self.start, self.end, self.glyph_colour = kind, start, end, glyph
+ self.radius, self.circle = radius, circle
+ self.setFixedSize(size, size)
+ self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
+
+ def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ box = QRectF(0, 0, self.width(), self.height())
+ gradient = QLinearGradient(box.topLeft(), box.bottomRight())
+ gradient.setColorAt(0.0, QColor(self.start))
+ gradient.setColorAt(1.0, QColor(self.end))
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QBrush(gradient))
+ if self.circle:
+ painter.drawEllipse(box)
+ else:
+ painter.drawRoundedRect(box, self.radius, self.radius)
+ paint_glyph(painter, self.kind, box, self.glyph_colour)
+ painter.end()
+
+ def minimumSizeHint(self) -> QSize:
+ return QSize(self.width(), self.height())
+
+
+def window_mark(parent: QWidget | None = None, size: int = 38) -> LogoTile:
+ tile = LogoTile("check", start=TECH_BLUE["accent"], end=TECH_BLUE["accent_pressed"],
+ size=size, radius=11, parent=parent)
+ tile.setAccessibleName("诊断与药方对照")
+ return tile
+
+
+def model_mark(model_key: str, parent: QWidget | None = None, size: int = 30) -> LogoTile:
+ if model_key == "openai":
+ return LogoTile("knot", start=TECH_BLUE["openai_dim"], end=TECH_BLUE["surface_2"],
+ glyph=TECH_BLUE["openai_text"], size=size, circle=True, parent=parent)
+ return LogoTile("spark", start=TECH_BLUE["qwen_dim"], end=TECH_BLUE["surface_2"],
+ glyph=TECH_BLUE["qwen_text"], size=size, radius=9, parent=parent)
+
+
+def glyph_icon(kind: str, colour: str, size: int = 16) -> QIcon:
+ """The same line art as a QIcon, for buttons that place their own icon and label."""
+
+ pixmap = QPixmap(size, size)
+ pixmap.fill(Qt.GlobalColor.transparent)
+ painter = QPainter(pixmap)
+ paint_glyph(painter, kind, QRectF(0, 0, size, size), colour)
+ painter.end()
+ return QIcon(pixmap)
diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_labels.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_labels.py
index edfa0abd8..dbabd59eb 100644
--- a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_labels.py
+++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_labels.py
@@ -283,3 +283,61 @@ def value_text(value: Any, field: str, labels: Mapping[str, str], fields: Mappin
if field and field not in fields:
return system_text(value, labels, fields)
return plain_text(value)
+
+
+STATE_LABELS = {
+ "blank": "尚未开方", "not_generated": "尚无分析记录", "unavailable": "暂无可比结果",
+ "not_applicable": "尚未开方", "not_started": "尚未分析", "pending": "待分析",
+ "preparing": "准备资料", "waiting_sources": "等待转写/资料", "retry_wait": "等待重试", "blocked": "需完善资料关联",
+ "queued": "待分析", "waiting": "等待资料", "waiting_transcript": "等待转写",
+ "waiting_transcription": "等待转写", "running": "分析中", "processing": "分析中",
+ "retrying": "重试中", "succeeded": "已完成", "completed": "已完成", "success": "已完成",
+ "partial": "部分完成", "failed": "需重试", "cancelled": "已取消", "canceled": "已取消",
+ "stale": "处方已变更", "superseded": "处方已变更", "invalid": "已失效",
+ "prescription_changed": "处方已变更", "source_updated": "资料已更新", "voided": "处方已作废", "deleted": "处方已删除", "revoked": "权限已撤销",
+ "current": "当前版本", "valid": "当前有效", "complete": "资料清单完整",
+ "incomplete": "资料不全", "missing": "资料缺失", "unknown": "未确认",
+ "needs_patient_link": "需完善患者关联", "patient_unlinked": "需完善患者关联",
+ "independent_baseline": "独立基线", "baseline": "独立基线",
+ "latest_context": "最新资料对照", "supplemental": "最新资料对照",
+ "assisted_revision": "AI 辅助后修订", "ai_assisted": "AI 辅助后修订",
+ "non_independent": "非独立对照", "auxiliary": "辅助复核",
+ "comparable": "可比", "not_comparable": "不可比",
+ "available_for_review": "供医生复核", "insufficient_data": "资料不足,暂不提供候选用药",
+ "withheld_for_risk": "因风险暂缓候选用药", "viewed": "已查看", "needs_information": "需补充资料",
+ "not_adopted": "不采纳", "reviewed": "已复核",
+ "per_dose": "每剂", "per_day": "每日", "matched": "共同药味", "doctor_only": "仅医生方", "candidate_only": "仅模型方",
+ "insufficient_sample": "样本不足", "descriptive_only": "仅作描述性统计",
+}
+FIELD_LABELS = {
+ "summary": "概要", "timeline": "病程", "analysis": "综合分析", "tcm_analysis": "中医辨证",
+ "diagnosis": "辨证分析", "treatment_advice": "治疗与随访建议", "risk_assessment": "需复核风险",
+ "evidence_references": "证据来源编号", "missing_information": "待补充资料", "level": "风险等级", "label": "说明",
+ "risk_warnings": "需复核风险", "risks": "风险", "follow_up": "随访建议", "evidence": "依据",
+ "sources": "来源", "manifest": "来源清单", "missing": "资料缺口", "status": "状态",
+ "reason": "原因", "name": "药名", "herb_name": "规范药名", "canonical_name": "规范药名",
+ "processing": "炮制", "dosage": "剂量", "dose": "剂量", "unit": "单位", "dose_basis": "剂量基准",
+ "formula_type": "主辅方", "doctor_dosage": "医生剂量", "candidate_dosage": "模型剂量",
+ "doctor_dose": "医生剂量", "candidate_dose": "模型剂量", "ai_dose": "模型剂量",
+ "contribution": "匹配贡献", "ratio": "匹配贡献", "match_ratio": "匹配贡献", "match": "匹配情况",
+ "prescription_name": "候选方名称", "prescription_type": "剂型", "herbs": "药味",
+ "dose_count": "剂数", "usage_days": "疗程(天)", "times_per_day": "每日服次",
+ "usage_instruction": "服法", "usage_time": "服药时间", "usage_way": "给药途径",
+ "rationale": "方义与依据", "usage_differences": "用法、疗程与风险差异", "normalization": "规范化记录",
+ "algorithm_version": "算法版本", "dictionary_version": "药材字典版本", "model_version": "模型版本",
+ "prompt_version": "提示词版本", "doctor_count": "医生药项数", "candidate_count": "候选药项数",
+ "matched_count": "共同药项数", "coverage": "模型资料覆盖", "source_summary": "来源汇总",
+ "cutoff_at": "资料截止时间", "generated_at": "报告生成时间", "comment": "复核意见",
+ "match_type": "增减药项", "administration_route": "给药途径", "group": "用药组",
+ "delivered": "已送达", "unreadable": "不可读", "unsupported": "不支持", "parsed": "已解析",
+ "diagnosis_count": "病历数", "prescription_count": "历史处方数", "chat_count": "聊天记录数",
+ "daily_record_count": "日常记录数", "transcript_count": "转写数", "attachment_count": "附件数",
+ "files": "附件处理清单", "source_ids": "已读取来源编号", "source_id": "来源编号", "file_id": "附件编号",
+ "complete": "资料清单完整", "source_complete": "文字来源齐全", "transmitted": "附件已送达", "critical": "关键资料缺口",
+ "baseline_eligible": "独立基线统计资格", "baseline_exclusion_reasons": "基线排除原因", "instructions": "特殊煎服说明",
+ "versions": "版本信息", "strata": "按版本分层", "count": "样本数", "mean": "均值", "median": "中位数",
+ "distribution": "一致度分布", "sample_status": "样本说明",
+}
+STATE_LABELS.update(SYSTEM_LABELS)
+FIELD_LABELS.update(EXTRA_FIELD_LABELS)
+
diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_pages.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_pages.py
new file mode 100644
index 000000000..fea4d8928
--- /dev/null
+++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_pages.py
@@ -0,0 +1,1575 @@
+"""Composed pages for the prescription analysis window: per-herb, sources, progress, history.
+
+Each page renders only what the saved batch carries. Missing values stay visibly absent, and no
+page issues a request of its own: the dialog owns all polling and passes the batch down.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+from PySide6.QtCore import QPointF, QRectF, Qt
+from PySide6.QtGui import QColor, QFont, QPainter
+from PySide6.QtWidgets import (
+ QAbstractItemView,
+ QFrame,
+ QGridLayout,
+ QHBoxLayout,
+ QHeaderView,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QSizePolicy,
+ QTableWidget,
+ QTableWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+from .issued_prescription_ai_charts import TrendBars, WaffleCoverage
+from .issued_prescription_ai_labels import FIELD_LABELS, STATE_LABELS, system_text
+from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE
+from .issued_prescription_ai_theme import num_font, on_theme_changed
+
+MODEL_NAMES = {"qwen": "千问", "openai": "OpenAI"}
+# Upstream stage keys arrive as ``text:0`` / ``files:1``; the index is the batch within the stage.
+STAGE_NAMES = {"text": "文字资料", "files": "附件读取", "reduce": "归并", "final": "生成候选与报告",
+ "repair": "格式修复", "names": "药名核对", "insist": "补充追问"}
+ACCENTS = {"qwen": TECH_BLUE["qwen"], "openai": TECH_BLUE["openai"]}
+
+
+@on_theme_changed
+def _rebuild_accents() -> None:
+ ACCENTS.update({"qwen": TECH_BLUE["qwen"], "openai": TECH_BLUE["openai"]})
+
+
+def _mapping(value: Any) -> dict[str, Any]:
+ return dict(value) if isinstance(value, Mapping) else {}
+
+
+def _number(value: Any) -> float | None:
+ if value is None or isinstance(value, bool):
+ return None
+ try:
+ parsed = float(value)
+ except (TypeError, ValueError):
+ return None
+ return parsed
+
+
+def _text(value: Any) -> str:
+ return str(value).strip() if isinstance(value, (str, int, float)) and not isinstance(value, bool) else ""
+
+
+def _label(value: Any) -> str:
+ """Shared translator: the same maps the report panels use, so codes never leak to the UI."""
+
+ return system_text(value, STATE_LABELS, FIELD_LABELS) if value not in (None, "") else ""
+
+
+def _stage_text(value: Any) -> str:
+ text = _text(value)
+ head, _, tail = text.partition(":")
+ name = STAGE_NAMES.get(head)
+ if not name:
+ return text or "—"
+ return f"{name} {int(tail) + 1}" if tail.isdigit() else name
+
+
+def _fit_rows(table: QTableWidget) -> None:
+ """A table is content, not a scroll area: give it exactly the height its rows need.
+
+ Boxed into whatever space is left over, a table shows one row at a time and cuts its own
+ header; laid out in full it simply makes the page longer, and the page already scrolls.
+ """
+
+ table.resizeRowsToContents()
+ rows = sum(table.rowHeight(index) for index in range(table.rowCount()))
+ header = 0 if table.horizontalHeader().isHidden() else table.horizontalHeader().height()
+ table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ table.setFixedHeight(header + max(rows, 31) + 4)
+
+
+def _coverage_text(value: Any) -> str:
+ """``partial`` means part of the material is missing here, not that a task half finished."""
+
+ return {"partial": "部分资料缺失"}.get(_text(value)) or _label(value) or "—"
+
+
+def _dose(value: Any) -> float | None:
+ """A dose may arrive as a number or inside the normalized row object."""
+
+ if isinstance(value, Mapping):
+ return _number(_mapping(value).get("dosage"))
+ return _number(value)
+
+
+def _title(text: str, hint: str = "", parent: QWidget | None = None) -> QWidget:
+ holder = QWidget(parent)
+ layout = QHBoxLayout(holder)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(10)
+ title = QLabel(text, holder)
+ title.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 13.5px; font-weight: 600;")
+ layout.addWidget(title)
+ if hint:
+ note = QLabel(hint, holder)
+ note.setStyleSheet(f"color: {TECH_BLUE['faint']}; font-size: 10.65px;")
+ note.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
+ note.setMinimumWidth(0)
+ note.setToolTip(hint)
+ layout.addWidget(note, 1)
+ else:
+ layout.addStretch(1)
+ return holder
+
+
+def _card(parent: QWidget | None = None) -> tuple[QFrame, QVBoxLayout]:
+ frame = QFrame(parent)
+ frame.setObjectName("AiMetricCard")
+ layout = QVBoxLayout(frame)
+ layout.setContentsMargins(18, 15, 18, 18)
+ layout.setSpacing(8)
+ return frame, layout
+
+
+def _table(headers: list[str]) -> QTableWidget:
+ table = QTableWidget(0, len(headers))
+ table.setHorizontalHeaderLabels(headers)
+ table.verticalHeader().hide()
+ table.setShowGrid(False)
+ table.setAlternatingRowColors(True)
+ table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
+ table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+ table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
+ table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
+ table.horizontalHeader().setStretchLastSection(False)
+ table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
+ table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
+ table.setStyleSheet(
+ f"QTableWidget {{ border: 0; background: transparent; }}"
+ f"QHeaderView::section {{ background: {TECH_BLUE['surface_2']}; color: {TECH_BLUE['faint']}; border: 0;"
+ f" border-bottom: 1px solid {TECH_BLUE['line_soft']}; padding: 10px 16px; font-size: 10px; }}"
+ f"QTableWidget::item {{ padding: 8px 16px; font-size: 12px; }}")
+ return table
+
+
+def _item(text: str, *, align_right: bool = False, colour: str | None = None, tooltip: str = "") -> QTableWidgetItem:
+ item = QTableWidgetItem(text)
+ if align_right:
+ item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+ if colour:
+ item.setForeground(Qt.GlobalColor.darkGray if colour == "muted" else Qt.GlobalColor.black)
+ if tooltip:
+ item.setToolTip(tooltip)
+ return item
+
+
+class ModelDoseCell(QWidget):
+ """One model's cell in the matrix: the dose, the contribution bar, and its value."""
+
+ BAR_WIDTH = 54
+ BAR_HEIGHT = 5
+
+ def __init__(self, dose: str, contribution: float | None, accent: str,
+ parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.dose, self.contribution, self.accent = dose, contribution, accent
+ self.setFixedHeight(26)
+ described = dose if contribution is None else f"{dose},贡献 {contribution:.2f}"
+ self.setAccessibleDescription(described)
+ self.setToolTip("未收录或不可比,不计入分子" if contribution is None else
+ f"贡献 {contribution:.2f} = min(医生剂量, 候选剂量) ÷ max(医生剂量, 候选剂量)")
+
+ def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ painter.setFont(num_font(12))
+ metrics = painter.fontMetrics()
+ missing = self.dose == "—"
+ painter.setPen(QColor(TECH_BLUE["faint"] if missing else TECH_BLUE["text"]))
+ dose_width = max(46, metrics.horizontalAdvance(self.dose) + 6)
+ painter.drawText(QRectF(0, 0, dose_width, self.height()),
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self.dose)
+ if self.contribution is None:
+ painter.end()
+ return
+ left = dose_width + 8
+ top = (self.height() - self.BAR_HEIGHT) / 2
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor(TECH_BLUE["raised"]))
+ painter.drawRoundedRect(QRectF(left, top, self.BAR_WIDTH, self.BAR_HEIGHT), 2.5, 2.5)
+ painter.setBrush(QColor(self.accent))
+ painter.drawRoundedRect(QRectF(left, top, self.BAR_WIDTH * min(1.0, self.contribution),
+ self.BAR_HEIGHT), 2.5, 2.5)
+ painter.setPen(QColor(TECH_BLUE["text"]))
+ painter.drawText(QRectF(left + self.BAR_WIDTH + 8, 0, 44, self.height()),
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
+ f"{self.contribution:.2f}")
+ painter.end()
+
+
+VERDICTS = {
+ "both": ("两模型均收录", "selection", "accent_text"),
+ "one": ("仅单模型收录", "qwen_dim", "qwen_text"),
+ "diff": ("剂量分歧", "amber_dim", "amber_text"),
+ "doc": ("仅医方使用", "raised", "muted"),
+ "lack": ("两模型均未收录", "rose_dim", "rose_text"),
+}
+
+
+class ContributionCell(QWidget):
+ """A contribution as a short bar plus its exact value; an absent value stays a dash."""
+
+ def __init__(self, value: float | None, accent: str, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.value, self.accent = value, accent
+ self.setFixedHeight(22)
+ self.setAccessibleDescription("—" if value is None else f"{value:.2f}")
+ self.setToolTip("未收录或不可比,不计入分子" if value is None else
+ f"贡献 {value:.2f} = min(医生剂量, 候选剂量) ÷ max(医生剂量, 候选剂量)")
+ self.setToolTip("未收录或不可比,不计入分子" if value is None else
+ f"贡献 {value:.2f} = min(医生剂量, 候选剂量) ÷ max(医生剂量, 候选剂量)")
+
+ def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ painter.setFont(self.font())
+ text = "—" if self.value is None else f"{self.value:.2f}"
+ metrics = painter.fontMetrics()
+ text_width = metrics.horizontalAdvance("0.00") + 6
+ bar_width = max(0, self.width() - text_width - 8)
+ if self.value is not None and bar_width > 12:
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor(f"{TECH_BLUE['raised']}"))
+ painter.drawRoundedRect(QRectF(0, 8, bar_width, 6), 3, 3)
+ painter.setBrush(QColor(self.accent))
+ painter.drawRoundedRect(QRectF(0, 8, bar_width * min(1.0, self.value), 6), 3, 3)
+ painter.setPen(QColor(TECH_BLUE["muted"] if self.value is None else TECH_BLUE["heading"]))
+ painter.drawText(QRectF(self.width() - text_width, 0, text_width, self.height()),
+ Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, text)
+ painter.end()
+
+
+class WrappedLabel(QLabel):
+ """A wrapping label that claims the height its wrapped text actually needs.
+
+ A plain ``QLabel`` only ever asks for one line, so a layout hands it one line and the rest is
+ clipped; this asks again for the width it ended up with.
+ """
+
+ def __init__(self, text: str = "", parent: QWidget | None = None) -> None:
+ super().__init__(text, parent)
+ self.setWordWrap(True)
+ policy = self.sizePolicy()
+ policy.setHeightForWidth(True)
+ policy.setVerticalPolicy(QSizePolicy.Policy.Minimum)
+ self.setSizePolicy(policy)
+
+ def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
+ super().resizeEvent(event)
+ needed = self.heightForWidth(self.width())
+ if needed > 0 and needed != self.minimumHeight():
+ self.setMinimumHeight(needed)
+
+
+class PrescriptionCard(QFrame):
+ """One prescription as it was saved: name, dose, and the usage line underneath."""
+
+ def __init__(self, title: str, accent: str | None, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("AiMetricCard")
+ if accent:
+ self.setStyleSheet(f"QFrame#AiMetricCard {{ border-left: 4px solid {accent}; }}")
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(16, 12, 18, 12)
+ layout.setSpacing(6)
+ head = QHBoxLayout()
+ head.setSpacing(8)
+ self.title = QLabel(title, self)
+ self.title.setStyleSheet(
+ f"background: {'#EAF2FF' if accent else '#EEF3FB'}; color: {accent or '#3B5675'};"
+ " border-radius: 9px; padding: 2px 9px; font-size: 11px;")
+ head.addWidget(self.title)
+ self.summary = QLabel("—", self)
+ self.summary.setTextFormat(Qt.TextFormat.PlainText)
+ self.summary.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 13px; font-weight: 600;")
+ head.addWidget(self.summary)
+ head.addStretch(1)
+ self.state = QLabel("", self)
+ self.state.setTextFormat(Qt.TextFormat.PlainText)
+ self.state.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11px;")
+ head.addWidget(self.state)
+ layout.addLayout(head)
+ self.herbs = QLabel("尚未保存药味", self)
+ self.herbs.setTextFormat(Qt.TextFormat.RichText)
+ self.herbs.setWordWrap(False)
+ self.herbs.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
+ self.herbs.setAlignment(Qt.AlignmentFlag.AlignTop)
+ layout.addWidget(self.herbs)
+ layout.addStretch(1)
+ self.usage = WrappedLabel("", self)
+ self.usage.setTextFormat(Qt.TextFormat.PlainText)
+ self.usage.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;")
+ layout.addWidget(self.usage)
+
+ MAX_ROWS = 5
+
+ def set_prescription(self, herbs: list[Any], summary: str, usage: str, state: str = "") -> None:
+ rows = []
+ for herb in herbs[:self.MAX_ROWS]:
+ value = _mapping(herb)
+ dose = _number(value.get("dosage"))
+ unit = _text(value.get("unit")) or "g"
+ amount = "—" if dose is None else f"{dose:g} {unit}"
+ rows.append(
+ f"| {_text(value.get('name')) or '药名未保存'} | "
+ f"{amount} |
")
+ if len(herbs) > self.MAX_ROWS:
+ rows.append(f"| "
+ f"另有 {len(herbs) - self.MAX_ROWS} 味,见下方逐味对照 |
")
+ self.herbs.setText(f"" if rows else "尚未保存药味")
+ self.summary.setText(summary)
+ self.usage.setText(usage)
+ self.usage.setToolTip(usage)
+ self.state.setText(state)
+
+
+class CandidatesPage(QWidget):
+ """The three prescriptions side by side, then every herb with the contribution it earned."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._rows: list[dict[str, Any]] = []
+ self._filter = "all"
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(14, 12, 14, 10)
+ layout.setSpacing(10)
+
+ cards = QHBoxLayout()
+ cards.setSpacing(12)
+ self.cards = {
+ "doctor": PrescriptionCard("医生原方", None, self),
+ "qwen": PrescriptionCard(f"{MODEL_NAMES['qwen']}候选", ACCENTS["qwen"], self),
+ "openai": PrescriptionCard(f"{MODEL_NAMES['openai']} 候选", ACCENTS["openai"], self),
+ }
+ for card in self.cards.values():
+ card.setMinimumHeight(200)
+ cards.addWidget(card, 1)
+ layout.addLayout(cards)
+
+ body, body_layout = _card(self)
+ head = _title(
+ "逐味对照与贡献", "一致度 = 2 × Σ 各共同药味贡献 ÷(医生药味数 + 候选药味数)", body)
+ legend = QLabel(
+ f"● {MODEL_NAMES['qwen']}贡献度"
+ f" ● {MODEL_NAMES['openai']} 贡献度", head)
+ legend.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 10.65px;")
+ head.layout().addWidget(legend)
+ body_layout.addWidget(head)
+ filters = QHBoxLayout()
+ filters.setSpacing(8)
+ self.filter_buttons: dict[str, QPushButton] = {}
+ for key, text in (("all", "全部"), ("shared", "两模型均收录"), ("single", "仅单模型收录"),
+ ("doctor", "仅医方收录"), ("different", "剂量分歧 ≥5 克")):
+ button = QPushButton(text, body)
+ button.setCheckable(True)
+ button.setChecked(key == "all")
+ button.setCursor(Qt.CursorShape.PointingHandCursor)
+ button.setStyleSheet(
+ f"QPushButton {{ border: 1px solid {TECH_BLUE['line']}; border-radius: 8px; padding: 6px 13px;"
+ f" color: {TECH_BLUE['muted']}; background: {TECH_BLUE['surface_2']}; font-size: 11.1px; }}"
+ f"QPushButton:checked {{ background: {TECH_BLUE['accent']}; border-color: {TECH_BLUE['accent']};"
+ f" color: {TECH_BLUE['surface']}; font-weight: 600; }}")
+ button.clicked.connect(lambda _checked=False, target=key: self._choose(target))
+ self.filter_buttons[key] = button
+ filters.addWidget(button)
+ filters.addStretch(1)
+ self.search = QLineEdit(body)
+ self.search.setPlaceholderText("搜索药名…")
+ self.search.setFixedWidth(190)
+ self.search.textChanged.connect(lambda _text: self._render())
+ filters.addWidget(self.search)
+ body_layout.addLayout(filters)
+ self.table = _table(["药味", f"{MODEL_NAMES['qwen']}(克/剂 · 贡献度)",
+ f"{MODEL_NAMES['openai']}(克/剂 · 贡献度)", "医方原方", "结论"])
+ self.table.verticalHeader().setDefaultSectionSize(38)
+ header = self.table.horizontalHeader()
+ header.setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
+ header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
+ for column, width in ((1, 190), (2, 190), (3, 110), (4, 150)):
+ header.setSectionResizeMode(column, QHeaderView.ResizeMode.Fixed)
+ self.table.setColumnWidth(column, width)
+ self.table.setStyleSheet(
+ f"QTableWidget {{ border: 0; background: transparent; color: {TECH_BLUE['text']}; }}"
+ f"QHeaderView::section {{ background: {TECH_BLUE['surface_2']}; color: {TECH_BLUE['faint']};"
+ f" border: 0; border-bottom: 1px solid {TECH_BLUE['line_soft']}; padding: 10px 14px;"
+ " font-size: 10px; }"
+ f"QTableWidget::item {{ border-bottom: 1px solid {TECH_BLUE['line_soft']};"
+ " padding: 9px 14px; }")
+ body_layout.addWidget(self.table, 1)
+ layout.addWidget(body, 1)
+
+ self.usage = QWidget(self)
+ usage_row = QHBoxLayout(self.usage)
+ usage_row.setContentsMargins(0, 0, 0, 0)
+ usage_row.setSpacing(12)
+ self.usage_values: dict[str, tuple[QLabel, QLabel]] = {}
+ for key, caption in (("usage", "服法"), ("course", "疗程"), ("form", "剂型"), ("aux", "辅方")):
+ cell = QFrame(self.usage)
+ cell.setObjectName("AiUsageCell")
+ cell.setStyleSheet(
+ f"QFrame#AiUsageCell {{ background: {TECH_BLUE['surface_2']}; border: 1px solid {TECH_BLUE['line']};"
+ " border-radius: 12px; }}")
+ box = QVBoxLayout(cell)
+ box.setContentsMargins(12, 8, 12, 8)
+ box.setSpacing(1)
+ head = QLabel(caption, cell)
+ head.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 10.5px;")
+ box.addWidget(head)
+ doctor = QLabel("医生:—", cell)
+ doctor.setTextFormat(Qt.TextFormat.PlainText)
+ doctor.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 12.5px; font-weight: 600;")
+ box.addWidget(doctor)
+ model = QLabel("模型:—", cell)
+ model.setTextFormat(Qt.TextFormat.PlainText)
+ model.setStyleSheet(f"color: {TECH_BLUE['amber_text']}; font-size: 11px;")
+ box.addWidget(model)
+ self.usage_values[key] = (doctor, model)
+ usage_row.addWidget(cell, 1)
+ layout.addWidget(self.usage)
+ self.footnote = QLabel(
+ "贡献 = min(医生剂量, 候选剂量) ÷ max(医生剂量, 候选剂量);未收录与不可比药味不进分子,也不被剔除出分母。", self)
+ self.footnote.setWordWrap(True)
+ self.footnote.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;")
+ layout.addWidget(self.footnote)
+
+ def row_count(self) -> int:
+ """How many herbs the matrix holds, for the rail's badge."""
+
+ return len(self._rows)
+
+ def _choose(self, key: str) -> None:
+ self._filter = key
+ for name, button in self.filter_buttons.items():
+ button.setChecked(name == key)
+ self._render()
+
+ def set_batch(self, batch: Mapping[str, Any] | None) -> None:
+ data = _mapping(batch)
+ models = _mapping(data.get("models"))
+ merged: dict[str, dict[str, Any]] = {}
+ for key in ("qwen", "openai"):
+ comparison = _mapping(_mapping(models.get(key)).get("comparison"))
+ rows = comparison.get("rows")
+ if not isinstance(rows, list):
+ continue
+ for row in rows:
+ value = _mapping(row)
+ name = _text(value.get("name"))
+ if not name:
+ continue
+ entry = merged.setdefault(name, {"name": name, "doctor": None, "unit": "", "qwen": None,
+ "openai": None, "qwen_contribution": None,
+ "openai_contribution": None, "processing": ""})
+ doctor = _dose(value.get("doctor_dosage"))
+ if doctor is None:
+ doctor = _dose(value.get("doctor"))
+ if doctor is not None:
+ entry["doctor"] = doctor
+ candidate = _dose(value.get("candidate_dosage"))
+ if candidate is None:
+ candidate = _dose(value.get("candidate"))
+ entry[key] = candidate
+ entry[f"{key}_contribution"] = _number(value.get("contribution"))
+ entry["unit"] = entry["unit"] or _text(value.get("unit"))
+ entry["processing"] = entry["processing"] or _text(value.get("processing"))
+ self._rows = sorted(merged.values(), key=lambda row: (row["doctor"] is None, -(row["doctor"] or 0)))
+ self._render_cards(data)
+ self._render()
+
+ def _render_cards(self, data: Mapping[str, Any]) -> None:
+ prescription = _mapping(_mapping(data.get("doctor_snapshot")).get("prescription"))
+ herbs = prescription.get("herbs") if isinstance(prescription.get("herbs"), list) else []
+ form = _text(prescription.get("prescription_type"))
+ doses = _text(prescription.get("dose_count"))
+ self.cards["doctor"].set_prescription(
+ list(herbs), " · ".join(part for part in (f"{len(herbs)} 味" if herbs else "", form) if part) or "未保存原方",
+ " · ".join(part for part in (_text(prescription.get("usage_instruction")),
+ f"{doses} 剂" if doses else "") if part) or "用法未保存")
+ models = _mapping(data.get("models"))
+ for key in ("qwen", "openai"):
+ model = _mapping(models.get(key))
+ candidate = _mapping(model.get("candidate"))
+ model_herbs = candidate.get("herbs") if isinstance(candidate.get("herbs"), list) else []
+ usage = " · ".join(part for part in (
+ f"每日 {_text(candidate.get('times_per_day'))} 次" if _text(candidate.get("times_per_day")) else "",
+ f"{_text(candidate.get('usage_days'))} 天" if _text(candidate.get("usage_days")) else "",
+ _text(candidate.get("rationale"))) if part)
+ warnings = candidate.get("risk_warnings")
+ if isinstance(warnings, list) and warnings:
+ usage = (usage + "\n" if usage else "") + "风险提示:" + _text(warnings[0])
+ comparison_status = _mapping(model.get("comparison")).get("status")
+ self.cards[key].set_prescription(
+ list(model_herbs),
+ " · ".join(part for part in (f"{len(model_herbs)} 味" if model_herbs else "",
+ _text(candidate.get("prescription_type"))) if part) or "尚无候选方",
+ usage or "服法未保存",
+ _label(comparison_status) or "")
+ self._render_usage(prescription, models)
+
+ def _render_usage(self, prescription: Mapping[str, Any], models: Mapping[str, Any]) -> None:
+ """Four facts the score never covers: 服法、疗程、剂型、辅方 — doctor first, models after."""
+
+ candidates = [_mapping(_mapping(models.get(key)).get("candidate")) for key in ("qwen", "openai")]
+ candidates = [candidate for candidate in candidates if candidate]
+
+ def agreed(values: list[str]) -> str:
+ unique = [value for value in dict.fromkeys(values) if value]
+ if not unique:
+ return "—"
+ return unique[0] if len(unique) == 1 else " / ".join(unique)
+
+ herbs = prescription.get("herbs") if isinstance(prescription.get("herbs"), list) else []
+ auxiliary = [_text(_mapping(herb).get("name")) for herb in herbs
+ if _text(_mapping(herb).get("formula_type")) in {"辅方", "aux"}]
+ doctor_days = _text(prescription.get("usage_days"))
+ model_days = agreed([f"{_text(candidate.get('usage_days'))} 天"
+ for candidate in candidates if _text(candidate.get("usage_days"))])
+ model_usage = agreed([f"每日 {_text(candidate.get('times_per_day'))} 次"
+ for candidate in candidates if _text(candidate.get("times_per_day"))])
+ doctor_form = _text(prescription.get("prescription_type"))
+ model_form = agreed([_text(candidate.get("prescription_type")) for candidate in candidates])
+ values = {
+ "usage": (_text(prescription.get("usage_instruction")) or "未记录", model_usage),
+ "course": (f"{doctor_days} 天" if doctor_days else "未记录", model_days),
+ "form": (doctor_form or "未记录", model_form),
+ "aux": ("、".join(auxiliary) if auxiliary else "未记录", "未给出辅方" if candidates else "—"),
+ }
+ for key, (doctor_value, model_value) in values.items():
+ doctor_label, model_label = self.usage_values[key]
+ doctor_label.setText(f"医生:{doctor_value}")
+ model_label.setText(f"模型:{model_value}")
+
+ def _difference(self, row: Mapping[str, Any]) -> bool:
+ """A dose difference needs two real doses: a missing herb is a coverage gap, not a delta."""
+
+ if row.get("doctor") is None:
+ return False
+ return any(row.get(key) is not None and abs(row[key] - row["doctor"]) > 1e-9
+ for key in ("qwen", "openai"))
+
+ @staticmethod
+ def _verdict(row: Mapping[str, Any]) -> str:
+ """Which of the design's five conclusions this herb falls under."""
+
+ models = [key for key in ("qwen", "openai") if row.get(key) is not None]
+ if not models:
+ return "doc" if row.get("doctor") is not None else "lack"
+ if row.get("doctor") is not None and len(models) == 2:
+ gaps = [abs(row[key] - row["doctor"]) for key in models]
+ return "diff" if max(gaps) >= 5 else "both"
+ if len(models) == 2:
+ return "both"
+ return "one"
+
+ def _keep(self, row: Mapping[str, Any]) -> bool:
+ verdict = self._verdict(row)
+ if self._filter == "shared":
+ return verdict in {"both", "diff"}
+ if self._filter == "single":
+ return verdict == "one"
+ if self._filter == "doctor":
+ return verdict in {"doc", "lack"}
+ if self._filter == "different":
+ return verdict == "diff"
+ return True
+
+ def _render(self) -> None:
+ verdicts = [self._verdict(row) for row in self._rows]
+ counts = {"all": len(self._rows),
+ "shared": sum(1 for verdict in verdicts if verdict in {"both", "diff"}),
+ "single": verdicts.count("one"),
+ "doctor": sum(1 for verdict in verdicts if verdict in {"doc", "lack"}),
+ "different": verdicts.count("diff")}
+ for key, button in self.filter_buttons.items():
+ base = {"all": "全部", "shared": "两模型均收录", "single": "仅单模型收录",
+ "doctor": "仅医方收录", "different": "剂量分歧 ≥5 克"}[key]
+ button.setText(f"{base} {counts[key]}")
+ needle = self.search.text().strip()
+ rows = [row for row in self._rows if (not needle or needle in row["name"]) and self._keep(row)]
+ self.table.setRowCount(len(rows))
+ for index, row in enumerate(rows):
+ unit = row["unit"] or "g"
+ name = _item(row["name"])
+ if row["processing"]:
+ name.setToolTip(row["processing"])
+ name.setText(f"{row['name']}\n{row['processing']}")
+ self.table.setItem(index, 0, name)
+ for column, key in ((1, "qwen"), (2, "openai")):
+ value = row.get(key)
+ dose = "—" if value is None else f"{value:g} g" if unit == "g" else f"{value:g} {unit}"
+ cell = ModelDoseCell(dose, row.get(f"{key}_contribution"), ACCENTS[key], self.table)
+ self.table.setItem(index, column, _item(""))
+ self.table.setCellWidget(index, column, cell)
+ doctor = row.get("doctor")
+ self.table.setItem(index, 3, _item("未收录" if doctor is None else f"{doctor:g} {unit}"))
+ self.table.setCellWidget(index, 4, self._verdict_cell(row))
+ _fit_rows(self.table)
+
+ def _verdict_cell(self, row: Mapping[str, Any]) -> QWidget:
+ """The conclusion badge, in the design's five states."""
+
+ key = self._verdict(row)
+ text, background, colour = VERDICTS[key]
+ if key == "one":
+ owner = MODEL_NAMES["qwen"] if row.get("qwen") is not None else MODEL_NAMES["openai"]
+ text = f"仅 {owner} 收录"
+ colour = "qwen_text" if row.get("qwen") is not None else "openai_text"
+ background = "qwen_dim" if row.get("qwen") is not None else "openai_dim"
+ if key == "diff":
+ gaps = [abs(row[model] - row["doctor"]) for model in ("qwen", "openai")
+ if row.get(model) is not None and row.get("doctor") is not None]
+ text = f"剂量分歧 {max(gaps):g} {row['unit'] or 'g'}" if gaps else text
+ holder = QWidget(self.table)
+ layout = QHBoxLayout(holder)
+ layout.setContentsMargins(14, 0, 14, 0)
+ layout.setSpacing(0)
+ pill = QLabel(text, holder)
+ pill.setTextFormat(Qt.TextFormat.PlainText)
+ if key == "doc":
+ # The design leaves this one unpilled: it states a fact, not something to decide.
+ pill.setStyleSheet(f"color: {TECH_BLUE['faint']}; font-size: 10px;")
+ else:
+ pill.setStyleSheet(
+ f"background: {TECH_BLUE[background]}; color: {TECH_BLUE[colour]};"
+ f" border: 1px solid {TECH_BLUE[colour]}; border-radius: 9px; padding: 2px 8px;"
+ " font-size: 10px;")
+ layout.addWidget(pill)
+ layout.addStretch(1)
+ return holder
+
+ def _explain(self, row: Mapping[str, Any]) -> str:
+ if row.get("doctor") is None:
+ owners = [MODEL_NAMES[key] for key in ("qwen", "openai") if row.get(key) is not None]
+ return "、".join(owners) + " 新增" if owners else "无剂量记录"
+ missing = [MODEL_NAMES[key] for key in ("qwen", "openai") if row.get(key) is None]
+ if len(missing) == 2:
+ return "仅医生使用"
+ if missing:
+ return f"{missing[0]} 未收录"
+ gaps = {key: abs(row[key] - row["doctor"]) for key in ("qwen", "openai")}
+ largest = max(gaps.values())
+ unit = row["unit"] or "g"
+ return "三方剂量一致" if largest < 1e-9 else f"剂量差 {largest:g} {unit}"
+
+
+class SourceBar(QWidget):
+ """One source type as a proportional bar; a type with no records still shows its zero."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._fraction = 0.0
+ self._colour = TECH_BLUE["accent"]
+ self.setFixedHeight(9)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+
+ def set_fraction(self, fraction: float, colour: str) -> None:
+ self._fraction, self._colour = max(0.0, min(1.0, fraction)), colour
+ self.update()
+
+ def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor(f"{TECH_BLUE['raised']}"))
+ painter.drawRoundedRect(QRectF(0, 0, self.width(), self.height()), 4, 4)
+ if self._fraction > 0:
+ painter.setBrush(QColor(self._colour))
+ painter.drawRoundedRect(QRectF(0, 0, max(4.0, self.width() * self._fraction), self.height()), 4, 4)
+ painter.end()
+
+
+SOURCE_ROWS = (("video_calls_count", "问诊通话", "#155BCC"), ("blood_glucose_pressure_count", "监测记录", "#4A8BEE"),
+ ("doctor_notes_count", "医生备注", "#7EAEF3"), ("prescriptions_count", "历史处方", "#A9CBF7"),
+ ("diagnoses_count", "病历", "#CFE1FA"), ("chat_messages_count", "聊天归档", "#CBD5E1"),
+ ("tencent_im_count", "腾讯 IM", "#CBD5E1"), ("wechat_messages_count", "企微聊天", "#CBD5E1"))
+
+
+ATTACHMENT_TYPES = {"image": "图片", "document": "文档", "audio": "音频", "video": "视频"}
+ATTACHMENT_STATES = {"processed": "已读取", "restricted": "存储授权受限", "unsupported": "格式不支持",
+ "unreadable": "模型未能读出内容", "pending": "未送达模型"}
+
+
+class AttachmentTile(QFrame):
+ """One attachment: its type, its short id, and whether the models actually read it."""
+
+ def __init__(self, entry: Mapping[str, Any], index: int, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("AiAttachmentTile")
+ status = _text(entry.get("status")) or "pending"
+ read = status == "processed"
+ tone = (TECH_BLUE["qwen_dim"], TECH_BLUE["qwen_text"]) if read else (
+ TECH_BLUE["amber_dim"], TECH_BLUE["amber_text"])
+ self.setStyleSheet(
+ f"QFrame#AiAttachmentTile {{ background: {tone[0]}; border: 1px solid {tone[1]};"
+ " border-radius: 8px; }")
+ self.setFixedSize(72, 54)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(6, 5, 6, 5)
+ layout.setSpacing(1)
+ kind = ATTACHMENT_TYPES.get(_text(entry.get("type")), "附件")
+ title = QLabel(f"{kind} {index}", self)
+ title.setTextFormat(Qt.TextFormat.PlainText)
+ title.setStyleSheet(f"color: {tone[1]}; font-size: 11.5px; font-weight: 600;")
+ layout.addWidget(title)
+ identifier = _text(entry.get("file_id"))
+ short = identifier[-4:] if len(identifier) > 4 else identifier
+ caption = QLabel(f"#{short}" if short else "—", self)
+ caption.setTextFormat(Qt.TextFormat.PlainText)
+ caption.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 10.5px;")
+ layout.addWidget(caption)
+ reason = _label(entry.get("reason")) or _text(entry.get("reason"))
+ state = ATTACHMENT_STATES.get(status, status)
+ self.setToolTip("\n".join(part for part in (
+ f"{kind} · 编号 {identifier or '未记录'}", f"状态:{state}",
+ f"原因:{reason}" if reason else "",
+ "版本已核验" if entry.get("version_verified") else "版本未核验") if part))
+ self.setAccessibleName(f"{kind} {index}")
+ self.setAccessibleDescription(f"{state}")
+
+
+class SourcesPage(QWidget):
+ """What the batch froze, what the models actually read, and what is still missing."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(14, 12, 14, 10)
+ layout.setSpacing(12)
+
+ left, left_layout = _card(self)
+ left.setMinimumWidth(280)
+ self.composition_title = _title("资料构成", "等待资料汇总", left)
+ left_layout.addWidget(self.composition_title)
+ self.composition_rows = QWidget(left)
+ self.composition_layout = QVBoxLayout(self.composition_rows)
+ self.composition_layout.setContentsMargins(0, 4, 0, 0)
+ self.composition_layout.setSpacing(6)
+ left_layout.addWidget(self.composition_rows)
+ left_layout.addWidget(_title("读取范围", parent=left))
+ self.meta = QLabel("—", left)
+ self.meta.setWordWrap(True)
+ self.meta.setTextFormat(Qt.TextFormat.PlainText)
+ self.meta.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 12px; line-height: 180%;")
+ left_layout.addWidget(self.meta)
+ left_layout.addStretch(1)
+ layout.addWidget(left, 3)
+
+ middle, middle_layout = _card(self)
+ middle_layout.addWidget(_title("附件读取情况", "模型逐个独立读取", parent=middle))
+ self.waffle = WaffleCoverage(middle, columns=12)
+ middle_layout.addWidget(self.waffle)
+ self.tiles = QWidget(middle)
+ self.tile_grid = QGridLayout(self.tiles)
+ self.tile_grid.setContentsMargins(0, 2, 0, 2)
+ self.tile_grid.setSpacing(6)
+ middle_layout.addWidget(self.tiles)
+ self.attachment_note = QLabel("附件情况未确认", middle)
+ self.attachment_note.setWordWrap(True)
+ self.attachment_note.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 12px;")
+ middle_layout.addWidget(self.attachment_note)
+ self.attachment_legend = QLabel("", middle)
+ self.attachment_legend.setTextFormat(Qt.TextFormat.RichText)
+ self.attachment_legend.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;")
+ middle_layout.addWidget(self.attachment_legend)
+ middle_layout.addWidget(_title("模型各自读取到的量", "同一批资料,两个模型分别送达", parent=middle))
+ self.per_model = _table(["模型", "已读附件", "受限或不支持", "读取比例"])
+ middle_layout.addWidget(self.per_model)
+ middle_layout.addStretch(1)
+ layout.addWidget(middle, 4)
+
+ right, right_layout = _card(self)
+ self.gap_title = _title("缺口清单", "等待报告", right)
+ right_layout.addWidget(self.gap_title)
+ self.gaps = _table(["缺口类型", "严重度", "条数"])
+ right_layout.addWidget(self.gaps)
+ # Nothing here expands, so without this the card would centre its contents.
+ right_layout.addStretch(1)
+ self.gap_note = WrappedLabel(
+ "缺口不代表资料不存在,只表示本批次未能核验或未能读取。补齐后可用“重新分析”生成新批次,旧报告保留。", right)
+ self.gap_note.setStyleSheet(
+ f"background: {TECH_BLUE['amber_dim']}; border: 1px solid {TECH_BLUE['amber']};"
+ f" border-radius: 7px; padding: 9px 12px; color: {TECH_BLUE['amber_text']};"
+ " font-size: 10.8px; line-height: 170%;")
+ right_layout.addWidget(self.gap_note)
+ layout.addWidget(right, 4)
+
+ def set_batch(self, batch: Mapping[str, Any] | None) -> None:
+ data = _mapping(batch)
+ summary = _mapping(data.get("source_summary"))
+ rows = [(name, int(value), colour) for key, name, colour in SOURCE_ROWS
+ if (value := _number(summary.get(key))) is not None]
+ largest = max((value for _name, value, _colour in rows), default=0)
+ while self.composition_layout.count():
+ item = self.composition_layout.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.setParent(None)
+ widget.deleteLater()
+ for name, value, colour in rows:
+ self.composition_layout.addWidget(self._source_row(name, value, colour, largest))
+ total = _number(summary.get("source_record_count"))
+ counted = sum(value for _name, value, _colour in rows)
+ self.composition_title.findChildren(QLabel)[-1].setText(
+ f"{int(total)} 条" if total is not None else (f"共 {counted} 条" if rows else "等待资料汇总"))
+
+ read = limited = 0
+ models = _mapping(data.get("models"))
+ per_model: list[tuple[str, int, int]] = []
+ for key in ("qwen", "openai"):
+ files = _mapping(_mapping(models.get(key)).get("coverage")).get("files")
+ if not isinstance(files, list):
+ continue
+ processed = sum(1 for item in files if _mapping(item).get("status") == "processed")
+ blocked = sum(1 for item in files if _mapping(item).get("status") != "processed")
+ per_model.append((MODEL_NAMES[key], processed, blocked))
+ read, limited = max(read, processed), max(limited, blocked)
+ attachments = int(_number(summary.get("attachment_count")) or (read + limited))
+ self.waffle.set_counts(read, limited, attachments)
+ self._render_tiles(models)
+ # The tiles say more than the waffle whenever the files themselves were saved.
+ self.waffle.setVisible(attachments > 0 and self.tile_grid.count() == 0)
+ untouched = max(0, attachments - read - limited)
+ self.attachment_note.setText(
+ f"附件 {attachments} 个:模型实际读取 {read} 个,受限或不支持 {limited} 个"
+ + (f",未送达模型 {untouched} 个" if untouched else "") if attachments else "本次没有附件")
+ self.attachment_legend.setText(
+ f"■ 已读取 {read}"
+ f" ■ 受限或不支持 {limited}"
+ + (f" ■ 未送达 {untouched}" if untouched else ""))
+ self.per_model.setRowCount(len(per_model))
+ for index, (name, processed, blocked) in enumerate(per_model):
+ self.per_model.setItem(index, 0, _item(name))
+ self.per_model.setItem(index, 1, _item(str(processed), align_right=True))
+ self.per_model.setItem(index, 2, _item(str(blocked), align_right=True))
+ share = f"{processed / attachments * 100:.0f}%" if attachments else "—"
+ self.per_model.setItem(index, 3, _item(share, align_right=True))
+ _fit_rows(self.per_model)
+
+ missing = data.get("missing")
+ grouped: dict[str, dict[str, Any]] = {}
+ if isinstance(missing, list):
+ for item in missing:
+ value = _mapping(item)
+ code = _text(value.get("code")) or "UNKNOWN"
+ entry = grouped.setdefault(code, {"count": 0, "critical": False})
+ entry["count"] += 1
+ entry["critical"] = entry["critical"] or bool(value.get("critical"))
+ ordered = sorted(grouped.items(), key=lambda pair: (not pair[1]["critical"], -pair[1]["count"], pair[0]))
+ self.gaps.setRowCount(len(ordered))
+ for index, (code, entry) in enumerate(ordered):
+ self.gaps.setItem(index, 0, _item(_label(code) or code, tooltip=code))
+ self.gaps.setItem(index, 1, _item("关键" if entry["critical"] else "一般"))
+ self.gaps.setItem(index, 2, _item(str(entry["count"]), align_right=True))
+ _fit_rows(self.gaps)
+ critical = sum(entry["count"] for _code, entry in ordered if entry["critical"])
+ total_gaps = sum(entry["count"] for _code, entry in ordered)
+ self.gap_title.findChildren(QLabel)[-1].setText(
+ f"{total_gaps} 项 · 关键 {critical}" if total_gaps else "本批次没有记录缺口")
+ self.gap_note.setVisible(bool(total_gaps))
+
+ versions = _mapping(_mapping(data.get("models")).get("qwen"))
+ self.meta.setText("\n".join(part for part in (
+ f"资料截止:{_text(data.get('cutoff_at')) or '—'}",
+ f"比较算法:{_text(versions.get('algorithm_version')) or '—'}",
+ f"提示词版本:{_text(versions.get('prompt_version')) or '—'}",
+ f"药材字典版本:{_text(versions.get('dictionary_version')) or '—'}",
+ f"覆盖状态:{_coverage_text(data.get('coverage_status'))}",
+ f"对照类型:{_label(data.get('comparison_type')) or '—'}",
+ ) if part))
+
+ TILE_COLUMNS = 4
+
+ def _render_tiles(self, models: Mapping[str, Any]) -> None:
+ """One tile per saved attachment, merged across models by file id."""
+
+ while self.tile_grid.count():
+ item = self.tile_grid.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.setParent(None)
+ widget.deleteLater()
+ merged: dict[str, dict[str, Any]] = {}
+ for key in ("qwen", "openai"):
+ files = _mapping(_mapping(_mapping(models).get(key)).get("coverage")).get("files")
+ if not isinstance(files, list):
+ continue
+ for entry in files:
+ value = _mapping(entry)
+ identifier = _text(value.get("file_id"))
+ if not identifier:
+ continue
+ kept = merged.get(identifier)
+ # A file counts as read when any model managed to read it; otherwise keep the
+ # first refusal so the tile can say why.
+ if kept is None or (value.get("status") == "processed" and kept.get("status") != "processed"):
+ merged[identifier] = value
+ for index, identifier in enumerate(sorted(merged)):
+ tile = AttachmentTile(merged[identifier], index + 1, self.tiles)
+ self.tile_grid.addWidget(tile, index // self.TILE_COLUMNS, index % self.TILE_COLUMNS)
+ self.tiles.setVisible(bool(merged))
+
+ def _source_row(self, name: str, value: int, colour: str, largest: int) -> QWidget:
+ row = QWidget(self.composition_rows)
+ layout = QHBoxLayout(row)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(10)
+ label = QLabel(name, row)
+ label.setFixedWidth(70)
+ label.setStyleSheet(f"color: {TECH_BLUE['text']}; font-size: 12.5px;")
+ layout.addWidget(label)
+ bar = SourceBar(row)
+ bar.set_fraction(value / largest if largest else 0.0, colour)
+ bar.setAccessibleDescription(f"{name} {value}")
+ bar.setToolTip(f"{name} {value} 条;条形按本页最大项({largest} 条)归一。")
+ bar.setToolTip(f"{name} {value} 条;条形按本页最大项({largest} 条)归一。")
+ layout.addWidget(bar, 1)
+ count = QLabel(str(value), row)
+ count.setFixedWidth(34)
+ count.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+ count.setStyleSheet(
+ f"color: {TECH_BLUE['heading'] if value else TECH_BLUE['muted']}; font-size: 12.5px; font-weight: 600;")
+ layout.addWidget(count)
+ return row
+
+
+class DurationBar(QWidget):
+ """One call's latency against the slowest call in the batch."""
+
+ def __init__(self, fraction: float, colour: str, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._fraction = max(0.0, min(1.0, fraction))
+ self._colour = colour
+ self.setFixedHeight(18)
+ self.setToolTip(f"占本批次最慢一次调用的 {self._fraction * 100:.0f}%")
+ self.setToolTip(f"占本批次最慢一次调用的 {self._fraction * 100:.0f}%")
+
+ def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor(f"{TECH_BLUE['raised']}"))
+ painter.drawRoundedRect(QRectF(0, 6, self.width(), 6), 3, 3)
+ if self._fraction > 0:
+ painter.setBrush(QColor(self._colour))
+ painter.drawRoundedRect(QRectF(0, 6, max(3.0, self.width() * self._fraction), 6), 3, 3)
+ painter.end()
+
+
+STAGE_GROUPS = (("text", "文字资料分析"), ("files", "附件读取"), ("final", "生成候选与报告"),
+ ("repair", "格式修复"), ("names", "药名回问"), ("insist", "补充追问"), ("reduce", "归并"))
+
+
+class ProgressPage(QWidget):
+ """Per-model stages and the individual upstream calls behind them."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(14, 12, 14, 10)
+ layout.setSpacing(10)
+
+ stats = QHBoxLayout()
+ stats.setSpacing(12)
+ self.stat_values: dict[str, QLabel] = {}
+ for key, caption in (("elapsed", "批次总耗时"), ("calls", "模型调用"),
+ ("repairs", "格式修复 / 追问"), ("failures", "失败调用")):
+ card, card_layout = _card(self)
+ card_layout.setContentsMargins(18, 15, 18, 15)
+ card_layout.setSpacing(7)
+ head = QLabel(caption, card)
+ head.setStyleSheet(f"color: {TECH_BLUE['faint']}; font-size: 10.65px; letter-spacing: 0.85px;")
+ card_layout.addWidget(head)
+ value = QLabel("—", card)
+ value.setTextFormat(Qt.TextFormat.PlainText)
+ value.setFont(num_font(26, weight=QFont.Weight.DemiBold))
+ value.setStyleSheet(f"color: {TECH_BLUE['heading']};")
+ card_layout.addWidget(value)
+ self.stat_values[key] = value
+ stats.addWidget(card, 1)
+ layout.addLayout(stats)
+
+ self.model_row = QHBoxLayout()
+ self.model_row.setSpacing(12)
+ self.stage_labels: dict[str, QLabel] = {}
+ self.model_cards: dict[str, QVBoxLayout] = {}
+ self.stage_lists: dict[str, QVBoxLayout] = {}
+ for key, name in MODEL_NAMES.items():
+ card, card_layout = _card(self)
+ head = QHBoxLayout()
+ head.setSpacing(8)
+ title = QLabel(name, card)
+ title.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 13.8px; font-weight: 600;")
+ head.addWidget(title)
+ stage = QLabel("等待处理进度", card)
+ stage.setWordWrap(True)
+ stage.setTextFormat(Qt.TextFormat.PlainText)
+ stage.setStyleSheet(f"color: {ACCENTS[key]}; font-size: 12.5px;")
+ head.addWidget(stage, 1)
+ card_layout.addLayout(head)
+ stages = QWidget(card)
+ stage_layout = QVBoxLayout(stages)
+ stage_layout.setContentsMargins(0, 4, 0, 0)
+ stage_layout.setSpacing(0)
+ card_layout.addWidget(stages)
+ card_layout.addStretch(1)
+ self.stage_labels[key] = stage
+ self.stage_lists[key] = stage_layout
+ self.model_cards[key] = card_layout
+ self.model_row.addWidget(card, 1)
+ layout.addLayout(self.model_row)
+ layout.addWidget(_title("调用记录", "耗时与用量来自上游返回;任务数不等于费用", self))
+ self.calls = _table(["模型", "阶段", "耗时分布", "耗时", "附件", "输出 tokens", "结果"])
+ layout.addWidget(self.calls)
+
+ def attach_live(self, key: str, widgets: Sequence[QWidget]) -> None:
+ """Host the dialog's live progress widgets, which say more than the saved stage label."""
+
+ layout = self.model_cards[key]
+ self.stage_labels[key].setVisible(False)
+ for index, widget in enumerate(widgets):
+ layout.insertWidget(1 + index, widget)
+
+ def set_batch(self, batch: Mapping[str, Any] | None) -> None:
+ data = _mapping(batch)
+ models = _mapping(data.get("models"))
+ rows: list[tuple[str, dict[str, Any]]] = []
+ total_calls = repairs = failures = 0
+ longest = 0.0
+ elapsed_total = 0.0
+ for key in ("qwen", "openai"):
+ model = _mapping(models.get(key))
+ progress = _mapping(model.get("progress"))
+ attempt = progress.get("attempt")
+ elapsed = _number(progress.get("elapsed_seconds"))
+ parts = [_text(progress.get("stage_label")) or "等待处理进度"]
+ if attempt:
+ parts.append(f"第 {int(attempt)} 次尝试")
+ if elapsed is not None:
+ elapsed_total = max(elapsed_total, elapsed)
+ parts.append(f"已用时 {int(elapsed // 60)} 分 {int(elapsed % 60):02d} 秒")
+ notice = _text(progress.get("notice"))
+ self.stage_labels[key].setText(" · ".join(parts) + (f"\n{notice}" if notice else ""))
+ calls = _mapping(model.get("usage")).get("calls")
+ calls = [_mapping(call) for call in calls] if isinstance(calls, list) else []
+ total_calls += len(calls)
+ repairs += sum(1 for call in calls if _text(call.get("stage")).partition(":")[0]
+ in {"repair", "names", "insist"})
+ failures += sum(1 for call in calls if not call.get("ok"))
+ longest = max([longest] + [_number(call.get("latency_ms")) or 0 for call in calls])
+ rows.extend((key, call) for call in calls)
+ self._render_stages(key, calls)
+ self.stat_values["elapsed"].setText(
+ f"{int(elapsed_total // 60)} 分 {int(elapsed_total % 60):02d} 秒" if elapsed_total else "—")
+ self.stat_values["calls"].setText(f"{total_calls} 次" if total_calls else "—")
+ self.stat_values["repairs"].setText(f"{repairs} 次" if total_calls else "—")
+ self.stat_values["failures"].setText(f"{failures} 次" if total_calls else "—")
+
+ self.calls.setRowCount(len(rows))
+ for index, (key, call) in enumerate(rows):
+ latency = _number(call.get("latency_ms"))
+ tokens = _number(_mapping(call.get("usage")).get("completion_tokens"))
+ files = _number(call.get("file_count"))
+ error = _text(call.get("error_code"))
+ self.calls.setItem(index, 0, _item(MODEL_NAMES[key]))
+ self.calls.setItem(index, 1, _item(_stage_text(call.get("stage"))))
+ self.calls.setItem(index, 2, _item(""))
+ self.calls.setCellWidget(index, 2, DurationBar((latency or 0) / longest if longest else 0.0,
+ ACCENTS[key], self.calls))
+ self.calls.setItem(index, 3, _item("—" if latency is None else f"{latency / 1000:.1f} s", align_right=True))
+ self.calls.setItem(index, 4, _item("—" if files is None else f"{int(files)}", align_right=True))
+ self.calls.setItem(index, 5, _item("—" if tokens is None else f"{int(tokens)}", align_right=True))
+ self.calls.setItem(index, 6, _item("通过" if call.get("ok") else (_label(error) or error or "失败")))
+ self.calls.setColumnWidth(2, 120)
+ _fit_rows(self.calls)
+
+ def _render_stages(self, key: str, calls: list[dict[str, Any]]) -> None:
+ """Stages are grouped from the saved calls, never invented when nothing was recorded."""
+
+ layout = self.stage_lists[key]
+ while layout.count():
+ item = layout.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.setParent(None)
+ widget.deleteLater()
+ grouped: dict[str, tuple[int, float, int]] = {}
+ for call in calls:
+ head = _text(call.get("stage")).partition(":")[0]
+ count, seconds, failed = grouped.get(head, (0, 0.0, 0))
+ grouped[head] = (count + 1, seconds + (_number(call.get("latency_ms")) or 0) / 1000,
+ failed + (0 if call.get("ok") else 1))
+ for head, name in STAGE_GROUPS:
+ if head not in grouped:
+ continue
+ count, seconds, failed = grouped[head]
+ layout.addWidget(self._stage_row(name, count, seconds, failed))
+
+ def _stage_row(self, name: str, count: int, seconds: float, failed: int) -> QWidget:
+ row = QWidget(self)
+ layout = QHBoxLayout(row)
+ layout.setContentsMargins(0, 5, 0, 5)
+ layout.setSpacing(10)
+ dot = QLabel("●", row)
+ tone = TECH_BLUE["rose_text"] if failed else TECH_BLUE["qwen_text"]
+ dot.setStyleSheet(f"color: {tone}; font-size: 9px;")
+ layout.addWidget(dot)
+ block = QVBoxLayout()
+ block.setSpacing(0)
+ title = QLabel(name, row)
+ title.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 12.5px; font-weight: 600;")
+ block.addWidget(title)
+ detail = QLabel(f"{count} 组" + (f" · {failed} 次未通过" if failed else ""), row)
+ detail.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11px;")
+ block.addWidget(detail)
+ layout.addLayout(block, 1)
+ time = QLabel(f"{seconds:.0f} 秒", row)
+ time.setStyleSheet(f"color: {TECH_BLUE['text']}; font-size: 12px;")
+ layout.addWidget(time)
+ return row
+
+
+class HistoryPage(QWidget):
+ """Every saved batch of this prescription: the trend on the left, the reasons on the right."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(14, 12, 14, 10)
+ layout.setSpacing(12)
+
+ left, left_layout = _card(self)
+ head = QHBoxLayout()
+ head.setSpacing(10)
+ self.chart_title = _title("一致度趋势", "按时间从左到右 · 不可比的批次留空", left)
+ head.addWidget(self.chart_title, 1)
+ legend = QLabel(
+ f"■ {MODEL_NAMES['qwen']}"
+ f" ■ {MODEL_NAMES['openai']}", left)
+ legend.setStyleSheet("font-size: 11.5px;")
+ head.addWidget(legend)
+ left_layout.addLayout(head)
+ self.chart = TrendBars(left)
+ self.chart.setMaximumHeight(320)
+ left_layout.addWidget(self.chart, 1)
+ left_layout.addStretch(0)
+ self.strata = QLabel("", left)
+ self.strata.setWordWrap(True)
+ self.strata.setStyleSheet(
+ f"background: {TECH_BLUE['surface_2']}; border: 1px solid {TECH_BLUE['line']}; border-radius: 12px; padding: 10px 12px;"
+ f" color: {TECH_BLUE['muted']}; font-size: 11.5px;")
+ left_layout.addWidget(self.strata)
+ layout.addWidget(left, 5)
+
+ right, right_layout = _card(self)
+ right.setMinimumWidth(420)
+ right_layout.addWidget(_title("批次列表", "保留全部历史", right))
+ self.table = _table(["批次", "时间", "状态 / 原因", MODEL_NAMES["qwen"], MODEL_NAMES["openai"], "算法"])
+ right_layout.addWidget(self.table, 1)
+ self.note = QLabel(
+ "进入医生维度统计的只有首次独立提交的批次;重试、重新分析与算法升级都不会重复增加样本。"
+ "跨算法或提示词版本的分数分层展示,不能直接相减。", right)
+ self.note.setWordWrap(True)
+ self.note.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;")
+ right_layout.addWidget(self.note)
+ layout.addWidget(right, 4)
+
+ @staticmethod
+ def _score(model: Mapping[str, Any]) -> float | None:
+ comparison = _mapping(model.get("comparison"))
+ status = comparison.get("status") or model.get("comparison_status")
+ if status != "comparable":
+ return None
+ return _number(comparison.get("score", model.get("score")))
+
+ @staticmethod
+ def _version(entry: Mapping[str, Any]) -> str:
+ models = _mapping(entry.get("models"))
+ for key in ("qwen", "openai"):
+ version = _text(_mapping(models.get(key)).get("algorithm_version"))
+ if version:
+ return version.replace("prescription-soft-dice-", "")
+ return "—"
+
+ @staticmethod
+ def _prompt(entry: Mapping[str, Any]) -> str:
+ models = _mapping(entry.get("models"))
+ for key in ("qwen", "openai"):
+ version = _text(_mapping(models.get(key)).get("prompt_version"))
+ if version:
+ return version
+ return ""
+
+ def set_history(self, rows: list[Any]) -> None:
+ entries = [entry for entry in (_mapping(row) for row in rows) if entry]
+ entries.sort(key=lambda entry: (_text(entry.get("created_at")), _number(entry.get("id")) or 0))
+ points = []
+ for entry in entries:
+ models = _mapping(entry.get("models"))
+ point = {"label": "#" + _text(entry.get("id") or entry.get("batch_id"))}
+ for key in ("qwen", "openai"):
+ point[key] = self._score(_mapping(models.get(key)))
+ points.append(point)
+ self.chart.set_points(points)
+ self.chart_title.findChildren(QLabel)[-1].setText(
+ f"{len(entries)} 个批次 · 不可比的批次留空" if entries else "尚无历史批次")
+
+ self.table.setRowCount(len(entries))
+ for index, entry in enumerate(reversed(entries)):
+ models = _mapping(entry.get("models"))
+ self.table.setItem(index, 0, _item("#" + _text(entry.get("id") or entry.get("batch_id"))))
+ self.table.setItem(index, 1, _item(_text(entry.get("created_at"))[-8:] or "—"))
+ reason = _label(entry.get("validity")) or _label(entry.get("status")) or "—"
+ detail = _reason_text(entry)
+ state = _item(reason if not detail else f"{reason}\n{detail}", tooltip=detail)
+ self.table.setItem(index, 2, state)
+ for column, key in ((3, "qwen"), (4, "openai")):
+ score = self._score(_mapping(models.get(key)))
+ self.table.setItem(index, column, _item("—" if score is None else f"{score:.1f}%", align_right=True))
+ self.table.setItem(index, 5, _item(self._version(entry)))
+
+ strata: list[str] = []
+ for previous, current in zip(entries, entries[1:], strict=False):
+ changes = []
+ if self._version(previous) != self._version(current):
+ changes.append(f"比较算法 {self._version(previous)} → {self._version(current)}")
+ if self._prompt(previous) and self._prompt(previous) != self._prompt(current):
+ changes.append(f"提示词 {self._prompt(previous)} → {self._prompt(current)}")
+ if changes:
+ strata.append(
+ f"#{_text(previous.get('id'))} 与 #{_text(current.get('id'))} 之间:" + ";".join(changes)
+ + "。两侧分数属于不同分层,不能直接相减。")
+ self.strata.setText("\n".join(strata) if strata
+ else "全部批次使用同一套算法与提示词版本,分数可以直接比较。" if entries
+ else "尚无历史批次可供比较。")
+
+
+def _reason_text(entry: Mapping[str, Any]) -> str:
+ """Why this batch exists or why it has no score — only what the record actually says."""
+
+ for field in ("invalid_reason", "regenerate_reason", "error_message", "error_code"):
+ value = _text(entry.get(field))
+ if value:
+ return _label(value) or value
+ models = _mapping(entry.get("models"))
+ for key in ("qwen", "openai"):
+ model = _mapping(models.get(key))
+ reason = _text(model.get("error_message") or model.get("error_code"))
+ if reason:
+ return f"{MODEL_NAMES[key]}:{_label(reason) or reason}"
+ return ""
+
+
+class DistributionBars(QWidget):
+ """Agreement histogram: one pair of bars per 20% bin, counts printed on the axis."""
+
+ BINS = ("[0,20)", "[20,40)", "[40,60)", "[60,80)", "[80,100]")
+ LABELS = ("0–20%", "20–40%", "40–60%", "60–80%", "80–100%")
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._series: dict[str, list[int]] = {}
+ self.setMinimumHeight(190)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+
+ def set_series(self, series: Mapping[str, Mapping[str, Any]]) -> None:
+ self._series = {}
+ for key in ("qwen", "openai"):
+ bins = _mapping(_mapping(series).get(key))
+ if not bins:
+ continue
+ self._series[key] = [int(_number(bins.get(name)) or 0) for name in self.BINS]
+ described = " · ".join(
+ f"{MODEL_NAMES[key]} " + "/".join(str(value) for value in values)
+ for key, values in self._series.items())
+ self.setAccessibleDescription("一致度分布(按 20% 分箱):" + (described or "暂无样本"))
+ self.setToolTip("\n".join(
+ f"{MODEL_NAMES[key]}:" + ",".join(f"{label} {count} 例"
+ for label, count in zip(self.LABELS, values, strict=True))
+ for key, values in self._series.items()) or "暂无样本")
+ self.setToolTip("\n".join(
+ f"{MODEL_NAMES[key]}:" + ",".join(f"{label} {count} 例"
+ for label, count in zip(self.LABELS, values, strict=True))
+ for key, values in self._series.items()) or "暂无样本")
+ self.update()
+
+ def has_data(self) -> bool:
+ return any(any(values) for values in self._series.values())
+
+ def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
+ if not self.has_data():
+ return
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ font = QFont(self.font())
+ font.setPixelSize(10)
+ painter.setFont(font)
+ left, right, top, bottom = 44.0, self.width() - 8.0, 8.0, self.height() - 22.0
+ ceiling = max(1, max(value for values in self._series.values() for value in values))
+ for fraction in (0.0, 0.5, 1.0):
+ y = bottom - (bottom - top) * fraction
+ painter.setPen(QColor(f"{TECH_BLUE['raised']}"))
+ painter.drawLine(QPointF(left, y), QPointF(right, y))
+ painter.setPen(QColor(TECH_BLUE["muted"]))
+ painter.drawText(QRectF(0, y - 8, left - 6, 16),
+ Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
+ f"{ceiling * fraction:.0f}" + (" 例" if fraction == 1.0 else ""))
+ slot = (right - left) / len(self.BINS)
+ width = min(16.0, slot / 3)
+ for index, label in enumerate(self.LABELS):
+ centre = left + slot * (index + 0.5)
+ for offset, key in ((-width * 0.6, "qwen"), (width * 0.6, "openai")):
+ values = self._series.get(key)
+ if not values:
+ continue
+ height = (bottom - top) * values[index] / ceiling
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor(ACCENTS[key]))
+ painter.drawRoundedRect(QRectF(centre + offset - width / 2, bottom - height, width, height), 3, 3)
+ painter.setPen(QColor(TECH_BLUE["muted"]))
+ painter.drawText(QRectF(centre - slot / 2, bottom + 3, slot, 16),
+ Qt.AlignmentFlag.AlignCenter, label)
+ painter.end()
+
+
+class FunnelBar(QWidget):
+ """A stage of the sample funnel: the label sits inside the bar it belongs to."""
+
+ def __init__(self, text: str, fraction: float, colour: str, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._text, self._fraction, self._colour = text, max(0.0, min(1.0, fraction)), colour
+ self.setFixedHeight(22)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ self.setAccessibleDescription(text)
+
+ def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor(f"{TECH_BLUE['surface_2']}"))
+ painter.drawRoundedRect(QRectF(0, 0, self.width(), self.height()), 8, 8)
+ filled = max(2.0, self.width() * self._fraction)
+ painter.setBrush(QColor(self._colour))
+ painter.drawRoundedRect(QRectF(0, 0, filled, self.height()), 8, 8)
+ light = self._colour not in {f"{TECH_BLUE['raised']}"}
+ painter.setPen(QColor(f"{TECH_BLUE['surface']}" if light else f"{TECH_BLUE['text']}"))
+ painter.drawText(QRectF(10, 0, self.width() - 20, self.height()),
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._text)
+ painter.end()
+
+
+class StatisticsPanel(QWidget):
+ """Doctor-level agreement: headline counts, distribution, sample funnel and per-doctor rows."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(10)
+ self.kpis = QHBoxLayout()
+ self.kpis.setSpacing(12)
+ self.kpi_values: dict[str, QLabel] = {}
+ self.kpi_notes: dict[str, QLabel] = {}
+ for key, caption in (("events", "合格开方事件"), ("qwen", f"有效比较 · {MODEL_NAMES['qwen']}"),
+ ("openai", f"有效比较 · {MODEL_NAMES['openai']}"), ("review", "专家复核合格率")):
+ card, card_layout = _card(self)
+ title = QLabel(caption, card)
+ title.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11px;")
+ value = QLabel("—", card)
+ value.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 26px; font-weight: 700;")
+ note = QLabel("", card)
+ note.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;")
+ note.setWordWrap(True)
+ card_layout.addWidget(title)
+ card_layout.addWidget(value)
+ card_layout.addWidget(note)
+ card_layout.addStretch(1)
+ self.kpi_values[key] = value
+ self.kpi_notes[key] = note
+ self.kpis.addWidget(card, 1)
+ layout.addLayout(self.kpis)
+
+ middle = QHBoxLayout()
+ middle.setSpacing(12)
+ chart_card, chart_layout = _card(self)
+ chart_card.setMaximumHeight(360)
+ head = QHBoxLayout()
+ head.setSpacing(10)
+ self.chart_title = _title("一致度分布", "按 20% 分箱", chart_card)
+ head.addWidget(self.chart_title, 1)
+ legend = QLabel(
+ f"■ {MODEL_NAMES['qwen']}"
+ f" ■ {MODEL_NAMES['openai']}", chart_card)
+ legend.setStyleSheet("font-size: 11.5px;")
+ head.addWidget(legend)
+ chart_layout.addLayout(head)
+ self.distribution = DistributionBars(chart_card)
+ chart_layout.addWidget(self.distribution, 1)
+ self.chart_empty = QLabel("尚无可比样本,暂不绘制分布。", chart_card)
+ self.chart_empty.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 12px;")
+ chart_layout.addWidget(self.chart_empty)
+ summary = QHBoxLayout()
+ summary.setSpacing(14)
+ self.summary_values: dict[str, QLabel] = {}
+ for key, caption in (("qwen", f"{MODEL_NAMES['qwen']} 均值 / 中位"),
+ ("openai", f"{MODEL_NAMES['openai']} 均值 / 中位"), ("paired", "配对共同样本")):
+ cell = QVBoxLayout()
+ cell.setSpacing(0)
+ head_label = QLabel(caption, chart_card)
+ head_label.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 10.5px;")
+ cell.addWidget(head_label)
+ value = QLabel("—", chart_card)
+ value.setTextFormat(Qt.TextFormat.PlainText)
+ value.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 17px; font-weight: 700;")
+ cell.addWidget(value)
+ self.summary_values[key] = value
+ summary.addLayout(cell, 1)
+ chart_layout.addLayout(summary)
+ middle.addWidget(chart_card, 5)
+
+ funnel_card, funnel_layout = _card(self)
+ funnel_layout.setSpacing(6)
+ funnel_layout.addWidget(_title("样本口径", "排除项会影响代表性", parent=funnel_card))
+ self.funnel_rows = QWidget(funnel_card)
+ self.funnel_layout = QVBoxLayout(self.funnel_rows)
+ self.funnel_layout.setContentsMargins(0, 2, 0, 0)
+ self.funnel_layout.setSpacing(6)
+ funnel_layout.addWidget(self.funnel_rows)
+ funnel_layout.addWidget(_title("排除原因", "合计全部医生", parent=funnel_card))
+ self.exclusion_rows = QWidget(funnel_card)
+ self.exclusion_layout = QVBoxLayout(self.exclusion_rows)
+ self.exclusion_layout.setContentsMargins(0, 2, 0, 0)
+ self.exclusion_layout.setSpacing(6)
+ funnel_layout.addWidget(self.exclusion_rows)
+ self.warning = QLabel(
+ "一致度不是医生准确率:分数低可能只是治疗思路不同,分数高也不代表安全或有效。"
+ "要得到“准确率”,需要按机构规则做独立专家复核抽样;在建立复核样本前,本页不显示该数值。", funnel_card)
+ self.warning.setWordWrap(True)
+ self.warning.setStyleSheet(
+ f"background: {TECH_BLUE['amber_dim']}; border: 1px solid {TECH_BLUE['amber']}; border-radius: 12px; padding: 8px 10px;"
+ f" color: {TECH_BLUE['amber_text']}; font-size: 11.5px;")
+ funnel_layout.addWidget(self.warning)
+ middle.addWidget(funnel_card, 4)
+ layout.addLayout(middle, 1)
+
+ table_card, table_layout = _card(self)
+ table_layout.addWidget(_title("按医生", "小样本不做排名", parent=table_card))
+ self.doctors = _table(["医生", "开方", "患者", f"{MODEL_NAMES['qwen']} 有效/均值",
+ f"{MODEL_NAMES['openai']} 有效/均值", "配对样本", "复核合格率"])
+ self.doctors.setMinimumHeight(140)
+ table_layout.addWidget(self.doctors, 1)
+ layout.addWidget(table_card, 1)
+ self.footnote = QLabel(
+ "一致度不是医生准确率:分数低可能只是治疗思路不同,分数高也不代表安全或有效;"
+ "专家复核合格率需要独立复核样本,未建立前显示“—”。", self)
+ self.footnote.setWordWrap(True)
+ self.footnote.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;")
+ layout.addWidget(self.footnote)
+
+ def set_statistics(self, result: Mapping[str, Any] | None) -> None:
+ data = _mapping(result)
+ doctors = [_mapping(row) for row in (data.get("doctors") or [])]
+ events = int(_number(data.get("total_count")) or 0)
+ patients = int(_number(data.get("patient_count")) or 0)
+ self.kpi_values["events"].setText(str(events))
+ self.kpi_notes["events"].setText(f"涉及患者 {patients} 人")
+ totals = {"qwen": 0, "openai": 0}
+ exclusions: dict[str, int] = {}
+ bins: dict[str, dict[str, int]] = {"qwen": {}, "openai": {}}
+ evaluated = qualified = paired = 0
+ means: dict[str, list[float]] = {"qwen": [], "openai": []}
+ medians: dict[str, list[float]] = {"qwen": [], "openai": []}
+ for doctor in doctors:
+ models = _mapping(doctor.get("models"))
+ for key in ("qwen", "openai"):
+ model = _mapping(models.get(key))
+ totals[key] += int(_number(model.get("eligible_count")) or 0)
+ mean = _number(model.get("mean"))
+ if mean is not None:
+ means[key].append(mean)
+ median = _number(model.get("median"))
+ if median is not None:
+ medians[key].append(median)
+ for code, count in _mapping(model.get("excluded_reasons")).items():
+ exclusions[code] = exclusions.get(code, 0) + int(_number(count) or 0)
+ for name, count in _mapping(model.get("distribution")).items():
+ bins[key][name] = bins[key].get(name, 0) + int(_number(count) or 0)
+ review = _mapping(doctor.get("review"))
+ evaluated += int(_number(review.get("evaluated_count")) or 0)
+ qualified += int(_number(review.get("qualified_count")) or 0)
+ paired += int(_number(doctor.get("paired_count")) or 0)
+ for key in ("qwen", "openai"):
+ self.kpi_values[key].setText(str(totals[key]))
+ coverage = (totals[key] / events * 100) if events else None
+ average = (sum(means[key]) / len(means[key])) if means[key] else None
+ self.kpi_notes[key].setText(" · ".join(part for part in (
+ f"覆盖率 {coverage:.1f}%" if coverage is not None else "覆盖率 —",
+ f"均值 {average:.1f}%" if average is not None else "均值 —") if part))
+ middle_value = (sum(medians[key]) / len(medians[key])) if medians[key] else None
+ self.summary_values[key].setText(
+ (f"{average:.1f}%" if average is not None else "—") + " / "
+ + (f"{middle_value:.1f}%" if middle_value is not None else "—"))
+ self.summary_values["paired"].setText(f"{paired} 例" if paired else "—")
+ self.kpi_values["review"].setText(f"{qualified / evaluated * 100:.1f}%" if evaluated else "—")
+ self.kpi_notes["review"].setText(
+ f"已复核 {evaluated} 例" if evaluated else "尚未建立复核样本,不用一致度代替")
+
+ self.distribution.set_series(bins)
+ self.distribution.setVisible(self.distribution.has_data())
+ self.chart_empty.setVisible(not self.distribution.has_data())
+ self.chart_title.findChildren(QLabel)[-1].setText(
+ " / ".join(f"{MODEL_NAMES[key]} {totals[key]} 例" for key in ("qwen", "openai")) + " · 按 20% 分箱"
+ if events else "按 20% 分箱")
+
+ stages = [("范围内开方事件", events)]
+ for key in ("qwen", "openai"):
+ stages.append((f"{MODEL_NAMES[key]} 有效基线比较", totals[key]))
+ stages.append(("两模型配对共同样本", paired))
+ self._fill(self.funnel_rows, self.funnel_layout, [(name, value, TECH_BLUE["accent"]) for name, value in stages], events)
+
+ ordered = sorted(exclusions.items(), key=lambda pair: (-pair[1], pair[0]))
+ largest = max([count for _code, count in ordered], default=0)
+ self._fill(self.exclusion_rows, self.exclusion_layout,
+ [(_label(code) or code, count, f"{TECH_BLUE['raised']}") for code, count in ordered], largest)
+
+ self.doctors.setRowCount(len(doctors))
+ for index, doctor in enumerate(doctors):
+ models = _mapping(doctor.get("models"))
+ review = _mapping(doctor.get("review"))
+ self.doctors.setItem(index, 0, _item(_text(doctor.get("doctor_name")) or _text(doctor.get("doctor_id"))))
+ self.doctors.setItem(index, 1, _item(str(int(_number(doctor.get("total_count")) or 0)), align_right=True))
+ self.doctors.setItem(index, 2, _item(str(int(_number(doctor.get("patient_count")) or 0)), align_right=True))
+ for column, key in ((3, "qwen"), (4, "openai")):
+ model = _mapping(models.get(key))
+ eligible = int(_number(model.get("eligible_count")) or 0)
+ mean = _number(model.get("mean"))
+ self.doctors.setItem(index, column, _item(
+ f"{eligible} / {mean:.1f}%" if mean is not None else f"{eligible} / —", align_right=True))
+ self.doctors.setItem(index, 5, _item(str(int(_number(doctor.get("paired_count")) or 0)), align_right=True))
+ rate = _number(review.get("qualified_rate"))
+ self.doctors.setItem(index, 6, _item("—" if rate is None else f"{rate:.1f}%"))
+
+ @staticmethod
+ def _fill(container: QWidget, layout: QVBoxLayout, rows: list[tuple[str, int, str]], largest: int) -> None:
+ while layout.count():
+ item = layout.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.setParent(None)
+ widget.deleteLater()
+ for name, value, colour in rows:
+ layout.addWidget(FunnelBar(f"{name} {value}", value / largest if largest else 0.0, colour))
+ # Bars have a fixed height; without a floor on the holder the card squeezes them together.
+ container.setMinimumHeight(len(rows) * 28 + 2 if rows else 0)
diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_theme.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_theme.py
new file mode 100644
index 000000000..f003977b1
--- /dev/null
+++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_theme.py
@@ -0,0 +1,210 @@
+"""The analysis console's palette, and the switch between its dark and light themes.
+
+The prescription analysis window has its own theme, separate from the workstation's light chrome:
+tech blue for the interface itself, and two model hues (cyan for 千问, violet for OpenAI) that stay
+apart from the interface colour and from each other. Severity keeps amber and rose, so a reading
+never depends on hue alone.
+
+``CONSOLE`` is the active palette and is mutated in place by :func:`use_theme`, so every module
+that imported it sees the new values. Anything derived from it — a stylesheet string, a colour
+constant — must be rebuilt in a callback registered through :func:`on_theme_changed`.
+
+Key names mirror ``reception_style.TECH_BLUE`` so a widget can take either palette.
+"""
+
+from __future__ import annotations
+
+import ctypes
+import sys
+from collections.abc import Callable
+from typing import Any
+
+from PySide6.QtGui import QFont
+
+DARK: dict[str, str] = {
+ # ground and cards
+ "canvas": "#080D18",
+ "canvas_soft": "#0C1322",
+ "surface": "#0F1726",
+ "surface_2": "#131D2E",
+ "raised": "#1A2637",
+ "line": "#22304A",
+ "line_soft": "#18233A",
+ # type
+ "heading": "#E8EFFB",
+ "text": "#B6C5DA",
+ "muted": "#8195AF",
+ "faint": "#6A7F99",
+ "selected_text": "#E8EFFB",
+ # interface colour
+ "accent": "#2E7BF6",
+ "accent_text": "#6BA5FF",
+ "accent_pressed": "#1A5FD0",
+ "selection": "#142645",
+ "selection_line": "#1B3E77",
+ # model hues
+ "qwen": "#17BFDD",
+ "qwen_text": "#55D8EE",
+ "qwen_dim": "#10303C",
+ "openai": "#9B7BFF",
+ "openai_text": "#B69FFF",
+ "openai_dim": "#241F48",
+ # severity
+ "amber": "#F5B942",
+ "amber_text": "#F8C661",
+ "amber_dim": "#2C2415",
+ "rose": "#F4697A",
+ "rose_text": "#F88694",
+ "rose_dim": "#2C1620",
+ "ok": "#2E7BF6",
+ "zebra": "#0E1626",
+ "grid_line": "#191F2C",
+}
+
+LIGHT: dict[str, str] = {
+ "canvas": "#F1F5FC",
+ "canvas_soft": "#E7EEFA",
+ "surface": "#FFFFFF",
+ "surface_2": "#F5F8FE",
+ "raised": "#E8EFFA",
+ "line": "#D5E1F2",
+ "line_soft": "#E3EBF8",
+ "heading": "#0A182E",
+ "text": "#2A3C56",
+ "muted": "#54697F",
+ "faint": "#7A8DA3",
+ "selected_text": "#0A182E",
+ "accent": "#1A5FD0",
+ "accent_text": "#124DAE",
+ "accent_pressed": "#0E3F90",
+ "selection": "#E8EFFA",
+ "selection_line": "#AEC7EE",
+ "qwen": "#0C89AC",
+ "qwen_text": "#086C88",
+ "qwen_dim": "#E2F5FA",
+ "openai": "#6B4AE0",
+ "openai_text": "#5537C6",
+ "openai_dim": "#EDE8FE",
+ "amber": "#B4791A",
+ "amber_text": "#8F5F0B",
+ "amber_dim": "#FCF2DF",
+ "rose": "#D0435A",
+ "rose_text": "#AE3149",
+ "rose_dim": "#FCE9EC",
+ "ok": "#1A5FD0",
+ "zebra": "#FAFBFE",
+ "grid_line": "#DDE4EE",
+}
+
+CONSOLE: dict[str, str] = dict(DARK)
+_THEME = "dark"
+_LISTENERS: list[Callable[[], None]] = []
+
+
+def current_theme() -> str:
+ return _THEME
+
+
+def on_theme_changed(callback: Callable[[], None]) -> Callable[[], None]:
+ """Register a rebuild for anything derived from the palette; returns the callback."""
+
+ _LISTENERS.append(callback)
+ return callback
+
+
+def use_theme(name: str) -> str:
+ """Switch the active palette in place and let every derived value rebuild itself."""
+
+ global _THEME
+ palette = LIGHT if name == "light" else DARK
+ _THEME = "light" if name == "light" else "dark"
+ CONSOLE.clear()
+ CONSOLE.update(palette)
+ _rebuild()
+ return _THEME
+
+
+def toggle_theme() -> str:
+ return use_theme("light" if _THEME == "dark" else "dark")
+
+
+def _rebuild() -> None:
+ for callback in list(_LISTENERS):
+ callback()
+
+
+MODEL_HUE = {"qwen": CONSOLE["qwen"], "openai": CONSOLE["openai"]}
+MODEL_TEXT = {"qwen": CONSOLE["qwen_text"], "openai": CONSOLE["openai_text"]}
+MODEL_DIM = {"qwen": CONSOLE["qwen_dim"], "openai": CONSOLE["openai_dim"]}
+
+
+@on_theme_changed
+def _rebuild_model_hues() -> None:
+ MODEL_HUE.update({"qwen": CONSOLE["qwen"], "openai": CONSOLE["openai"]})
+ MODEL_TEXT.update({"qwen": CONSOLE["qwen_text"], "openai": CONSOLE["openai_text"]})
+ MODEL_DIM.update({"qwen": CONSOLE["qwen_dim"], "openai": CONSOLE["openai_dim"]})
+
+
+RADIUS = 10
+RADIUS_SM = 7
+
+# The design sets numbers in a condensed face so columns of figures line up; the fallbacks keep
+# the same tabular behaviour when Bahnschrift is missing.
+NUM_FAMILIES = ("Bahnschrift", "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei")
+
+
+def num_font(size: int, *, weight: QFont.Weight | None = None) -> QFont:
+ """A tabular face for figures, at the pixel size the design states."""
+
+ font = QFont()
+ font.setFamilies(list(NUM_FAMILIES))
+ font.setPixelSize(size)
+ if weight is not None:
+ font.setWeight(weight)
+ font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias)
+ return font
+
+
+# Windows draws the title bar itself, so the console's dark ground stops at the frame unless the
+# window asks DWM for a matching caption. Windows 11 (build 22000+) honours these attributes;
+# anywhere else the call fails and the native bar is left as it is.
+_DWMWA_USE_IMMERSIVE_DARK_MODE = 20
+_DWMWA_BORDER_COLOR = 34
+_DWMWA_CAPTION_COLOR = 35
+_DWMWA_TEXT_COLOR = 36
+
+
+def _colorref(value: str) -> int:
+ """A ``#RRGGBB`` string as the ``0x00BBGGRR`` integer DWM expects."""
+
+ colour = value.lstrip("#")
+ red, green, blue = (int(colour[index:index + 2], 16) for index in (0, 2, 4))
+ return (blue << 16) | (green << 8) | red
+
+
+def apply_window_chrome(widget: Any) -> bool:
+ """Paint the native title bar in the palette that is active right now.
+
+ Returns whether DWM accepted the change, so a caller can tell "not Windows 11" from "done".
+ """
+
+ if sys.platform != "win32":
+ return False
+ handle = int(widget.winId())
+ if not handle:
+ return False
+ try:
+ dwm = ctypes.windll.dwmapi
+ except (AttributeError, OSError): # pragma: no cover - not Windows
+ return False
+ dark = ctypes.c_int(1 if _THEME == "dark" else 0)
+ caption = ctypes.c_uint(_colorref(CONSOLE["canvas"]))
+ text = ctypes.c_uint(_colorref(CONSOLE["heading"]))
+ border = ctypes.c_uint(_colorref(CONSOLE["line"]))
+ applied = False
+ for attribute, value in ((_DWMWA_USE_IMMERSIVE_DARK_MODE, dark), (_DWMWA_CAPTION_COLOR, caption),
+ (_DWMWA_TEXT_COLOR, text), (_DWMWA_BORDER_COLOR, border)):
+ result = dwm.DwmSetWindowAttribute(ctypes.c_void_p(handle), ctypes.c_int(attribute),
+ ctypes.byref(value), ctypes.sizeof(value))
+ applied = applied or result == 0
+ return applied
diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_workspace.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_workspace.py
new file mode 100644
index 000000000..8797f9451
--- /dev/null
+++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_workspace.py
@@ -0,0 +1,1307 @@
+"""Dose comparison with a persistent review rail and expandable supporting diagrams."""
+
+from __future__ import annotations
+
+from collections import Counter
+from collections.abc import Mapping
+from copy import deepcopy
+from dataclasses import dataclass, field
+from decimal import Decimal
+from types import SimpleNamespace
+from typing import Any
+
+from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
+from PySide6.QtGui import QColor, QFont, QPainter, QPaintEvent
+from PySide6.QtWidgets import (
+ QFrame,
+ QGridLayout,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QScrollArea,
+ QSizePolicy,
+ QVBoxLayout,
+ QWidget,
+)
+
+from ..reception_style import body_family
+from .issued_prescription_ai_comparison import (
+ MODEL_NAMES,
+ PrescriptionComparisonPanel,
+ _Dose,
+ _mapping,
+ _reason,
+ _Row,
+ _saved_rows,
+ _text,
+)
+from .issued_prescription_ai_pages import SourceBar, WrappedLabel, _label, _number
+from .issued_prescription_ai_progress import ACTIVE_STATES, SUCCESS_STATES
+from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE
+from .issued_prescription_ai_theme import num_font, on_theme_changed
+
+# The console gives each model its own hue, away from the interface blue and from each other.
+COLORS = {"doctor": TECH_BLUE["muted"], "qwen": TECH_BLUE["qwen"], "openai": TECH_BLUE["openai"],
+ "accent": TECH_BLUE["accent"], "muted": TECH_BLUE["muted"]}
+DANGER, WARN, MUTED_DOT = TECH_BLUE["rose_text"], TECH_BLUE["amber_text"], TECH_BLUE["muted"]
+
+
+@on_theme_changed
+def _rebuild_colours() -> None:
+ global DANGER, WARN, MUTED_DOT
+ COLORS.update({"doctor": TECH_BLUE["muted"], "qwen": TECH_BLUE["qwen"],
+ "openai": TECH_BLUE["openai"], "accent": TECH_BLUE["accent"],
+ "muted": TECH_BLUE["muted"]})
+ DANGER, WARN, MUTED_DOT = TECH_BLUE["rose_text"], TECH_BLUE["amber_text"], TECH_BLUE["muted"]
+
+
+@dataclass
+class ReviewDoseRow:
+ """One saved correspondence; model rows only merge on a unique persisted key."""
+
+ token: str
+ entries: dict[str, _Row] = field(default_factory=dict)
+ enabled: dict[str, bool] = field(default_factory=dict)
+
+ @property
+ def name(self) -> str:
+ return next(iter(self.entries.values())).name
+
+ @property
+ def doctor(self) -> _Dose | None:
+ return next(iter(self.entries.values())).doctor
+
+ @property
+ def doses(self) -> dict[str, _Dose | None]:
+ return {"doctor": self.doctor, **{key: row.candidate for key, row in self.entries.items()}}
+
+ @property
+ def scale(self) -> tuple[str, str] | None:
+ if not all(self.enabled.values()):
+ return None
+ scales = {row.scale for row in self.entries.values()}
+ doses = [dose for dose in self.doses.values() if dose is not None]
+ if None in scales or len(scales) != 1 or not doses:
+ return None
+ if any(dose.number is None for dose in doses) or len({dose.identity for dose in doses}) != 1:
+ return None
+ return next(iter(scales))
+
+ @property
+ def changed(self) -> bool:
+ if self.scale is None:
+ return False
+ return any(row.doctor is not None and row.candidate is not None and row.doctor.number != row.candidate.number for row in self.entries.values())
+
+ @property
+ def description(self) -> str:
+ return "\n".join(row.description(MODEL_NAMES[key]) for key, row in self.entries.items())
+
+
+def review_rows(batch: dict[str, Any]) -> tuple[list[ReviewDoseRow], dict[str, str]]:
+ """Keep every candidate, including unnormalized originals and ambiguous duplicates."""
+ result: list[ReviewDoseRow] = []
+ keyed: dict[str, ReviewDoseRow] = {}
+ states: dict[str, str] = {}
+ for model_key in MODEL_NAMES:
+ model = _mapping(_mapping(batch.get("models")).get(model_key))
+ candidate, comparison = _mapping(model.get("candidate")), _mapping(model.get("comparison"))
+ # The existing comparison state machine is deliberately shared; no UI is constructed.
+ enabled, message = PrescriptionComparisonPanel._state(
+ SimpleNamespace(_batch=batch, _model_key=model_key), model, candidate, comparison,
+ )
+ # An unspecified validity is suitable for reading, never for the combined plot.
+ enabled = enabled and batch.get("validity") in {"current", "valid"}
+ if batch and batch.get("validity") not in {"current", "valid"} and "未注明" in message:
+ message = "报告有效性未确认;仅显示已保存剂量。"
+ states[model_key] = message.replace("条形", "图形")
+ saved = comparison.get("rows")
+ originals = [dict(row) for row in saved if isinstance(row, Mapping)] if isinstance(saved, list) else []
+ counts = Counter(_text(row.get("key")) for row in originals)
+ for index, row in enumerate(_saved_rows(candidate, comparison)):
+ key = _text(originals[index].get("key")) if index < len(originals) else ""
+ reliable = bool(key) and counts[key] == 1 and row.origin == "comparison"
+ previous = keyed.get(key) if reliable else None
+ first = next(iter(previous.entries.values())) if previous else None
+ # A key alone cannot override contradictory names, baseline snapshots or units.
+ can_join = first is not None and first.name == row.name and first.doctor == row.doctor
+ if can_join:
+ first_doses = [dose for dose in (first.doctor, first.candidate, row.candidate) if dose is not None]
+ can_join = len({dose.identity for dose in first_doses}) == 1 and len({dose.scale for dose in first_doses}) == 1
+ if can_join and previous is not None:
+ previous.entries[model_key] = row
+ previous.enabled[model_key] = enabled
+ else:
+ item = ReviewDoseRow(f"{model_key}:{index}:{key}", {model_key: row}, {model_key: enabled})
+ result.append(item)
+ if reliable:
+ keyed[key] = item
+ return result, states
+
+
+def diagnosis_text(model: dict[str, Any]) -> str:
+ """Only the diagnosis field is diagnostic evidence; a summary is never a diagnosis."""
+ diagnosis = _mapping(model.get("report")).get("diagnosis")
+ if isinstance(diagnosis, Mapping):
+ labels = {"western_diagnosis": "西医诊断", "tcm_diagnosis": "中医诊断", "syndrome": "辨证"}
+ lines = [f"{label}:{_text(diagnosis.get(key))}" for key, label in labels.items() if _text(diagnosis.get(key))]
+ return "\n".join(lines) if lines else "诊断意见未保存"
+ prose = _text(diagnosis)
+ return prose or "诊断意见未保存"
+
+
+def _model_status(model: dict[str, Any]) -> str:
+ status = _text(model.get("status"))
+ if status in ACTIVE_STATES:
+ return "生成中"
+ if status in SUCCESS_STATES:
+ return "已完成"
+ return {"partial": "部分完成", "failed": "分析失败", "cancelled": "已取消", "canceled": "已取消", "blocked": "已暂停"}.get(status, "尚无报告")
+
+
+def _usage_text(prescription: dict[str, Any]) -> str:
+ parts = [_text(prescription.get("usage_instruction"))]
+ for key, pattern in (("times_per_day", "每日 {} 次"), ("usage_days", "共 {} 天"),
+ ("dosage_amount", "用量 {}"), ("dosage_bag_count", "每次 {} 包"),
+ ("bags_per_dose", "每剂 {} 包")):
+ if _text(prescription.get(key)):
+ parts.append(pattern.format(prescription[key]))
+ parts.extend(_text(prescription.get(key)) for key in ("usage_time", "usage_way", "usage_notes", "dietary_taboo"))
+ return " · ".join(filter(None, parts))
+
+
+def _display_dose(dose: _Dose | None, *, compact: bool) -> str:
+ if dose is None or not _text(dose.raw):
+ return "—"
+ return f"{_text(dose.raw)} {dose.unit}" if compact else dose.label
+
+
+class DoseDelta:
+ """One herb's distance from the doctor's dose, per model, on a shared gram scale."""
+
+ def __init__(self, name: str, doctor: Decimal | None, unit: str) -> None:
+ self.name, self.doctor, self.unit = name, doctor, unit
+ self.doses: dict[str, Decimal | None] = {"qwen": None, "openai": None}
+ self.present: dict[str, bool] = {"qwen": False, "openai": False}
+
+ @property
+ def deltas(self) -> dict[str, Decimal | None]:
+ result: dict[str, Decimal | None] = {}
+ for key, dose in self.doses.items():
+ if dose is None:
+ result[key] = None
+ elif self.doctor is None:
+ result[key] = dose
+ else:
+ result[key] = dose - self.doctor
+ return result
+
+ @property
+ def reach(self) -> Decimal:
+ return max((abs(value) for value in self.deltas.values() if value is not None), default=Decimal(0))
+
+ @property
+ def badge(self) -> str:
+ missing = [MODEL_NAMES[key] for key in ("qwen", "openai") if self.doses[key] is None]
+ if len(missing) == 2:
+ return "两模型均未收录"
+ if missing:
+ return f"{missing[0]} 未收录"
+ if all(value == 0 for value in self.deltas.values()) and self.doctor is not None:
+ return f"两模型一致 {_plain(self.doctor)} {self.unit}"
+ return ""
+
+ @property
+ def description(self) -> str:
+ parts = [self.name]
+ for key in ("qwen", "openai"):
+ value = self.deltas[key]
+ parts.append(f"{MODEL_NAMES[key]} " + ("未收录" if value is None else f"{_signed(value)}{self.unit}"))
+ return " ".join(parts)
+
+
+def _plain(value: Decimal) -> str:
+ text = format(value.normalize(), "f")
+ return text.rstrip(".") if "." in text else text
+
+
+def _signed(value: Decimal) -> str:
+ if value == 0:
+ return "一致"
+ return ("+" if value > 0 else "−") + _plain(abs(value))
+
+
+def plot_scale(row: ReviewDoseRow) -> tuple[str, str] | None:
+ """Like ``ReviewDoseRow.scale``, except a herb a model simply never listed is allowed.
+
+ An absent candidate is a fact worth drawing ("未收录"); a saved dose that cannot be read as a
+ number is not, so any unreadable value still takes the whole row off the chart.
+ """
+
+ if not row.enabled or not all(row.enabled.values()):
+ return None
+ scales = {entry.scale for entry in row.entries.values()}
+ if None in scales or len(scales) != 1:
+ return None
+ present = [dose for dose in row.doses.values() if dose is not None]
+ numbers = [dose for dose in present if dose.number is not None]
+ if not numbers or len(numbers) != len(present):
+ return None
+ if len({dose.identity for dose in numbers}) != 1:
+ return None
+ return next(iter(scales))
+
+
+def dose_deltas(rows: list[ReviewDoseRow]) -> list[DoseDelta]:
+ """Only comparable rows carry a delta; a row without a shared scale is left out."""
+
+ result: list[DoseDelta] = []
+ for row in rows:
+ scale = plot_scale(row)
+ if scale is None:
+ continue
+ doctor = row.doctor.number if row.doctor is not None else None
+ delta = DoseDelta(row.name, doctor, scale[0])
+ for key, entry in row.entries.items():
+ delta.present[key] = True
+ delta.doses[key] = entry.candidate.number if entry.candidate is not None else None
+ if doctor is None and not any(delta.doses.values()):
+ continue
+ result.append(delta)
+ result.sort(key=lambda item: (-item.reach, item.name))
+ return result
+
+
+def _axis_extent(deltas: list[DoseDelta]) -> Decimal:
+ """A round, symmetric scale so the two sides of the centre line stay comparable."""
+
+ reach = max((item.reach for item in deltas), default=Decimal(0))
+ if reach <= 0:
+ return Decimal(6)
+ step = Decimal(6)
+ while step < reach:
+ step += Decimal(6)
+ return step
+
+
+class _DoseAxis(QWidget):
+ """The plot area of one row: centre line, two bars, and a badge when a dose is absent."""
+
+ def __init__(self, delta: DoseDelta, extent: Decimal, parent: QWidget | None = None,
+ *, show_badge: bool = False) -> None:
+ super().__init__(parent)
+ self.delta, self.extent = delta, extent
+ self.show_badge = show_badge
+ self.setFixedHeight(30)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ self.setAccessibleName("剂量差异条")
+ self.setAccessibleDescription(delta.description)
+ self.setToolTip(delta.description)
+
+ def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ middle = self.width() / 2
+ painter.setPen(QColor(f"{TECH_BLUE['line_soft']}"))
+ for fraction in (0.25, 0.75):
+ painter.drawLine(QPointF(self.width() * fraction, 2), QPointF(self.width() * fraction, self.height() - 2))
+ painter.setPen(QColor("#C7D3E4"))
+ painter.drawLine(QPointF(middle, 0), QPointF(middle, self.height()))
+ painter.setPen(Qt.PenStyle.NoPen)
+ for index, key in enumerate(("qwen", "openai")):
+ value = self.delta.deltas[key]
+ if value is None or value == 0:
+ continue
+ width = float(abs(value) / self.extent) * (self.width() / 2 - 4)
+ top = 4 + index * 12
+ left = middle if value > 0 else middle - width
+ new_herb = self.delta.doctor is None
+ painter.setBrush(QColor("#A9CBF7" if new_herb and key == "qwen" else COLORS[key]))
+ painter.drawRoundedRect(QRectF(left, top, max(width, 3.0), 9), 4, 4)
+ badge = self.delta.badge if self.show_badge else ""
+ if badge:
+ painter.setFont(QFont(self.font().family(), 8))
+ metrics = painter.fontMetrics()
+ width = metrics.horizontalAdvance(badge) + 16
+ box = QRectF(middle + 6, (self.height() - 18) / 2, width, 18)
+ agreed = badge.startswith("两模型一致")
+ painter.setBrush(QColor("#E9F8EF" if agreed else f"{TECH_BLUE['surface_2']}"))
+ painter.setPen(QColor(f"{TECH_BLUE['qwen']}" if agreed else f"{TECH_BLUE['line']}"))
+ painter.drawRoundedRect(box, 7, 7)
+ painter.setPen(QColor(f"{TECH_BLUE['qwen_text']}" if agreed else MUTED_DOT))
+ painter.drawText(box, Qt.AlignmentFlag.AlignCenter, badge)
+ painter.end()
+
+
+class _AxisRuler(QWidget):
+ """The shared scale printed once, under the last row."""
+
+ def __init__(self, extent: Decimal, unit: str, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.extent, self.unit = extent, unit
+ self.setFixedHeight(16)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ self.setAccessibleDescription(f"刻度 −{_plain(extent)} 到 +{_plain(extent)} {unit}")
+
+ def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
+ painter = QPainter(self)
+ painter.setPen(QColor(TECH_BLUE["muted"]))
+ painter.setFont(QFont(self.font().family(), 8))
+ half = self.extent / 2
+ labels = ((0.0, f"−{_plain(self.extent)}"), (0.25, f"−{_plain(half)}"), (0.5, "0"),
+ (0.75, f"+{_plain(half)}"), (1.0, f"+{_plain(self.extent)}"))
+ for fraction, text in labels:
+ width = 90.0
+ left = self.width() * fraction - width / 2
+ align = Qt.AlignmentFlag.AlignCenter
+ if fraction == 0.0:
+ left, align = 0.0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
+ elif fraction == 1.0:
+ left, align = self.width() - width, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
+ painter.drawText(QRectF(left, 0, width, self.height()), align, text)
+ painter.end()
+
+
+def card_qss() -> str:
+ """Panel styles for the palette that is active right now."""
+
+ return f"""
+QFrame#AiPanel {{ background: {TECH_BLUE['surface']}; border: 1px solid {TECH_BLUE['line_soft']};
+ border-radius: 10px; }}
+QLabel#AiPanelTitle {{ color: {TECH_BLUE['heading']}; font-size: 13.5px; font-weight: 600; }}
+QLabel#AiPanelHint {{ color: {TECH_BLUE['faint']}; font-size: 10.6px; }}
+QLabel#AiPanelMore {{ color: {TECH_BLUE['muted']}; font-size: 10.6px; }}
+"""
+
+
+def _rule(parent: QWidget | None = None) -> QFrame:
+ """The hairline the design puts between list rows."""
+
+ line = QFrame(parent)
+ line.setFixedHeight(1)
+ line.setStyleSheet(f"background: {TECH_BLUE['line_soft']}; border: 0;")
+ return line
+
+
+def _card(parent: QWidget | None = None) -> tuple[QFrame, QVBoxLayout]:
+ frame = QFrame(parent)
+ frame.setObjectName("AiPanel")
+ layout = QVBoxLayout(frame)
+ layout.setContentsMargins(18, 15, 18, 18)
+ layout.setSpacing(10)
+ return frame, layout
+
+
+class HintLabel(QLabel):
+ """The quiet line beside a card title: it gives way before the card does, and elides."""
+
+ def __init__(self, text: str = "", parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._full = ""
+ self.setObjectName("AiPanelHint")
+ self.setTextFormat(Qt.TextFormat.PlainText)
+ self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
+ self.setMinimumWidth(0)
+ self.setText(text)
+
+ def setText(self, text: str) -> None: # noqa: N802 - Qt virtual
+ self._full = text
+ self.setToolTip(text)
+ self._elide()
+
+ def text(self) -> str:
+ return self._full
+
+ def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
+ super().resizeEvent(event)
+ self._elide()
+
+ def _elide(self) -> None:
+ width = max(0, self.width())
+ shown = self.fontMetrics().elidedText(self._full, Qt.TextElideMode.ElideRight, width) if width else ""
+ QLabel.setText(self, shown)
+
+
+def _panel_head(title: str, hint: str = "", more: str = "",
+ *, parent: QWidget | None = None) -> tuple[QWidget, HintLabel, QPushButton | None]:
+ """Title, quiet hint and an optional right-hand link — the header every card uses."""
+
+ holder = QWidget(parent)
+ row = QHBoxLayout(holder)
+ row.setContentsMargins(0, 0, 0, 0)
+ row.setSpacing(10)
+ label = QLabel(title, holder)
+ label.setObjectName("AiPanelTitle")
+ row.addWidget(label)
+ note = HintLabel(hint, holder)
+ row.addWidget(note, 1)
+ link: QPushButton | None = None
+ if more:
+ link = QPushButton(more, holder)
+ link.setObjectName("AiPanelMore")
+ link.setCursor(Qt.CursorShape.PointingHandCursor)
+ link.setFlat(True)
+ link.setStyleSheet(
+ f"QPushButton {{ border: 0; background: transparent; color: {TECH_BLUE['muted']}; font-size: 10.6px;"
+ " padding: 0; }"
+ f"QPushButton:hover {{ color: {TECH_BLUE['accent']}; }}")
+ row.addWidget(link)
+ return holder, note, link
+
+
+class DoseDiffPanel(QFrame):
+ """Every comparable herb as a distance from the doctor's dose, both models on one scale."""
+
+ NAME_WIDTH = 92
+ VALUE_WIDTH = 52
+ PLOT_FLOOR = 430
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("AiPanel")
+ self.rows: list[DoseDelta] = []
+ self._cells: list[tuple[DoseDelta, QLabel, _DoseAxis, QLabel, QLabel]] = []
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(18, 14, 18, 12)
+ layout.setSpacing(8)
+ head, self.scale_note, _link = _panel_head(
+ "剂量差异分布", "相对原生处方的剂量差异(克 / 每剂)", parent=self)
+ legend = QLabel(
+ f"● {MODEL_NAMES['qwen']}"
+ f" ● {MODEL_NAMES['openai']}", self)
+ legend.setStyleSheet("font-size: 12px;")
+ head.layout().addWidget(legend)
+ layout.addWidget(head)
+ self.scroll = QScrollArea(self)
+ self.scroll.setWidgetResizable(True)
+ self.scroll.setFrameShape(QFrame.Shape.NoFrame)
+ self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.body = QWidget(self.scroll)
+ self.body_layout = QVBoxLayout(self.body)
+ self.body_layout.setContentsMargins(0, 2, 0, 2)
+ self.body_layout.setSpacing(0)
+ self.scroll.setWidget(self.body)
+ layout.addWidget(self.scroll, 1)
+ self.empty = QLabel("分析完成后,在此查看每一味药与原处方的剂量差。", self)
+ self.empty.setWordWrap(True)
+ self.empty.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 12px;")
+ layout.addWidget(self.empty)
+ self.ruler_holder = QWidget(self)
+ ruler_row = QHBoxLayout(self.ruler_holder)
+ ruler_row.setContentsMargins(0, 0, 0, 0)
+ ruler_row.setSpacing(10)
+ self.ruler_left = QWidget(self.ruler_holder)
+ ruler_row.addWidget(self.ruler_left)
+ self.ruler = _AxisRuler(Decimal(24), "克", self.ruler_holder)
+ ruler_row.addWidget(self.ruler, 1)
+ self.ruler_caption = QLabel("剂量差异(克)", self.ruler_holder)
+ self.ruler_caption.setStyleSheet(f"color: {TECH_BLUE['faint']}; font-size: 9.9px;")
+ self.ruler_caption.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+ ruler_row.addWidget(self.ruler_caption)
+ layout.addWidget(self.ruler_holder)
+ self._apply_widths()
+
+ def narrow(self) -> bool:
+ return self.width() < self.PLOT_FLOOR
+
+ def _apply_widths(self) -> None:
+ narrow = self.narrow()
+ name_width = 92 if not narrow else 80
+ for delta, name, axis, qwen, openai in self._cells:
+ name.setFixedWidth(name_width)
+ axis.setVisible(not narrow)
+ for cell, key in ((qwen, "qwen"), (openai, "openai")):
+ cell.setFixedWidth(self.VALUE_WIDTH)
+ cell.setText(self._value_text(delta, key, narrow))
+ self.ruler_left.setFixedWidth(name_width)
+ self.ruler_caption.setFixedWidth(self.VALUE_WIDTH * 2 + 10)
+ self.ruler_holder.setVisible(bool(self.rows) and not narrow)
+
+ @staticmethod
+ def _value_text(delta: DoseDelta, key: str, narrow: bool) -> str:
+ value = delta.deltas[key]
+ if value is None:
+ return f"—"
+ if value == 0:
+ return f"0"
+ return f"{_signed(value)}"
+
+ def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
+ super().resizeEvent(event)
+ self._apply_widths()
+
+ def set_rows(self, deltas: list[DoseDelta]) -> None:
+ self.rows = deltas
+ self._cells = []
+ while self.body_layout.count():
+ item = self.body_layout.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.setParent(None)
+ widget.deleteLater()
+ extent = _axis_extent(deltas)
+ unit = deltas[0].unit if deltas else "克"
+ for delta in deltas:
+ self.body_layout.addWidget(self._row(delta, extent))
+ self.body_layout.addStretch(1)
+ self.ruler.extent, self.ruler.unit = extent, unit
+ self.ruler.setAccessibleDescription(f"刻度 −{_plain(extent)} 到 +{_plain(extent)} {unit}")
+ self.ruler.update()
+ self.scroll.setVisible(bool(deltas))
+ self.empty.setVisible(not deltas)
+ self._apply_widths()
+
+ def _row(self, delta: DoseDelta, extent: Decimal) -> QWidget:
+ row = QWidget(self.body)
+ row.setMinimumHeight(38)
+ row.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
+ layout = QHBoxLayout(row)
+ layout.setContentsMargins(0, 8, 0, 8)
+ layout.setSpacing(12)
+ name = QLabel(delta.name, row)
+ name.setTextFormat(Qt.TextFormat.PlainText)
+ name.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 12.3px;")
+ name.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+ name.setToolTip(delta.description)
+ layout.addWidget(name)
+ axis = _DoseAxis(delta, extent, row)
+ layout.addWidget(axis, 1)
+ cells = []
+ for _model in ("qwen", "openai"):
+ cell = QLabel(row)
+ cell.setTextFormat(Qt.TextFormat.RichText)
+ cell.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+ cell.setFont(num_font(12))
+ layout.addWidget(cell)
+ cells.append(cell)
+ self._cells.append((delta, name, axis, cells[0], cells[1]))
+ return row
+
+
+class ReadingNote(WrappedLabel):
+ """The "how to read this" line every chart in the console carries."""
+
+ def __init__(self, text: str, parent: QWidget | None = None) -> None:
+ mark = f"?"
+ super().__init__(f"{mark} {text}", parent)
+ self.setTextFormat(Qt.TextFormat.RichText)
+ self.setStyleSheet(
+ f"background: {TECH_BLUE['surface_2']}; color: {TECH_BLUE['muted']};"
+ f" border: 1px dashed {TECH_BLUE['line']}; border-radius: 7px;"
+ " padding: 9px 12px; font-size: 10.8px; line-height: 170%;")
+
+
+class ConclusionsPanel(QFrame):
+ """What this batch says, in numbered sentences built from the saved numbers only."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("AiPanel")
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(18, 15, 18, 18)
+ layout.setSpacing(11)
+ head, _hint, _link = _panel_head("本批结论", "自动生成 · 供复核", parent=self)
+ layout.addWidget(head)
+ self.scroll = QScrollArea(self)
+ self.scroll.setWidgetResizable(True)
+ self.scroll.setFrameShape(QFrame.Shape.NoFrame)
+ self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.body = QWidget(self.scroll)
+ self.body_layout = QVBoxLayout(self.body)
+ self.body_layout.setContentsMargins(0, 0, 6, 0)
+ self.body_layout.setSpacing(0)
+ self.scroll.setWidget(self.body)
+ layout.addWidget(self.scroll, 1)
+ layout.addWidget(ReadingNote(
+ "结论由规则依据下方图表生成,不代表医生判断;每条都可在对应卡片中核对。", self))
+
+ def set_items(self, items: list[str]) -> None:
+ while self.body_layout.count():
+ entry = self.body_layout.takeAt(0)
+ widget = entry.widget()
+ if widget is not None:
+ widget.setParent(None)
+ widget.deleteLater()
+ for index, text in enumerate(items, start=1):
+ if index > 1:
+ self.body_layout.addWidget(_rule(self.body))
+ self.body_layout.addWidget(self._item(index, text))
+ self.body_layout.addStretch(1)
+
+ def _item(self, index: int, text: str) -> QWidget:
+ row = QWidget(self.body)
+ policy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
+ policy.setHeightForWidth(True)
+ row.setSizePolicy(policy)
+ layout = QHBoxLayout(row)
+ layout.setContentsMargins(0, 10, 0, 10)
+ layout.setSpacing(10)
+ number = QLabel(str(index), row)
+ number.setFixedSize(20, 20)
+ number.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ number.setFont(num_font(10, weight=QFont.Weight.DemiBold))
+ number.setStyleSheet(
+ f"background: {TECH_BLUE['selection']}; color: {TECH_BLUE['accent_text']}; border-radius: 6px;")
+ layout.addWidget(number, 0, Qt.AlignmentFlag.AlignTop)
+ body = QLabel(text, row)
+ body.setWordWrap(True)
+ body.setTextFormat(Qt.TextFormat.RichText)
+ body.setStyleSheet(f"color: {TECH_BLUE['text']}; font-size: 12.3px; line-height: 160%;")
+ layout.addWidget(body, 1)
+ return row
+
+
+def conclusions(batch: dict, deltas: list[DoseDelta]) -> list[str]:
+ """Four sentences a reader can check against the cards below; nothing is inferred clinically."""
+
+ data = _mapping(batch)
+ models = _mapping(data.get("models"))
+ lines: list[str] = []
+ counts = {}
+ for key in ("qwen", "openai"):
+ herbs = _mapping(_mapping(models.get(key)).get("candidate")).get("herbs")
+ counts[key] = len(herbs) if isinstance(herbs, list) else None
+ if counts["qwen"] is not None and counts["openai"] is not None:
+ lines.append(f"两个模型都给出了候选方:{MODEL_NAMES['qwen']} {counts['qwen']} 味,"
+ f"{MODEL_NAMES['openai']} {counts['openai']} 味。")
+ else:
+ given = [MODEL_NAMES[key] for key in ("qwen", "openai") if counts[key] is not None]
+ lines.append(";".join(f"{name} 已给出候选方" for name in given) + ";其余模型本批次没有候选方。"
+ if given else "本批次尚无候选方,以下图表只反映已保存的部分。")
+
+ scores = {}
+ for key in ("qwen", "openai"):
+ comparison = _mapping(_mapping(models.get(key)).get("comparison"))
+ scores[key] = _number(comparison.get("score")) if comparison.get("status") == "comparable" else None
+ if scores["qwen"] is not None and scores["openai"] is not None:
+ gap = scores["qwen"] - scores["openai"]
+ leader = MODEL_NAMES["qwen"] if gap >= 0 else MODEL_NAMES["openai"]
+ overlaps = []
+ for key in ("qwen", "openai"):
+ herb = _number(_mapping(_mapping(models.get(key)).get("comparison")).get("herb_score"))
+ overlaps.append("—" if herb is None else f"{herb:.1f}%")
+ lines.append(f"{MODEL_NAMES['qwen']}一致率 {scores['qwen']:.1f}%,"
+ f"{MODEL_NAMES['openai']} {scores['openai']:.1f}%,"
+ f"{leader}领先 {abs(gap):.1f}pt;药味重合 {overlaps[0]} / {overlaps[1]}。")
+ else:
+ lines.append("两个模型都可比之后,这里给出一致率差值;现在只有可比的一侧有分数。")
+
+ changed = [delta for delta in deltas
+ if any(value not in (None, 0) for value in delta.deltas.values())]
+ if changed:
+ top = sorted(changed, key=lambda delta: -delta.reach)[:3]
+ named = "、".join(f"{delta.name} {_signed(delta.reach)}{delta.unit}" for delta in top)
+ lines.append(f"剂量偏差共 {len(changed)} 味,最大的是 {named}。")
+ else:
+ lines.append("本批次没有可比的剂量偏差:不是没有差别,而是缺少可比的共同标尺。")
+
+ gaps = data.get("missing")
+ if isinstance(gaps, list) and gaps:
+ critical = sum(1 for item in gaps if _mapping(item).get("critical"))
+ buckets: dict[str, int] = {}
+ for item in gaps:
+ name = _label(_mapping(item).get("code")) or "其他缺口"
+ buckets[name] = buckets.get(name, 0) + 1
+ largest = max(buckets.items(), key=lambda pair: pair[1])
+ lines.append(f"资料缺口 {len(gaps)} 项(关键 {critical} 项),其中「{largest[0]}」{largest[1]} 项,"
+ "建议补录后重新分析。")
+ else:
+ lines.append("本批次没有记录资料缺口。")
+ return lines
+
+
+class AttributionPanel(QFrame):
+ """Who used which herb: four groups, each with its count, its bar and its names."""
+
+ GROUPS = (("all", "三方共识", "accent"), ("doctor_only", "医方独有", "muted"),
+ ("qwen_only", f"{MODEL_NAMES['qwen']}独有", "qwen"),
+ ("openai_only", f"{MODEL_NAMES['openai']} 独有", "openai"))
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("AiPanel")
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(18, 15, 18, 18)
+ layout.setSpacing(11)
+ head, self.total_note, _link = _panel_head("药味归属分布", "等待候选方", parent=self)
+ legend = QLabel(
+ f"● {MODEL_NAMES['qwen']}"
+ f" ● {MODEL_NAMES['openai']}"
+ f" ● 医方独有", self)
+ legend.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 10.65px;")
+ head.layout().addWidget(legend)
+ layout.addWidget(head)
+ self.scroll = QScrollArea(self)
+ self.scroll.setWidgetResizable(True)
+ self.scroll.setFrameShape(QFrame.Shape.NoFrame)
+ self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ groups = QWidget(self.scroll)
+ group_layout = QVBoxLayout(groups)
+ group_layout.setContentsMargins(0, 0, 6, 0)
+ group_layout.setSpacing(13)
+ self.scroll.setWidget(groups)
+ layout.addWidget(self.scroll, 1)
+ self.rows: dict[str, dict[str, Any]] = {}
+ for key, caption, tone in self.GROUPS:
+ block = QVBoxLayout()
+ block.setSpacing(5)
+ line = QHBoxLayout()
+ line.setSpacing(8)
+ name = QLabel(caption, self)
+ name.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 11.9px;")
+ line.addWidget(name)
+ line.addStretch(1)
+ count = QLabel("—", self)
+ count.setTextFormat(Qt.TextFormat.PlainText)
+ count.setFont(num_font(13, weight=QFont.Weight.DemiBold))
+ count.setStyleSheet(f"color: {TECH_BLUE['heading']};")
+ line.addWidget(count)
+ block.addLayout(line)
+ bar = SourceBar(self)
+ block.addWidget(bar)
+ names = QLabel("—", self)
+ names.setWordWrap(True)
+ names.setTextFormat(Qt.TextFormat.PlainText)
+ names.setStyleSheet(f"color: {TECH_BLUE['faint']}; font-size: 10.65px; line-height: 160%;")
+ block.addWidget(names)
+ group_layout.addLayout(block)
+ self.rows[key] = {"count": count, "bar": bar, "names": names,
+ "colour": COLORS.get(tone, TECH_BLUE["muted"])}
+ group_layout.addStretch(1)
+ layout.addWidget(ReadingNote(
+ "条形长度 = 该归属下的药味数量;比韦恩图更容易直接读出「哪些药该重点看」。", self))
+
+ def apply(self, batch: dict) -> None:
+ sets = herb_sets(batch)
+ groups = {
+ "all": sets["doctor"] & sets["qwen"] & sets["openai"],
+ "doctor_only": sets["doctor"] - sets["qwen"] - sets["openai"],
+ "qwen_only": sets["qwen"] - sets["doctor"] - sets["openai"],
+ "openai_only": sets["openai"] - sets["doctor"] - sets["qwen"],
+ }
+ largest = max((len(value) for value in groups.values()), default=0)
+ total = len(sets["doctor"] | sets["qwen"] | sets["openai"])
+ # A herb used by the doctor and only one model belongs to none of the four groups; say so
+ # rather than letting the four counts silently fail to add up.
+ paired = total - sum(len(value) for value in groups.values())
+ text = (f"合计 {total} 味" + (f" · 另 {paired} 味为医方与单一模型共用" if paired else "")) if total else "等待候选方"
+ self.total_note.setText(text)
+ self.total_note.setToolTip(text)
+ for key, names in groups.items():
+ row = self.rows[key]
+ row["count"].setText(f"{len(names)} 味" if total else "—")
+ row["bar"].set_fraction(len(names) / largest if largest else 0.0, row["colour"])
+ ordered = sorted(names)
+ shown = "、".join(ordered[:6]) + (f" 等 {len(ordered)} 味" if len(ordered) > 6 else "")
+ row["names"].setText(shown or "—")
+ row["bar"].setToolTip("、".join(ordered) or "本组没有药味")
+ row["names"].setToolTip("、".join(ordered) or "本组没有药味")
+
+
+def herb_sets(batch: dict) -> dict[str, set[str]]:
+ """The three prescriptions as name sets, read from the saved candidates and comparison rows."""
+
+ data = _mapping(batch)
+ models = _mapping(data.get("models"))
+ sets: dict[str, set[str]] = {"doctor": set(), "qwen": set(), "openai": set()}
+ for key in ("qwen", "openai"):
+ herbs = _mapping(_mapping(models.get(key)).get("candidate")).get("herbs")
+ if isinstance(herbs, list):
+ sets[key] = {_text(_mapping(herb).get("name")) for herb in herbs
+ if _text(_mapping(herb).get("name"))}
+ rows = _mapping(_mapping(models.get(key)).get("comparison")).get("rows")
+ if isinstance(rows, list):
+ sets["doctor"] |= {_text(_mapping(row).get("name")) for row in rows
+ if _mapping(row).get("doctor") is not None and _text(_mapping(row).get("name"))}
+ return sets
+
+
+class RiskPanel(QFrame):
+ """Dose changes large enough to be worth a second look, with the rule that flagged them."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("AiPanel")
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(18, 15, 18, 18)
+ layout.setSpacing(11)
+ head, self.hint, _link = _panel_head("高风险提示", "建议优先复核", parent=self)
+ layout.addWidget(head)
+ self.body = QWidget(self)
+ self.body_layout = QVBoxLayout(self.body)
+ self.body_layout.setContentsMargins(0, 0, 0, 0)
+ self.body_layout.setSpacing(0)
+ layout.addWidget(self.body, 1)
+ self.empty = QLabel("本批次没有达到提示阈值的剂量变化。", self)
+ self.empty.setWordWrap(True)
+ self.empty.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 12px;")
+ layout.addWidget(self.empty)
+ layout.addWidget(ReadingNote(
+ "规则:剂量偏差 ≥ 10 克或成倍数 ≥ 50%,自动进入本清单。阈值只做排序,不替代医生判断。", self))
+
+ def set_rows(self, deltas: list[DoseDelta]) -> None:
+ while self.body_layout.count():
+ entry = self.body_layout.takeAt(0)
+ widget = entry.widget()
+ if widget is not None:
+ widget.setParent(None)
+ widget.deleteLater()
+ flagged = []
+ for delta in deltas:
+ for key in ("qwen", "openai"):
+ value = delta.deltas[key]
+ if value is None or value == 0:
+ continue
+ ratio = abs(value) / delta.doctor if delta.doctor else None
+ if abs(value) < 10 and (ratio is None or ratio < Decimal("0.5")):
+ continue
+ flagged.append((delta, key, value, ratio))
+ flagged.sort(key=lambda item: -abs(item[2]))
+ for delta, key, value, ratio in flagged[:5]:
+ self.body_layout.addWidget(self._row(delta, key, value, ratio))
+ self.body_layout.addStretch(1)
+ self.body.setVisible(bool(flagged))
+ self.empty.setVisible(not flagged)
+ self.hint.setText(f"建议优先复核 · {len(flagged)} 项" if flagged else "建议优先复核")
+
+ def _row(self, delta: DoseDelta, key: str, value: Decimal, ratio: Decimal | None) -> QWidget:
+ row = QWidget(self.body)
+ row.setMinimumHeight(56)
+ policy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
+ policy.setHeightForWidth(True)
+ row.setSizePolicy(policy)
+ layout = QHBoxLayout(row)
+ layout.setContentsMargins(0, 8, 0, 8)
+ layout.setSpacing(10)
+ block = QVBoxLayout()
+ block.setSpacing(2)
+ title = QLabel(f"{delta.name} {_signed(value)} {delta.unit}", row)
+ title.setTextFormat(Qt.TextFormat.PlainText)
+ title.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 13px; font-weight: 600;")
+ block.addWidget(title)
+ if delta.doctor is None:
+ reason = f"{MODEL_NAMES[key]}新增,医方原方未收录,需确认加入理由"
+ elif ratio is not None and ratio >= 1:
+ reason = f"{MODEL_NAMES[key]}剂量为医方 {1 + ratio:.1f} 倍,请评估安全性"
+ elif ratio is not None:
+ reason = f"{MODEL_NAMES[key]}相对医方变动 {ratio * 100:.0f}%,请确认是否有意调整"
+ else:
+ reason = f"{MODEL_NAMES[key]}与医方相差 {abs(value)}{delta.unit}"
+ note = QLabel(reason, row)
+ note.setWordWrap(True)
+ note.setTextFormat(Qt.TextFormat.PlainText)
+ note.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;")
+ block.addWidget(note)
+ layout.addLayout(block, 1)
+ severe = abs(value) >= 15 or (ratio is not None and ratio >= 1)
+ pill = QLabel("高" if severe else "中", row)
+ pill.setFixedWidth(26)
+ pill.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ tone = ("rose_dim", "rose_text") if severe else ("amber_dim", "amber_text")
+ pill.setStyleSheet(
+ f"background: {TECH_BLUE[tone[0]]}; color: {TECH_BLUE[tone[1]]}; border-radius: 7px;"
+ " padding: 2px 0; font-size: 11.5px;")
+ layout.addWidget(pill, 0, Qt.AlignmentFlag.AlignTop)
+ return row
+
+
+def restricted_attachments(batch: dict) -> int:
+ """Attachments no model managed to read, merged by file id across the two models."""
+
+ models = _mapping(_mapping(batch).get("models"))
+ states: dict[str, bool] = {}
+ for key in ("qwen", "openai"):
+ files = _mapping(_mapping(models.get(key)).get("coverage")).get("files")
+ if not isinstance(files, list):
+ continue
+ for entry in files:
+ value = _mapping(entry)
+ identifier = _text(value.get("file_id")) or str(len(states))
+ read = value.get("status") == "processed"
+ states[identifier] = states.get(identifier, False) or read
+ return sum(1 for read in states.values() if not read)
+
+
+def gap_counts(batch: dict) -> dict[str, int]:
+ """Saved gaps split into the three buckets the rail shows; nothing is inferred."""
+
+ counts = {"critical": 0, "attachment": 0, "other": 0}
+ missing = _mapping(batch).get("missing")
+ if not isinstance(missing, list):
+ return counts
+ for item in missing:
+ value = _mapping(item)
+ code = _text(value.get("code")).upper()
+ if value.get("critical"):
+ counts["critical"] += 1
+ elif "ATTACHMENT" in code or "FILE" in code or "STORAGE" in code:
+ counts["attachment"] += 1
+ else:
+ counts["other"] += 1
+ return counts
+
+
+GROUP_RULES = (("血压", "数据缺失"), ("肝肾", "数据缺失"), ("体重", "数据缺失"), ("BMI", "数据缺失"),
+ ("血糖", "数据缺失"), ("用药记录", "关键临床资料缺失"), ("舌象", "关键临床资料缺失"),
+ ("脉", "关键临床资料缺失"), ("甲状腺", "关键临床资料缺失"), ("眼底", "关键临床资料缺失"))
+
+
+def checklist_group(text: str) -> str:
+ """Fold near-identical gap wordings into the short label the list shows."""
+
+ for needle, name in GROUP_RULES:
+ if needle in text:
+ return name
+ return text
+
+
+class ChecklistPanel(QFrame):
+ """Everything that still needs a human decision, by severity, with the review form below."""
+
+ open_details = Signal()
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("AiPanel")
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(18, 15, 18, 18)
+ layout.setSpacing(9)
+ head, self.count_note, link = _panel_head(
+ "复核清单", "等待报告", "查看详情 ›", parent=self)
+ if link is not None:
+ link.clicked.connect(self.open_details.emit)
+ layout.addWidget(head)
+ self.scroll = QScrollArea(self)
+ self.scroll.setWidgetResizable(True)
+ self.scroll.setFrameShape(QFrame.Shape.NoFrame)
+ self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.scroll.setMinimumHeight(84)
+ self.body = QWidget(self.scroll)
+ self.body_layout = QVBoxLayout(self.body)
+ self.body_layout.setContentsMargins(0, 0, 0, 0)
+ self.body_layout.setSpacing(0)
+ self.scroll.setWidget(self.body)
+ layout.addWidget(self.scroll, 1)
+ self.empty = QLabel("报告未保存待核对项目;请结合诊断依据复核。", self)
+ self.empty.setWordWrap(True)
+ self.empty.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 12px;")
+ layout.addWidget(self.empty)
+ divider = QFrame(self)
+ divider.setFixedHeight(1)
+ divider.setStyleSheet(f"background: {TECH_BLUE['line_soft']};")
+ layout.addWidget(divider)
+ caption_row = QHBoxLayout()
+ caption_row.setSpacing(8)
+ caption = QLabel("复核意见", self)
+ caption.setObjectName("AiPanelHint")
+ caption_row.addWidget(caption)
+ caption_row.addStretch(1)
+ self.model_slot = QWidget(self)
+ model_layout = QHBoxLayout(self.model_slot)
+ model_layout.setContentsMargins(0, 0, 0, 0)
+ model_layout.setSpacing(0)
+ caption_row.addWidget(self.model_slot)
+ layout.addLayout(caption_row)
+ self.review_slot = QWidget(self)
+ self.review_layout = QVBoxLayout(self.review_slot)
+ self.review_layout.setContentsMargins(0, 0, 0, 0)
+ self.review_layout.setSpacing(0)
+ layout.addWidget(self.review_slot)
+ layout.addWidget(ReadingNote(
+ "仅保存当前所选模型的复核结果,另一模型的报告保持不变。", self))
+
+ def set_items(self, items: list[tuple[str, str, str]]) -> None:
+ while self.body_layout.count():
+ entry = self.body_layout.takeAt(0)
+ widget = entry.widget()
+ if widget is not None:
+ widget.setParent(None)
+ widget.deleteLater()
+ grouped: dict[str, tuple[str, str, int, str]] = {}
+ for text, attribution, tone in items:
+ key = checklist_group(text)
+ name, kept_tone, count, note = grouped.get(key, (key, tone, 0, attribution))
+ grouped[key] = (name, kept_tone if _rank(kept_tone) <= _rank(tone) else tone,
+ count + 1, note or attribution)
+ ordered = sorted(grouped.values(), key=lambda entry: (_rank(entry[1]), -entry[2], entry[0]))
+ for position, (name, tone, count, note) in enumerate(ordered):
+ if position:
+ self.body_layout.addWidget(_rule(self.body))
+ self.body_layout.addWidget(self._note(name, note, tone, count))
+ self.body_layout.addStretch(1)
+ total = sum(count for _name, _tone, count, _note in ordered)
+ self.count_note.setText(f"{total} 条 · 按严重度排序" if total else "暂无待确认项")
+ self.scroll.setVisible(bool(ordered))
+ self.empty.setVisible(not ordered)
+
+ def _note(self, text: str, attribution: str, tone: str, count: int) -> QWidget:
+ row = QWidget(self.body)
+ row.setMinimumHeight(46)
+ policy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
+ policy.setHeightForWidth(True)
+ row.setSizePolicy(policy)
+ layout = QHBoxLayout(row)
+ layout.setContentsMargins(0, 11, 0, 11)
+ layout.setSpacing(11)
+ severe = tone == DANGER
+ pill = QLabel("关键" if severe else "一般", row)
+ pill.setFixedWidth(34)
+ pill.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ pair = ("rose_dim", "rose_text") if severe else ("amber_dim", "amber_text")
+ pill.setStyleSheet(
+ f"background: {TECH_BLUE[pair[0]]}; color: {TECH_BLUE[pair[1]]};"
+ f" border: 1px solid {TECH_BLUE[pair[1]]}; border-radius: 10px; padding: 2px 0; font-size: 9.6px;")
+ layout.addWidget(pill, 0, Qt.AlignmentFlag.AlignTop)
+ block = QVBoxLayout()
+ block.setSpacing(1)
+ title = QLabel(text, row)
+ title.setTextFormat(Qt.TextFormat.PlainText)
+ title.setWordWrap(True)
+ title.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 12.3px;")
+ block.addWidget(title)
+ note = QLabel(attribution or "—", row)
+ note.setTextFormat(Qt.TextFormat.PlainText)
+ note.setWordWrap(True)
+ note.setStyleSheet(f"color: {TECH_BLUE['faint']}; font-size: 10.5px;")
+ block.addWidget(note)
+ layout.addLayout(block, 1)
+ badge = QLabel(str(count), row)
+ badge.setTextFormat(Qt.TextFormat.PlainText)
+ badge.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+ badge.setFixedWidth(28)
+ badge.setFont(num_font(13))
+ badge.setStyleSheet(f"color: {TECH_BLUE['muted']};")
+ layout.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop)
+ return row
+
+
+def checklist_items(batch: dict) -> list[tuple[str, str, str]]:
+ """Model-reported risks and gaps first, then the system's own source gaps."""
+
+ data = _mapping(batch)
+ models = _mapping(data.get("models"))
+ collected: dict[str, tuple[list[str], str]] = {}
+
+ def add(text: str, name: str, tone: str) -> None:
+ if not text:
+ return
+ names, previous = collected.get(text, ([], tone))
+ if name and name not in names:
+ names.append(name)
+ collected[text] = (names, previous if _rank(previous) <= _rank(tone) else tone)
+
+ for key, name in MODEL_NAMES.items():
+ report = _mapping(_mapping(models.get(key)).get("report"))
+ risks = report.get("risk_assessment")
+ if isinstance(risks, list):
+ for risk in risks:
+ value = _mapping(risk)
+ level = _text(value.get("level")).lower()
+ tone = DANGER if level in {"high", "critical", "severe", "高"} else WARN
+ add(_text(value.get("label")), name, tone)
+ missing = report.get("missing_information")
+ if isinstance(missing, list):
+ for value in missing:
+ add(_reason(value), name, WARN)
+ gaps = data.get("missing")
+ if isinstance(gaps, list):
+ for gap in gaps:
+ value = _mapping(gap)
+ add(_reason(value.get("code") or value), "", DANGER if value.get("critical") else MUTED_DOT)
+ order = {DANGER: 0, WARN: 1, MUTED_DOT: 2}
+ items = [(text, "、".join(names) + (" 关键" if tone == DANGER else ""), tone)
+ for text, (names, tone) in collected.items()]
+ items.sort(key=lambda item: order.get(item[2], 3))
+ return items
+
+
+def _rank(tone: str) -> int:
+ return {DANGER: 0, WARN: 1, MUTED_DOT: 2}.get(tone, 3)
+
+
+class PrescriptionReviewWorkspace(QWidget):
+ """The overview page: what this batch concluded, who used what, and what needs a decision."""
+
+ open_report = Signal(str)
+ summary_changed = Signal(str)
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("PrescriptionReviewWorkspace")
+ self.setMinimumWidth(0)
+ self._batch: dict[str, Any] = {}
+ self.rows: list[ReviewDoseRow] = []
+ self._review_widget: QWidget | None = None
+ self._summary = "选择报告后查看诊断与药方"
+ self.setStyleSheet(f"""
+ QWidget#PrescriptionReviewWorkspace {{ background: transparent; }}
+ {card_qss()}
+ QWidget#PrescriptionReviewWorkspace QLabel {{ color: {TECH_BLUE['text']};
+ font-family: '{body_family()}'; font-size: 13px; background: transparent; }}
+ QWidget#PrescriptionReviewWorkspace QScrollArea {{ border: none; background: transparent; }}
+ """)
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(0, 0, 0, 0)
+ outer.setSpacing(0)
+ self.page_scroll = QScrollArea(self)
+ self.page_scroll.setWidgetResizable(True)
+ self.page_scroll.setFrameShape(QFrame.Shape.NoFrame)
+ self.page_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ page = QWidget(self.page_scroll)
+ self.page_scroll.setWidget(page)
+ outer.addWidget(self.page_scroll)
+ root = QVBoxLayout(page)
+ root.setContentsMargins(0, 0, 6, 0)
+ root.setSpacing(14)
+
+ self.first = QGridLayout()
+ self.first.setSpacing(14)
+ self.conclusions = ConclusionsPanel(page)
+ self.attribution = AttributionPanel(page)
+ self.checklist = ChecklistPanel(page)
+ self.checklist.open_details.connect(lambda: self.open_report.emit("sources"))
+ for card in (self.conclusions, self.attribution, self.checklist):
+ card.setMinimumHeight(318)
+ root.addLayout(self.first, 5)
+
+ self.second = QGridLayout()
+ self.second.setSpacing(14)
+ self.doses = DoseDiffPanel(page)
+ self.doses.setMinimumHeight(256)
+ self.risks = RiskPanel(page)
+ self.risks.setMinimumHeight(256)
+ root.addLayout(self.second, 4)
+ self._columns = 0
+ self._reflow(3)
+
+ links = QHBoxLayout()
+ links.setSpacing(9)
+ self.links: dict[str, QPushButton] = {}
+ for text, target in (("查看完整报告", "report"), ("查看候选与逐味", "comparison"),
+ ("查看资料与缺口", "sources"), ("查看处理进度", "progress")):
+ button = self._link(f"{text} ›", target, page)
+ self.links[target] = button
+ links.addWidget(button)
+ links.addStretch(1)
+ root.addLayout(links)
+ self._render()
+
+ # The design collapses its card grid to one column below 1240px; the workspace sits inside
+ # the window's rail and gutters, so the same breakpoint lands here.
+ STACK_BELOW = 960
+
+ def _reflow(self, columns: int) -> None:
+ """Lay the two card rows out in three columns, or stacked when the window is narrow."""
+
+ if columns == self._columns:
+ return
+ self._columns = columns
+ for layout in (self.first, self.second):
+ while layout.count():
+ layout.takeAt(0)
+ for index in range(layout.columnCount()):
+ layout.setColumnStretch(index, 0)
+ cards = (self.conclusions, self.attribution, self.checklist)
+ if columns == 1:
+ for row, card in enumerate(cards):
+ self.first.addWidget(card, row, 0)
+ self.first.setColumnStretch(0, 1)
+ self.second.addWidget(self.doses, 0, 0)
+ self.second.addWidget(self.risks, 1, 0)
+ self.second.setColumnStretch(0, 1)
+ return
+ for column, card in enumerate(cards):
+ self.first.addWidget(card, 0, column)
+ self.first.setColumnStretch(column, 1)
+ self.second.addWidget(self.doses, 0, 0)
+ self.second.addWidget(self.risks, 0, 1)
+ self.second.setColumnStretch(0, 2)
+ self.second.setColumnStretch(1, 1)
+
+ def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
+ super().resizeEvent(event)
+ self._reflow(1 if self.width() < self.STACK_BELOW else 3)
+
+ def minimumSizeHint(self) -> QSize:
+ return QSize(360, 420)
+
+ @property
+ def summary_text(self) -> str:
+ return self._summary
+
+ def _link(self, text: str, target: str, parent: QWidget) -> QPushButton:
+ button = QPushButton(text, parent)
+ button.setObjectName("AiQuickLink")
+ button.setCursor(Qt.CursorShape.PointingHandCursor)
+ button.setStyleSheet(
+ f"QPushButton#AiQuickLink {{ background: {TECH_BLUE['surface']}; color: {TECH_BLUE['muted']};"
+ f" border: 1px solid {TECH_BLUE['line']}; border-radius: 18px; padding: 8px 15px;"
+ " font-size: 11.55px; }"
+ f"QPushButton#AiQuickLink:hover {{ color: {TECH_BLUE['accent_text']};"
+ f" border-color: {TECH_BLUE['accent']}; }}")
+ button.clicked.connect(lambda _checked=False: self.open_report.emit(target))
+ return button
+
+ def set_review_selector(self, widget: QWidget) -> None:
+ """The model being annotated is chosen on the checklist card's own header line."""
+
+ widget.setParent(self.checklist.model_slot)
+ self.checklist.model_slot.layout().addWidget(widget)
+ widget.show()
+
+ def set_review_widget(self, widget: QWidget) -> None:
+ if widget is self._review_widget:
+ return
+ if self._review_widget is not None:
+ self.checklist.review_layout.removeWidget(self._review_widget)
+ self._review_widget.hide()
+ self._review_widget = widget
+ widget.setParent(self.checklist.review_slot)
+ widget.setMinimumWidth(0)
+ widget.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
+ self.checklist.review_layout.addWidget(widget)
+ widget.show()
+
+ def difference_count(self) -> int:
+ """Herbs where at least one model's dose differs from the doctor's."""
+
+ return sum(1 for delta in self.doses.rows
+ if any(value not in (None, 0) for value in delta.deltas.values()))
+
+ def consensus_count(self) -> int:
+ """Herbs the doctor and both candidates all used."""
+
+ sets = herb_sets(self._batch)
+ return len(sets["doctor"] & sets["qwen"] & sets["openai"])
+
+ @staticmethod
+ def gap_counts(batch: dict) -> dict[str, int]:
+ return gap_counts(batch)
+
+ @staticmethod
+ def restricted_count(batch: dict) -> int:
+ return restricted_attachments(batch)
+
+ def set_batch(self, batch: dict) -> None:
+ data = _mapping(batch)
+ if data == self._batch:
+ return
+ self._batch = deepcopy(data)
+ self._render()
+
+ def _render(self) -> None:
+ self.rows, _states = review_rows(self._batch)
+ deltas = dose_deltas(self.rows)
+ self.doses.set_rows(deltas)
+ self.risks.set_rows(deltas)
+ self.attribution.apply(self._batch)
+ self.conclusions.set_items(conclusions(self._batch, deltas))
+ self.checklist.set_items(checklist_items(self._batch))
+ differences = self.difference_count()
+ self._summary = (f"本批次有 {differences} 项剂量差异,请在复核清单中逐条确认。"
+ if deltas else "候选方案尚未生成可比剂量;请结合诊断依据与资料缺口复核。")
+ if self._batch.get("validity") not in {None, "current", "valid"}:
+ self._summary = "这是历史或失效报告,仅展示已保存记录,不再更新。"
+ if not self._batch:
+ self._summary = "选择报告后查看诊断与药方。"
+ self.summary_changed.emit(self._summary)
diff --git a/app/tests/test_issued_prescription_ai.py b/app/tests/test_issued_prescription_ai.py
index 0736fb8f1..b4eaa55eb 100644
--- a/app/tests/test_issued_prescription_ai.py
+++ b/app/tests/test_issued_prescription_ai.py
@@ -11,8 +11,8 @@ from uuid import UUID
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
-from PySide6.QtCore import Qt
-from PySide6.QtWidgets import QApplication, QPushButton
+from PySide6.QtCore import QEvent, QObject, QRect, Qt
+from PySide6.QtWidgets import QApplication, QPushButton, QWidget
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
@@ -138,6 +138,215 @@ def test_shared_report_reads_only_and_renders_each_model_independently(applicati
assert repository.calls == calls
+def test_report_open_and_poll_never_show_auxiliary_windows(application: QApplication, immediate: None) -> None:
+ shown_windows = []
+
+ class WindowObserver(QObject):
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool:
+ if event.type() == QEvent.Type.Show and isinstance(watched, QWidget) and watched.isWindow():
+ shown_windows.append((watched.metaObject().className(), watched.windowTitle()))
+ return False
+
+ observer = WindowObserver()
+ application.installEventFilter(observer)
+ dialog = None
+ try:
+ repository = Repository()
+ dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
+ dialog.show()
+ application.processEvents()
+ initial_windows = list(shown_windows)
+ shown_windows.clear()
+ dialog.tabs.setCurrentIndex(3)
+ dialog.model_views["qwen"]["comment"].setPlainText("正在填写的复核意见")
+ for _ in range(3):
+ dialog._timer.timeout.emit()
+ dialog._progress_timer.timeout.emit()
+ application.processEvents()
+ assert len([call for call in repository.calls if call[0] == "detail"]) == 4
+ assert shown_windows == [], "Polling must not show even transient top-level widgets"
+ assert initial_windows == [("IssuedPrescriptionAiDialog", dialog.windowTitle())]
+ assert dialog.tabs.currentIndex() == 3
+ assert dialog.model_views["qwen"]["comment"].toPlainText() == "正在填写的复核意见"
+ assert dialog.chip_row.count() == 2
+ assert all(dialog.chip_row.itemAt(index).widget().isVisible() for index in range(2))
+ assert dialog._timer.isActive() and dialog._progress_timer.isActive()
+ finally:
+ application.removeEventFilter(observer)
+ if dialog is not None:
+ dialog.close()
+
+
+def test_navigation_names_every_destination_once(application: QApplication, immediate: None) -> None:
+ """A duplicated tab name makes the navigation unreadable; the live progress lives in the one tab."""
+ dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801)
+ dialog.show()
+ application.processEvents()
+ names = [dialog.tabs.tabText(index) for index in range(dialog.tabs.count())]
+ assert len(names) == len(set(names)), names
+ assert names.count("处理进度") == 1
+ # Those pages are hosted in a scroll area, so the tab holds the host, not the page itself.
+ for key in ("per_herb", "gaps", "history"):
+ assert dialog.tabs.indexOf(dialog.tab_pages[key]) >= 0
+ bar = dialog.model_views["qwen"]["progress_bar"]
+ assert dialog.pipeline_page.isAncestorOf(bar)
+ assert dialog.pipeline_page.stage_labels["qwen"].isHidden()
+ dialog.close()
+
+
+@pytest.mark.parametrize(("width", "height"), [(1280, 860), (1024, 700), (940, 640)])
+def test_prescription_workspace_is_the_primary_view(application: QApplication, immediate: None, width: int, height: int) -> None:
+ dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801)
+ dialog.resize(width, height)
+ dialog.show()
+ application.processEvents()
+ assert (dialog.width(), dialog.height()) == (width, height)
+ assert dialog.tabs.currentWidget() is dialog.comparison_panel
+ assert dialog.tabs.tabText(0) == "对比总览"
+ assert dialog.comparison_panel.isVisible()
+ assert dialog.tabs.height() > height * 0.28
+ assert dialog.model_views["qwen"]["comment"].isVisible()
+ # The chart strip is on the first screen but must never outgrow its budget, and a model
+ # without a comparable score shows no filled bar.
+ assert dialog.model_views["qwen"]["agreement_bar"].value() == 0
+ assert dialog.model_views["openai"]["agreement_bar"].isHidden()
+ assert dialog.model_views["qwen"]["gauge"].accessibleDescription() == "0.0%"
+ assert dialog.model_views["openai"]["gauge"].accessibleDescription() == "暂无可比结果"
+ dialog.close()
+
+
+@pytest.mark.parametrize(("width", "height"), [(1440, 940), (1280, 860), (1024, 700), (940, 640)])
+def test_review_controls_remain_inside_the_sidebar(application: QApplication, immediate: None,
+ width: int, height: int) -> None:
+ repository = Repository()
+ model = repository.batches[0]["models"]["qwen"]
+ herb = model["candidate"]["herbs"][0]
+ model["comparison"]["rows"][0].update(key="黄芪", doctor={**herb, "dosage": 16}, candidate=herb)
+ dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
+ dialog.resize(width, height)
+ dialog.show()
+ panel = dialog.comparison_panel
+ for _ in range(4):
+ application.processEvents()
+ assert dialog.nav_buttons["overview"].isChecked()
+ for key in ai.MODELS:
+ dialog.review_model.setCurrentIndex(0 if key == "qwen" else 1)
+ application.processEvents()
+ views = dialog.model_views[key]
+ for control in (dialog.review_model, views["review_state"], views["comment"], views["save"]):
+ assert control.isVisible()
+ bounds = QRect(control.mapTo(panel.checklist, control.rect().topLeft()), control.size())
+ assert panel.checklist.rect().contains(bounds), (key, control.accessibleName(), bounds)
+ assert ai.MODELS[key] in dialog.save_review_button.toolTip()
+ assert panel.checklist.isVisible()
+ dialog.close()
+
+
+def test_hero_band_carries_the_frozen_identity_and_yields_on_short_windows(application: QApplication, immediate: None) -> None:
+ repository = Repository()
+ repository.batches[0]["doctor_snapshot"] = {
+ "patient": {"name": "测试患者", "gender_label": "女", "age": 58},
+ "diagnosis": {"clinical_diagnosis": "消渴病 气阴两虚"},
+ "prescription": {"prescription_type": "浓缩水丸", "dose_count": 1, "dose_unit": "剂"},
+ }
+ dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
+ dialog.resize(1280, 860)
+ dialog.show()
+ application.processEvents()
+ assert "测试患者 · 女 · 58 岁" in dialog.identity.text()
+ assert "消渴病 气阴两虚" in dialog.identity.text()
+ assert "浓缩水丸 · 1 剂" in dialog.identity.text()
+ assert dialog.fact_values["basis"].text() == "同单位 · 每剂"
+ assert dialog.fact_values["prescription"].text() == "801 / 501"
+ dialog.resize(1024, 700)
+ application.processEvents()
+ assert dialog.fact_values["batch"].text() == "#40"
+ dialog.close()
+
+
+def test_hero_band_shows_dashes_when_the_snapshot_is_not_deployed(application: QApplication, immediate: None) -> None:
+ repository = Repository()
+ repository.batches[0].pop("doctor_snapshot", None)
+ dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
+ dialog.resize(1280, 860)
+ dialog.show()
+ application.processEvents()
+ assert dialog.identity.text() == "尚无冻结快照"
+ assert dialog.fact_values["prescription"].text().startswith("801")
+ dialog.close()
+
+
+def test_failed_model_states_the_reason_and_offers_its_own_retry(application: QApplication, immediate: None) -> None:
+ repository = Repository()
+ repository.batches[0]["models"]["openai"].update(status="failed", error_code="INVALID_REPORT_OUTPUT",
+ error_message="INVALID_REPORT_OUTPUT")
+ dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
+ dialog.show()
+ application.processEvents()
+ assert dialog.model_views["openai"]["failure"].isVisible()
+ text = dialog.model_views["openai"]["failure_text"].text()
+ assert "OpenAI 失败" in text and "格式校验" in text and "原资料快照" in text
+ assert "INVALID_REPORT_OUTPUT" not in text
+ assert dialog.model_views["openai"]["retry"].isEnabled()
+ assert dialog.model_views["openai"]["retry"].isVisible()
+ # A model that is still running never shows a failure row.
+ dialog.model_views["openai"]["retry"].click()
+ application.processEvents()
+ assert ("retry", (40, "openai")) in repository.calls
+ dialog.close()
+
+
+def test_exhausted_manual_retries_point_at_regeneration_instead_of_retry(application: QApplication, immediate: None) -> None:
+ repository = Repository()
+ repository.batches[0]["models"]["openai"].update(status="failed", error_code="UPSTREAM_TIMEOUT",
+ error_message="UPSTREAM_TIMEOUT", manual_retries=2)
+ dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
+ dialog.show()
+ application.processEvents()
+ text = dialog.model_views["openai"]["failure_text"].text()
+ assert "手动重试次数已用完" in text and "重新分析" in text
+ assert not dialog.model_views["openai"]["retry"].isVisible()
+ dialog.close()
+
+
+def test_inline_review_keeps_model_drafts_and_saves_selected_model(application: QApplication, immediate: None) -> None:
+ repository = Repository()
+ repository.batches[0]["models"]["openai"].update(report_id=92, report={"summary": "已保存报告"})
+ dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
+ dialog.show()
+ application.processEvents()
+ dialog.model_views["qwen"]["comment"].setPlainText("千问复核草稿")
+ dialog.review_model.setCurrentIndex(1)
+ dialog.model_views["openai"]["comment"].setPlainText("OpenAI复核草稿")
+ dialog.model_views["openai"]["review_state"].setCurrentIndex(3)
+ dialog._poll()
+ application.processEvents()
+ assert dialog.review_model.currentData() == "openai"
+ assert dialog.model_views["qwen"]["comment"].toPlainText() == "千问复核草稿"
+ assert dialog.model_views["openai"]["comment"].toPlainText() == "OpenAI复核草稿"
+ dialog.save_review_button.click()
+ assert ("review", (40, "openai", "reviewed", "OpenAI复核草稿")) in repository.calls
+ assert not any(call[0] == "review" and call[1][1] == "qwen" for call in repository.calls)
+ dialog.close()
+
+
+def test_workspace_links_reach_existing_supporting_reports(application: QApplication, immediate: None) -> None:
+ dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801)
+ dialog.show()
+ # Each link opens the composed page that answers it, not the raw saved text.
+ for field, key in (("report", "report"), ("candidate", "candidate"), ("comparison", "per_herb"),
+ ("sources", "gaps"), ("progress", "pipeline"), ("doctor", "original")):
+ dialog.comparison_panel.open_report.emit(field)
+ assert dialog.tabs.currentWidget() is dialog.tab_pages[key], field
+ dialog.nav_buttons["overview"].click()
+ assert dialog.tabs.currentWidget() is dialog.comparison_panel
+ assert dialog.nav_buttons["overview"].isChecked()
+ assert not dialog.nav_buttons["gaps"].isChecked()
+ dialog.nav_buttons["per_herb"].click()
+ assert dialog.tabs.currentWidget() is dialog.tab_pages["per_herb"]
+ dialog.close()
+
+
def test_retry_and_review_target_only_selected_model(application: QApplication, immediate: None) -> None:
repository = Repository()
repository.batches[0]["status"] = "partial"
@@ -681,6 +890,25 @@ def test_metadata_and_prose_remain_html_escaped(application: QApplication) -> No
browser.close()
+def test_candidate_reading_view_keeps_full_medicine_instructions(application: QApplication) -> None:
+ candidate = {
+ "prescription_name": "测试候选方", "dose_basis": "per_dose", "unit": "g",
+ "herbs": [{"name": "测试药材", "dosage": 12, "formula_type": "主方",
+ "instructions": "先煎,具体时长由医生复核", "processing": "生品",
+ "evidence_references": ["diagnoses:501"]}],
+ "rationale": "保留完整方义", "risk_warnings": ["复核提示 "],
+ }
+ html = ai._candidate_html(candidate)
+ assert html.index("测试药材") < html.index("保留完整方义")
+ assert "