This commit is contained in:
Your Name
2026-08-27 14:23:23 +08:00
parent b5b14516a1
commit 2fa8492c56
27 changed files with 3832 additions and 1435 deletions
+347 -49
View File
@@ -5,8 +5,9 @@ from __future__ import annotations
import logging
import os
import sys
import time
from contextlib import suppress
import time
from collections.abc import Mapping, Sequence
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
@@ -34,17 +35,179 @@ from doctor_workstation.services import (
build_repository,
)
from doctor_workstation.ui import LoginWindow, ShellWindow, apply_theme
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
from doctor_workstation.ui.widgets import (
friendly_error,
run_async,
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
from doctor_workstation.ui.widgets import (
first_value,
friendly_error,
gender_text,
get_value,
run_async,
set_authentication_expired_handler,
show_toast,
)
from doctor_workstation.video import BackendMode, launch_video_call
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
LOGGER = logging.getLogger(__name__)
LOGGER = logging.getLogger(__name__)
def _video_case_text(value: Any, *, limit: int = 2000) -> str:
"""Render a bounded, JSON-safe clinical value for the trusted call rail."""
if value in (None, "", [], {}):
return ""
if isinstance(value, Mapping):
parts = [
f"{key}:{_video_case_text(item, limit=limit)}"
for key, item in value.items()
if item not in (None, "", [], {})
]
return ";".join(part for part in parts if not part.endswith(":"))[:limit]
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return "、".join(
part
for item in value
if (part := _video_case_text(item, limit=limit))
)[:limit]
return str(value).strip()[:limit]
def _video_identity(value: Any) -> str:
if value in (None, "") or isinstance(value, bool):
return ""
text = str(value).strip()
if not text:
return ""
with suppress(ValueError, TypeError):
return str(int(text))
return text
def _video_identity_matches(expected: Any, actual: Any) -> bool:
normalized_actual = _video_identity(actual)
return not normalized_actual or normalized_actual == _video_identity(expected)
def _build_video_patient_case(
detail: Any,
fallback_record: Any,
*,
diagnosis_id: Any,
patient_id: Any,
patient_name: str,
) -> dict[str, str]:
"""Reduce the readonly diagnosis aggregate to the fields needed in-call."""
if isinstance(detail, Mapping) and not get_value(detail, "diagnosis", None):
nested = get_value(detail, "data", None)
if isinstance(nested, Mapping):
detail = nested
diagnosis = get_value(detail, "diagnosis", None)
if not diagnosis and isinstance(detail, Mapping):
diagnosis = detail
diagnosis = diagnosis or {}
patient = get_value(detail, "patient", None) or {}
appointment = get_value(detail, "appointment", None) or {}
detail_diagnosis_id = first_value(diagnosis, "id", "diagnosis_id", default=None)
detail_patient_id = first_value(
diagnosis,
"source_patient_id",
default=first_value(
patient,
"source_patient_id",
default=None,
),
)
if not _video_identity_matches(
diagnosis_id,
detail_diagnosis_id,
) or not _video_identity_matches(patient_id, detail_patient_id):
detail = diagnosis = patient = appointment = {}
fallback_diagnosis_id = first_value(
fallback_record,
"diagnosis_id",
"id",
default=None,
)
fallback_patient_id = first_value(
fallback_record,
"source_patient_id",
"patient_id",
default=None,
)
if not _video_identity_matches(
diagnosis_id,
fallback_diagnosis_id,
) or not _video_identity_matches(patient_id, fallback_patient_id):
fallback_record = {}
sources = (diagnosis, patient, appointment, fallback_record)
def pick(*keys: str, limit: int = 2000) -> str:
for source in sources:
value = first_value(source, *keys, default=None)
text = _video_case_text(value, limit=limit)
if text:
return text
return ""
raw_gender = next(
(
first_value(source, "gender_desc", "gender", default=None)
for source in sources
if first_value(source, "gender_desc", "gender", default=None) not in (None, "")
),
None,
)
return {
"diagnosisId": _video_case_text(diagnosis_id, limit=80),
"name": pick("patient_name", "name", limit=120)
or _video_case_text(patient_name, limit=120)
or "患者",
"gender": "" if raw_gender in (None, "") else gender_text(raw_gender),
"age": pick("age", limit=20),
"height": pick("height", limit=20),
"weight": pick("weight", limit=20),
"diagnosisDate": pick("diagnosis_date", "diagnosis_date_text", limit=80),
"appointmentDate": pick(
"appointment_date",
"latest_appointment_date",
limit=80,
),
"clinicalDiagnosis": pick(
"clinical_diagnosis",
"diagnosis_name",
"disease_name",
),
"chiefComplaint": pick("chief_complaint", "complaint"),
"presentIllness": pick("present_illness", "present_illness_history", "symptoms"),
"pastHistory": pick("past_history_text", "past_history_desc", "past_history"),
"allergyHistory": pick(
"allergy_history_text",
"allergy_history_desc",
"allergy_history",
),
"personalHistory": pick(
"personal_history_text",
"personal_history_desc",
"personal_history",
),
"familyHistory": pick(
"family_history_text",
"family_history_desc",
"family_history",
),
"currentMedication": pick(
"current_medications",
"current_medicine",
"current_medication",
),
"tongue": pick("tongue", "tongue_coating"),
"pulse": pick("pulse", "pulse_condition"),
"prescriptionOpinion": pick("prescription_opinion", "prescription_advice"),
"remark": pick("remark"),
}
class _ChineseQtTranslator(QTranslator):
@@ -247,9 +410,11 @@ class ApplicationController(QObject):
self.shell_window: ShellWindow | None = None
self.current_repository: Any = None
self.current_demo_mode = config.demo_mode
self.video_calls: dict[str, Any] = {}
self.video_pending: dict[str, object] = {}
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
self.video_calls: dict[str, Any] = {}
self.video_pending: dict[str, object] = {}
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
self._video_preview_state: dict[str, Any] | None = None
self._video_preview_generation = 0
self._restore_generation = 0
self._restore_in_progress = False
self._restore_worker: Any = None
@@ -547,16 +712,17 @@ class ApplicationController(QObject):
self._logout(message="登录状态已失效,请重新登录。")
return True
def _logout(self, *, message: str = "") -> None:
"""Clear authenticated resources and return to the login window."""
calls = tuple(self.video_calls.values())
def _logout(self, *, message: str = "") -> None:
"""Clear authenticated resources and return to the login window."""
self._restore_video_preview(activate=False)
calls = tuple(self.video_calls.values())
for call in calls:
with suppress(Exception):
call.close()
self._wait_for_video_lifecycle(calls, timeout=1.25)
self.video_calls.clear()
self.video_pending.clear()
self.video_calls.clear()
self.video_pending.clear()
for dialog in self.demo_video_dialogs.values():
dialog.close()
self.demo_video_dialogs.clear()
@@ -579,7 +745,8 @@ class ApplicationController(QObject):
patient_id = payload.get("patient_id")
diagnosis_id = payload.get("diagnosis_id")
patient_name = str(payload.get("patient_name") or "患者")
open_im = str(payload.get("mode") or "video").lower() == "im"
open_im = str(payload.get("mode") or "video").lower() == "im"
fallback_record = payload.get("record")
if patient_id in (None, "") or diagnosis_id in (None, ""):
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
return
@@ -633,27 +800,49 @@ class ApplicationController(QObject):
marker = object()
self.video_pending[call_key] = marker
def get_ticket() -> Any:
return repository.get_call_ticket(
patient_id=int(patient_id),
diagnosis_id=int(diagnosis_id),
)
def get_video_context() -> tuple[Any, dict[str, str]]:
ticket = repository.get_call_ticket(
patient_id=int(patient_id),
diagnosis_id=int(diagnosis_id),
)
detail: Any = {}
detail_loader = getattr(repository, "patient_detail", None)
if callable(detail_loader):
try:
detail = detail_loader(int(diagnosis_id))
except Exception as error:
LOGGER.warning(
"patient detail could not be loaded for video call",
extra={
"diagnosis_id": diagnosis_id,
"error_type": type(error).__name__,
},
)
patient_case = _build_video_patient_case(
detail,
fallback_record,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
patient_name=patient_name,
)
return ticket, patient_case
def request_ticket() -> None:
if self.video_pending.get(call_key) is not marker:
return
run_async(
get_ticket,
on_success=lambda ticket: self._launch_video(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
repository=repository,
run_async(
get_video_context,
on_success=lambda context: self._launch_video(
context[0],
diagnosis_id=diagnosis_id,
patient_id=patient_id,
repository=repository,
call_key=call_key,
marker=marker,
open_im=open_im,
patient_name=patient_name,
),
open_im=open_im,
patient_name=patient_name,
patient_case=context[1],
),
on_error=lambda error: self._video_ticket_error(
call_key,
marker,
@@ -696,8 +885,9 @@ class ApplicationController(QObject):
repository: Any,
call_key: str,
marker: object,
open_im: bool = False,
patient_name: str = "患者",
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
) -> None:
if self.video_pending.get(call_key) is not marker:
return
@@ -723,9 +913,13 @@ class ApplicationController(QObject):
local_dist=video_dist_path(),
remote_url=self.config.video_web_url or None,
logger=logging.getLogger("doctor_workstation.video"),
open_im=open_im,
patient_name=patient_name,
)
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=lambda current_id=diagnosis_id: (
self._open_video_diagnosis(current_id)
),
)
except Exception as error:
LOGGER.exception("video call could not be launched")
show_toast(
@@ -734,20 +928,123 @@ class ApplicationController(QObject):
"danger",
5600,
)
return
self.video_calls[call_key] = call
return
self.video_calls[call_key] = call
qt_window = getattr(call, "qt_window", None)
if qt_window is not None:
qt_window.destroyed.connect(
lambda _obj=None, key=call_key, expected=call: self._release_video_call(
key,
expected,
)
)
def _release_video_call(self, call_key: str, call: Any) -> None:
if self.video_calls.get(call_key) is call:
self.video_calls.pop(call_key, None)
)
)
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
"""Open the diagnosis while keeping its live video visible as a preview."""
shell = self.shell_window
if shell is None or self.current_repository is None:
return
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
if dialog is None:
return
call = self.video_calls.get(str(diagnosis_id))
video_window = getattr(call, "qt_window", None)
if video_window is not None:
self._show_video_preview(video_window, dialog)
def _show_video_preview(self, video_window: Any, dialog: QDialog) -> None:
"""Pin a compact call window above the modeless diagnosis drawer."""
current = self._video_preview_state
if current is not None and current.get("window") is not video_window:
self._restore_video_preview(activate=False)
self._video_preview_generation += 1
generation = self._video_preview_generation
if current is None or current.get("window") is not video_window:
try:
state = {
"window": video_window,
"geometry": video_window.geometry(),
"minimum_size": video_window.minimumSize(),
"maximized": video_window.isMaximized(),
"full_screen": video_window.isFullScreen(),
"stays_on_top": bool(
video_window.windowFlags()
& Qt.WindowType.WindowStaysOnTopHint
),
}
screen = video_window.screen() or QGuiApplication.primaryScreen()
available = screen.availableGeometry()
preview_width = min(540, max(460, round(available.width() * 0.29)))
preview_height = min(380, max(320, round(preview_width * 0.66)))
margin = 18
video_window.showNormal()
video_window.setMinimumSize(440, 300)
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
video_window.resize(preview_width, preview_height)
video_window.move(
available.x() + available.width() - preview_width - margin,
available.y() + margin,
)
video_window.show()
self._video_preview_state = state
except RuntimeError:
self._video_preview_state = None
return
dialog.finished.connect(
lambda _result, expected=generation: self._restore_video_preview(expected)
)
try:
video_window.raise_()
dialog.raise_()
dialog.activateWindow()
except RuntimeError:
self._video_preview_state = None
def _restore_video_preview(
self,
generation: int | None = None,
*,
activate: bool = True,
) -> None:
if generation is not None and generation != self._video_preview_generation:
return
state = self._video_preview_state
if state is None:
return
self._video_preview_state = None
self._video_preview_generation += 1
window = state.get("window")
try:
window.setWindowFlag(
Qt.WindowType.WindowStaysOnTopHint,
bool(state.get("stays_on_top")),
)
window.setMinimumSize(state["minimum_size"])
window.setGeometry(state["geometry"])
if state.get("full_screen"):
window.showFullScreen()
elif state.get("maximized"):
window.showMaximized()
else:
window.showNormal()
window.raise_()
if activate:
window.activateWindow()
except (AttributeError, RuntimeError):
return
def _release_video_call(self, call_key: str, call: Any) -> None:
preview = self._video_preview_state
if preview is not None and preview.get("window") is getattr(call, "qt_window", None):
self._video_preview_state = None
self._video_preview_generation += 1
if self.video_calls.get(call_key) is call:
self.video_calls.pop(call_key, None)
def _forget_demo_dialog(self, call_key: str, dialog: DemoVideoDialog) -> None:
if self.demo_video_dialogs.get(call_key) is dialog:
@@ -778,15 +1075,16 @@ class ApplicationController(QObject):
if icon_file.exists():
window.setWindowIcon(QIcon(str(icon_file)))
def shutdown(self) -> None:
def shutdown(self) -> None:
"""Invalidate asynchronous restoration and release owned resources."""
if self._shutting_down:
return
self._shutting_down = True
self._cancel_session_restore()
set_authentication_expired_handler(None)
calls = tuple(self.video_calls.values())
self._cancel_session_restore()
set_authentication_expired_handler(None)
self._restore_video_preview(activate=False)
calls = tuple(self.video_calls.values())
for call in calls:
with suppress(Exception):
call.close()
@@ -15,6 +15,7 @@ from urllib.parse import urlsplit
from PySide6.QtCore import QObject, QPointF, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot
from PySide6.QtGui import (
QColor,
QFont,
QIcon,
QKeyEvent,
QPainter,
@@ -22,6 +23,9 @@ from PySide6.QtGui import (
QPen,
QPixmap,
QPolygonF,
QTextBlockFormat,
QTextCharFormat,
QTextCursor,
)
from PySide6.QtWidgets import (
QDialog,
@@ -44,7 +48,7 @@ from PySide6.QtWidgets import (
from ..diagnosis_drawer import CaseGrid, _RemoteImageButton
from ..diagnosis_media import open_safe_http_url, safe_http_url
from ..theme import crisp_pixmap, mark_business_dialog
from ..theme import apply_reading_rhythm, crisp_pixmap, mark_business_dialog
from ..widgets import (
display_text,
first_value,
@@ -790,14 +794,24 @@ _DOCTOR_DOCUMENT_CSS = (
"li { margin:3px 0; line-height:1.58; } "
"a { color:#DDE1FF; } code,pre { color:#FFFFFF; }"
)
# 模型回复几乎都是“分节标题 + 两层要点”的长文。早期样式里标题只比正文大 1px、
# 段落与列表项的间距也几乎相同,整段读起来就是一堵字墙。这里把层级、行距和分节
# 留白拉开:标题明显更大更重,顶层小节之间留出整行空白,嵌套要点单独收紧缩进。
_AI_DOCUMENT_CSS = (
"body { color:#34436B; font-size:14px; line-height:1.68; } "
"h1,h2,h3,h4 { color:#15224A; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
"p { margin:6px 0; line-height:1.68; } ul,ol { margin:7px 0 7px 21px; } "
"li { margin:4px 0; line-height:1.62; } "
"strong { color:#15224A; } a { color:#5B67F1; } "
"body { color:#3A4870; font-size:14px; line-height:200%; } "
"h1,h2 { color:#111B3F; font-size:17px; margin:22px 0 10px; line-height:160%; } "
"h3,h4 { color:#15224A; font-size:15px; margin:18px 0 8px; line-height:160%; } "
"p { margin:10px 0; line-height:200%; } "
"ol { margin:12px 0 12px 22px; } "
"ul { margin:8px 0 8px 20px; } "
"li { margin:6px 0; line-height:190%; } "
# 顶层小节之间留出整行空白,让四个小节一眼可数。
"ol > li { margin:16px 0; } "
"ol > li > strong { color:#111B3F; font-size:15px; } "
"strong { color:#1B2A55; } a { color:#5B67F1; } "
"hr { margin:20px 0; } "
"code { color:#33406B; background:#EEF1FF; } "
"pre { color:#33406B; background:#F0F3FA; margin:8px 0; padding:8px; }"
"pre { color:#33406B; background:#F0F3FA; margin:12px 0; padding:10px; }"
)
_STRUCTURED_FIELD_LABELS = {
"diagnosis": "诊断建议",
@@ -1257,6 +1271,7 @@ def render_chat_payload(browser: QTextBrowser, raw: Any, *, role: str = "ai") ->
return
if isinstance(raw, (Mapping, list, tuple)):
browser.setMarkdown(_structured_markdown(raw))
apply_reading_rhythm(browser, role=role)
return
text = str(raw).strip()
if not text:
@@ -1269,12 +1284,15 @@ def render_chat_payload(browser: QTextBrowser, raw: Any, *, role: str = "ai") ->
parsed = None
if parsed is not None:
browser.setMarkdown(_structured_markdown(parsed))
apply_reading_rhythm(browser, role=role)
return
lowered = text[:240].lower()
if text.startswith("<") and any(marker in lowered for marker in _HTML_MARKERS):
browser.setHtml(text)
apply_reading_rhythm(browser, role=role)
return
browser.setMarkdown(text)
apply_reading_rhythm(browser, role=role)
def _unwrap_analysis(payload: Any) -> dict[str, Any]:
@@ -1894,6 +1912,75 @@ def _parse_clinical_analysis(raw: Any) -> _ClinicalAnalysisModel | None:
)
#: One section of a generic structured reply: a title plus its bullet points.
_ReportSection = namedtuple("_ReportSection", "title items")
_REPORT_NUMBERED_RE = re.compile(r"^\s*(\d{1,2})\s*[.、))]\s*(.+?)\s*$")
_REPORT_BULLET_RE = re.compile(r"^\s*[-*+•○]\s*(.+?)\s*$")
def parse_structured_report(raw: Any) -> tuple[str, tuple[_ReportSection, ...], str] | None:
"""Split a sectioned reply into ``(intro, sections, disclaimer)``.
The clinical panel only recognises four fixed section names (证候分析,
信息缺口, 建议下一步, 风险提示), so the sectioned answers models actually
return — "症状演变与疗效评估", "血糖控制与监测细节", … — fell through to plain
text. This accepts any reply that is genuinely sectioned: two or more
numbered or ``#`` headings, each carrying at least one bullet.
"""
if not isinstance(raw, str):
return None
text = raw.strip()
if len(text) < 120 or text[0] in "[{<" or "```" in text:
return None
intro: list[str] = []
disclaimer: list[str] = []
sections: list[_ReportSection] = []
collecting_disclaimer = False
for line in text.replace("\r\n", "\n").splitlines():
stripped = line.strip()
if not stripped or set(stripped) <= {"-", "*", "_"}:
continue
plain = _plain_markdown(stripped)
if collecting_disclaimer or plain.startswith(("重要提示", "免责声明", "提示:", "安全提示")):
collecting_disclaimer = True
disclaimer.append(plain)
continue
bullet = _REPORT_BULLET_RE.match(line)
if bullet is not None and sections:
sections[-1].items.append(_plain_markdown(bullet.group(1)))
continue
heading = ""
if stripped.startswith("#"):
heading = _plain_markdown(stripped.lstrip("#").strip())
else:
numbered = _REPORT_NUMBERED_RE.match(_plain_markdown(stripped))
# 顶层小节标题很短;带句号的长句是正文,不是标题。
if numbered is not None and len(numbered.group(2)) <= 28:
heading = numbered.group(2)
if heading:
sections.append(_ReportSection(heading, []))
continue
if sections:
sections[-1].items.append(plain)
elif bullet is None:
intro.append(plain)
usable = [section for section in sections if section.items]
if len(usable) < 2:
return None
return (
" ".join(intro).strip(),
tuple(_ReportSection(s.title, tuple(s.items)) for s in usable),
" ".join(disclaimer).strip(),
)
_InsightItem = namedtuple("_InsightItem", "kind marker label text")
_INSIGHT_LIST_RE = re.compile(r"^\s*(\d{1,2})\s*([\.、))]\s*)")
@@ -2088,6 +2175,110 @@ class _RichMessage(QTextBrowser):
self._fitting = False
class _StructuredReportPanel(QWidget):
"""Scan-first report for any sectioned reply.
Renders each section as its own card with a numbered chip, so a doctor can
count and jump between sections instead of reading a continuous column of
bullets.
"""
def __init__(
self,
intro: str,
sections: Sequence[_ReportSection],
disclaimer: str,
*,
time_text: str = "",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("AiConsultClinicalPanel")
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.sections = tuple(sections)
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(12)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.setSpacing(9)
title = QLabel("AI 结构化报告")
title.setObjectName("AiConsultClinicalTitle")
header.addWidget(title)
self.meta_label = QLabel(time_text)
self.meta_label.setObjectName("AiConsultClinicalMeta")
header.addWidget(self.meta_label)
header.addStretch(1)
badge = QLabel(f"{len(self.sections)} 个要点")
badge.setObjectName("AiConsultClinicalBadge")
badge.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
header.addWidget(badge, 0, Qt.AlignmentFlag.AlignVCenter)
root.addLayout(header)
if intro:
summary = QFrame(self)
summary.setObjectName("AiConsultClinicalSummary")
summary.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
_style_surface(summary)
summary_layout = QVBoxLayout(summary)
summary_layout.setContentsMargins(13, 11, 13, 11)
summary_layout.setSpacing(4)
body = QLabel(intro, summary)
body.setObjectName("AiConsultClinicalSummaryBody")
body.setWordWrap(True)
summary_layout.addWidget(body)
root.addWidget(summary)
for index, section in enumerate(self.sections, start=1):
root.addWidget(self._section_card(index, section))
if disclaimer:
note = QLabel(disclaimer, self)
note.setObjectName("AiConsultClinicalDisclaimer")
note.setWordWrap(True)
note.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
root.addWidget(note)
# 富余高度归到底部,否则会被平摊进每张卡片,把报告拉得空空荡荡。
root.addStretch(1)
def _section_card(self, index: int, section: _ReportSection) -> QFrame:
card = QFrame(self)
card.setObjectName("AiConsultClinicalSection")
card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
_style_surface(card)
layout = QVBoxLayout(card)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(8)
head = QHBoxLayout()
head.setContentsMargins(0, 0, 0, 0)
head.setSpacing(8)
chip = QLabel(str(index), card)
chip.setObjectName("AiConsultHypothesisTag")
chip.setAlignment(Qt.AlignmentFlag.AlignCenter)
head.addWidget(chip)
title = QLabel(section.title, card)
title.setObjectName("AiConsultClinicalSectionTitle")
title.setWordWrap(True)
head.addWidget(title, 1)
layout.addLayout(head)
for item in section.items:
row = QFrame(card)
row.setObjectName("AiConsultGapRow")
_style_surface(row)
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(11, 9, 11, 9)
row_layout.setSpacing(8)
body = QLabel(item, row)
body.setObjectName("AiConsultClinicalBody")
body.setWordWrap(True)
row_layout.addWidget(body, 1)
layout.addWidget(row)
return card
class _ClinicalAnalysisPanel(QWidget):
"""High-contrast, scan-first presentation for completed clinical replies."""
@@ -2499,14 +2690,27 @@ class _ChatBubble(QWidget):
if self._role != "ai" or self.body is None:
return False
model = _parse_clinical_analysis(self._raw_payload)
if model is None:
panel: QWidget | None = None
if model is not None:
panel = _ClinicalAnalysisPanel(
model,
time_text=self.stamp.text(),
parent=self._bubble_frame,
)
else:
# 临床面板只认四个固定小节名;其余同样分好节的长回复走通用结构化报告,
# 而不是退回成一整段纯文本。
report = parse_structured_report(self._raw_payload)
if report is not None:
panel = _StructuredReportPanel(
*report,
time_text=self.stamp.text(),
parent=self._bubble_frame,
)
if panel is None:
return False
self._reset_clinical_panel()
panel = _ClinicalAnalysisPanel(
model,
time_text=self.stamp.text(),
parent=self._bubble_frame,
)
panel.setParent(self._bubble_frame)
self._clinical_panel = panel
self._role_name.hide()
self.stamp.hide()
@@ -2691,6 +2895,14 @@ class _ClickCard(QFrame):
# the doctor can see exactly what material is going to the model.
AI_CONTEXT_MAX_CHARS = 320
AI_PROMPT_LIMIT = 500
#: 首个增量到达前显示的占位文案,避免留下一块没有任何说明的空白气泡。
AI_STREAM_PENDING_TEXT = "正在生成…"
#: 连接结束但一个字都没收到时的兜底文案。
AI_STREAM_SILENT_TEXT = (
"AI 助手没有返回内容,可能是服务端未响应或连接中断,请稍后重试。"
)
AI_CONTEXT_SEPARATOR = "\n\n— 医生提问 —\n"
@@ -2998,6 +3210,10 @@ class AiConsultDialog(QDialog):
self._patient_ai_context: str = ""
self._patient_ai_context_labels: list[str] = []
self._patient_ai_context_bubble: _ChatBubble | None = None
# 全量上下文说明每轮问答都一样,只在本次会话第一次提问时提示一次。
self._context_notice_shown = False
# done 事件是否到达。用于区分“模型没内容”和“连接直接断了”。
self._stream_completed = False
self._follow_chat = True
self._flush_timer = QTimer(self)
self._flush_timer.setSingleShot(True)
@@ -3005,6 +3221,14 @@ class AiConsultDialog(QDialog):
self._flush_timer.timeout.connect(self._flush_stream_chunks)
self.setObjectName("AiConsultDialog")
self.setWindowTitle("问诊详情")
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
# 显式补上最大化/最小化按钮,并允许拖拽边角调整大小。
self.setWindowFlags(
self.windowFlags()
| Qt.WindowType.WindowMinimizeButtonHint
| Qt.WindowType.WindowMaximizeButtonHint
)
self.setSizeGripEnabled(True)
self.resize(1280, 820)
self.setMinimumSize(1080, 680)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
@@ -3047,6 +3271,8 @@ class AiConsultDialog(QDialog):
self._patient_ai_context = ""
self._patient_ai_context_labels = []
self._patient_ai_context_bubble = None
# 换患者等于开启新会话,全量上下文说明需要再提示一次。
self._context_notice_shown = False
# Appointment seeds are presentation hints only. In the admin list,
# ``patient_id`` may actually be the diagnosis id, so it must never
# replace the separately supplied patient owner.
@@ -5281,12 +5507,18 @@ class AiConsultDialog(QDialog):
self._append_bubble(
"doctor", text, time_text=datetime.now().strftime("%H:%M")
)
self._append_bubble(
"ai",
"服务端将按当前诊单实时附带患者全部纵向资料(病历、备注/舌苔、报告、日常记录、视频转写及历史处方)。",
time_text="全量上下文",
)
self._stream_bubble = self._append_bubble("ai", "")
if not self._context_notice_shown:
self._append_bubble(
"ai",
"服务端将按当前诊单实时附带患者全部纵向资料"
"(病历、备注/舌苔、报告、日常记录、视频转写及历史处方)。",
time_text="全量上下文",
)
self._context_notice_shown = True
# 首帧到达前先占位。空气泡在后端无响应时会一直是一块空白卡片,
# 医生无法判断是还在生成还是已经失败。
self._stream_bubble = self._append_bubble("ai", AI_STREAM_PENDING_TEXT)
self._stream_completed = False
self._stream_text = ""
self._pending_chunks.clear()
self._stream_meta.clear()
@@ -5341,6 +5573,7 @@ class AiConsultDialog(QDialog):
if kind != "done":
return
self._stream_meta.update(payload)
self._stream_completed = True
if not self._stream_text and not self._pending_chunks:
answer = first_value(payload, "answer", "content", default="")
if answer not in (None, ""):
@@ -5396,6 +5629,16 @@ class AiConsultDialog(QDialog):
return
if self._stream_worker is worker:
self._stream_worker = None
# 连接结束却既没有 done 也没有报错时(服务端静默断开),占位气泡会永远
# 停在“正在生成…”。这里补一条明确说明,而不是留一块空白卡片。
if (
not self._stream_completed
and not self._stream_text
and self._stream_bubble is not None
):
self._stream_text = AI_STREAM_SILENT_TEXT
self._stream_bubble.set_payload(self._stream_text)
self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M"))
self._asking = False
self.send_button.setEnabled(True)
@@ -5408,6 +5651,7 @@ class AiConsultDialog(QDialog):
self._stream_bubble = None
self._pending_chunks.clear()
self._stream_text = ""
self._stream_completed = False
self._stream_meta.clear()
self._asking = False
self.send_button.setEnabled(True)
@@ -1911,8 +1911,20 @@ class DiagnosisDialog(QDialog):
)
present_diagnosis_ai_report(self.repository, self.permissions, self, row)
def open_view_only(self, diagnosis_id: int, *, seed: Any = None) -> None:
self.open_for(diagnosis_id, editable=False, seed=seed, view_only=True)
def open_view_only(
self,
diagnosis_id: int,
*,
seed: Any = None,
modeless: bool = False,
) -> None:
self.open_for(
diagnosis_id,
editable=False,
seed=seed,
view_only=True,
modeless=modeless,
)
def open_for(
self,
@@ -1921,6 +1933,7 @@ class DiagnosisDialog(QDialog):
editable: bool = False,
seed: Any = None,
view_only: bool = False,
modeless: bool = False,
) -> None:
"""Open immediately, then replace the seed with authoritative server data."""
@@ -1973,7 +1986,7 @@ class DiagnosisDialog(QDialog):
self.view_stack.setCurrentWidget(
self.readonly_page if self._standalone_readonly else self.drawer_overlay
)
self.setModal(not self._standalone_readonly)
self.setModal(not modeless and not self._standalone_readonly)
self.setWindowTitle(
"患者信息详情"
if self._standalone_readonly
File diff suppressed because it is too large Load Diff
+51
View File
@@ -43,6 +43,7 @@ from doctor_workstation.resources import app_icon_path
from .dialogs.ai_consult import can_open_ai_consult
from .dialogs.ai_consult_picker import select_and_present_ai_consult
from .dialogs.diagnosis import DiagnosisDialog
from .dialogs.local_audio_queue import LocalAudioQueueDialog
from .pages import (
AppointmentsPage,
@@ -59,6 +60,7 @@ from .widgets import (
display_text,
first_value,
get_value,
has_permission,
show_toast,
)
@@ -895,6 +897,7 @@ class ShellWindow(QMainWindow):
self._activation_generation = 0
self._activation_refreshed = False
self._local_audio_settings_dialog: LocalAudioQueueDialog | None = None
self._global_diagnosis_dialog: DiagnosisDialog | None = None
self.setMinimumSize(_SHELL_MINIMUM_SIZE)
screen = self.screen() or QApplication.primaryScreen()
@@ -1845,6 +1848,54 @@ class ShellWindow(QMainWindow):
if callable(refresh):
refresh()
def _ensure_global_diagnosis_dialog(self) -> DiagnosisDialog:
dialog = self._global_diagnosis_dialog
if dialog is None:
dialog = DiagnosisDialog(
self.repository,
self,
permissions=self.permissions,
)
dialog.saved.connect(self.refresh_current_page)
self._global_diagnosis_dialog = dialog
else:
dialog.refresh_permissions(self.permissions)
return dialog
def open_diagnosis_by_id(
self,
diagnosis_id: Any,
*,
modeless: bool = False,
) -> DiagnosisDialog | None:
"""Open one authoritative diagnosis in the strongest permitted mode."""
try:
normalized_id = int(diagnosis_id)
except (TypeError, ValueError):
normalized_id = 0
if normalized_id <= 0:
show_toast(self, "当前视频缺少有效诊单编号。", "warning", 3600)
return None
if has_permission(self.permissions, "tcm.diagnosis/edit", default=False):
dialog = self._ensure_global_diagnosis_dialog()
dialog.open_for(normalized_id, editable=True, modeless=modeless)
elif has_permission(
self.permissions,
"tcm.diagnosis/readonlyDetail",
default=False,
):
dialog = self._ensure_global_diagnosis_dialog()
dialog.open_view_only(normalized_id, modeless=modeless)
else:
show_toast(self, "当前账号没有查看该诊单的权限。", "danger", 4200)
return None
dialog.raise_()
dialog.activateWindow()
return dialog
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().showEvent(event)
page = self.stack.currentWidget()
+158 -3
View File
@@ -15,9 +15,19 @@ import os
import sys
from pathlib import Path
from string import Template
from typing import Any
from PySide6.QtCore import QEvent, QObject, Qt
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette, QPixmap
from PySide6.QtGui import (
QColor,
QFont,
QFontDatabase,
QPalette,
QPixmap,
QTextBlockFormat,
QTextCharFormat,
QTextCursor,
)
from PySide6.QtWidgets import (
QApplication,
QDialog,
@@ -25,6 +35,7 @@ from PySide6.QtWidgets import (
QFileDialog,
QInputDialog,
QMessageBox,
QTextBrowser,
)
# Canonical semantic tokens. The legacy teal/ink aliases remain available to
@@ -997,6 +1008,111 @@ QToolTip {
).substitute(_QSS_TOKENS)
#: Per-block reading rhythm, in device pixels. ``(top, bottom, line%)``.
#: QTextDocument's markdown importer builds block formats directly and ignores
#: ``setDefaultStyleSheet``, so spacing has to be applied to the parsed
#: document instead of being declared in CSS.
# 行内收紧、行间放开:一条要点自己的折行必须比两条要点之间更紧,否则整段会散成
# 一行一行的碎片,反而更难读。
_BLOCK_RHYTHM = {
"heading": (22, 10, 148),
"subheading": (18, 8, 148),
# 顶层列表项通常就是小节标题,之间留出整行空白让各小节一眼可数。
"section": (20, 8, 150),
"item": (13, 5, 158), # nested bullet under a section
"paragraph": (10, 10, 168),
}
def _block_is_all_bold(block: Any) -> bool:
"""True when every visible run in the block is bold.
Models title their sections as ``1. **症状演变与疗效评估**`` rather than as
real markdown headings, so a fully bold top-level list item is the only
reliable signal that a block is a section title and not a content bullet.
"""
iterator = block.begin()
seen = False
while not iterator.atEnd():
fragment = iterator.fragment()
iterator += 1
if not fragment.isValid() or not fragment.text().strip():
continue
seen = True
if fragment.charFormat().fontWeight() < QFont.Weight.DemiBold.value:
return False
return seen
def apply_reading_rhythm(browser: QTextBrowser, *, role: str) -> None:
"""Give a parsed reply real hierarchy and breathing room.
Model answers are long "section heading + two levels of bullets" documents.
Qt renders every one of those blocks at the same size with 6px margins, so
the reply arrives as a wall of text. This walks the parsed document once
and applies a heading scale plus per-level spacing.
"""
document = browser.document()
base_font = browser.font()
base_px = base_font.pixelSize()
if base_px <= 0:
base_px = max(12, round(base_font.pointSizeF() * 96 / 72))
cursor = QTextCursor(document)
cursor.beginEditBlock()
try:
block = document.begin()
while block.isValid():
heading = block.blockFormat().headingLevel()
text_list = block.textList()
indent = text_list.format().indent() if text_list is not None else 0
if heading:
kind = "heading" if heading <= 2 else "subheading"
size = base_px + (3 if heading <= 2 else 1)
elif indent >= 2:
kind, size = "item", None
elif indent == 1:
kind = "section"
# 只有整行加粗的顶层条目才是小节标题,普通要点保持正文字号。
size = base_px + 2 if _block_is_all_bold(block) else None
else:
kind, size = "paragraph", None
top, bottom, line = _BLOCK_RHYTHM[kind]
# 首块的上边距会在气泡顶部留出一段空白,视觉上像是排版错位。
if block.position() == 0:
top = 0
block_format = QTextBlockFormat(block.blockFormat())
block_format.setTopMargin(top)
block_format.setBottomMargin(bottom)
block_format.setLineHeight(
line, QTextBlockFormat.LineHeightTypes.ProportionalHeight.value
)
cursor.setPosition(block.position())
cursor.setBlockFormat(block_format)
if size is not None and block.length() > 1:
heading_font = QFont(base_font)
heading_font.setPixelSize(size)
heading_font.setBold(True)
char_format = QTextCharFormat()
char_format.setFont(heading_font)
if role != "doctor":
char_format.setForeground(QColor("#111B3F"))
cursor.setPosition(block.position())
cursor.setPosition(
block.position() + block.length() - 1,
QTextCursor.MoveMode.KeepAnchor,
)
cursor.mergeCharFormat(char_format)
block = block.next()
finally:
cursor.endEditBlock()
def _apply_group(
palette: QPalette,
group: QPalette.ColorGroup,
@@ -1085,17 +1201,48 @@ def _polish_dialog_buttons(button_box: QDialogButtonBox) -> None:
_refresh_widget_style(button)
def allow_dialog_resize(dialog: QDialog) -> None:
"""Give a business subwindow the usual minimise/maximise affordances.
``QDialog`` ships with a close button only, so every AI panel, report and
editor was stuck at whatever size it was constructed with — unusable for the
long, dense clinical replies these windows carry. Transient prompts
(message and input boxes) are deliberately left alone, and a dialog that has
pinned itself to a fixed size keeps that decision.
"""
if isinstance(dialog, (QMessageBox, QInputDialog, QFileDialog)):
return
if dialog.minimumSize() == dialog.maximumSize():
return
# Qt hides a window whose flags change while it is visible, so a dialog that
# is already on screen keeps the flags it was shown with.
if dialog.isVisible():
dialog.setSizeGripEnabled(True)
return
flags = dialog.windowFlags()
if flags & Qt.WindowType.WindowMaximizeButtonHint:
return
dialog.setWindowFlags(
flags
| Qt.WindowType.WindowMinimizeButtonHint
| Qt.WindowType.WindowMaximizeButtonHint
)
dialog.setSizeGripEnabled(True)
def mark_business_dialog(dialog: QDialog, object_name: str | None = None) -> None:
"""Opt a business subwindow into the shared visual contract.
The helper intentionally does not change modality, ownership or result
handling. It only supplies stable styling metadata and semantic button
roles, so existing workflows keep their original behavior.
handling. It only supplies stable styling metadata, semantic button roles
and window affordances, so existing workflows keep their original behavior.
"""
if object_name and not dialog.objectName():
dialog.setObjectName(object_name)
dialog.setProperty("businessDialog", True)
allow_dialog_resize(dialog)
for button_box in dialog.findChildren(QDialogButtonBox):
_polish_dialog_buttons(button_box)
_refresh_widget_style(dialog)
@@ -1119,6 +1266,12 @@ class _BusinessDialogStyleFilter(QObject):
QEvent.Type.Show,
}:
_polish_dialog_buttons(watched)
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Polish:
# Window flags can only be changed before the dialog is on screen,
# so dynamically-created dialogs get their affordances at polish
# time rather than when they are already visible.
if not self._is_native_or_overlay(watched):
allow_dialog_resize(watched)
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Show:
for button_box in watched.findChildren(QDialogButtonBox):
_polish_dialog_buttons(button_box)
@@ -1215,6 +1368,8 @@ def apply_theme(app: QApplication) -> None:
__all__ = [
"COLORS",
"allow_dialog_resize",
"apply_reading_rhythm",
"GLOBAL_QSS",
"METRICS",
"TYPE",
@@ -378,6 +378,8 @@ class VideoCallLauncher:
patient_id: Any = None,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> Any:
request = self.prepare(
ticket,
@@ -395,6 +397,8 @@ class VideoCallLauncher:
browser_opener=self.browser_opener,
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
)
@@ -411,6 +415,8 @@ def launch_video_call(
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> Any:
"""Normalize a ticket and open a call with the requested backend."""
@@ -427,4 +433,6 @@ def launch_video_call(
patient_id=patient_id,
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
)
@@ -296,6 +296,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
lifecycle_factory: Callable[[], OrderedCallLifecycle],
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> None:
super().__init__()
self.request = request
@@ -306,6 +308,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.logger = logger
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
self.patient_case = dict(patient_case or {})
self._on_open_diagnosis = on_open_diagnosis
try:
self._policy = TrustedDocumentPolicy.from_url(
location.url,
@@ -451,6 +455,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
config = {
**self.request.to_web_config(),
"patientName": self.patient_name,
"patientCase": self.patient_case,
"mode": "chat" if self.open_im else "video",
}
config_json = json.dumps(config, ensure_ascii=True, separators=(",", ":"))
@@ -499,6 +504,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if self._closing:
return
event = str(message.get("event", ""))
if event == "open-diagnosis-request":
if self._on_open_diagnosis is not None:
QTimer.singleShot(0, self._open_diagnosis_safely)
return
if event == "call-start-request":
self._start_call_cycle()
return
@@ -580,6 +589,17 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if not self.open_im or self._shutdown_requested:
self._close_from_companion("companion-error")
def _open_diagnosis_safely(self) -> None:
if self._closing or self._on_open_diagnosis is None:
return
try:
self._on_open_diagnosis()
except Exception:
self.logger.exception(
"diagnosis drawer could not be opened from video companion",
extra={"video_call": self.request.safe_log_context()},
)
def _notify_room_completed(self, room_id: str, future: Future[bool]) -> None:
try:
succeeded = bool(future.result())
@@ -1214,6 +1234,8 @@ class VideoCallWindow:
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> None:
del browser_opener # Reserved for a future authenticated handoff implementation.
try:
@@ -1231,6 +1253,8 @@ class VideoCallWindow:
self.repository = repository
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
self.patient_case = dict(patient_case or {})
self.on_open_diagnosis = on_open_diagnosis
self.logger = logger or _LOGGER
self.location = resolve_companion_location(
local_dist=local_dist,
@@ -1259,6 +1283,8 @@ class VideoCallWindow:
lifecycle_factory=self._new_lifecycle,
open_im=self.open_im,
patient_name=self.patient_name,
patient_case=self.patient_case,
on_open_diagnosis=self.on_open_diagnosis,
)
except Exception:
self.lifecycle.end("window-open-failed")
@@ -1302,6 +1328,8 @@ def open_video_call(
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> VideoCallWindow:
"""Create and immediately open a trusted embedded video window."""
@@ -1316,6 +1344,8 @@ def open_video_call(
browser_opener=browser_opener,
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
).open()