更新
This commit is contained in:
@@ -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> <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
Reference in New Issue
Block a user