This commit is contained in:
Your Name
2026-09-21 10:26:14 +08:00
parent bbe3e870a1
commit dbf474ddd7
67 changed files with 10636 additions and 279 deletions
+2 -2
View File
@@ -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"
File diff suppressed because it is too large Load Diff
@@ -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()
@@ -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'<span style="color:{DOCTOR_COLOR}">● 医生</span>&nbsp;&nbsp;<span style="color:{MODEL_COLORS[self._model_key]}">● {name}</span>')
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
@@ -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; }}
"""
@@ -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)
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -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
File diff suppressed because it is too large Load Diff