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