This commit is contained in:
Your Name
2026-10-17 16:51:48 +08:00
parent 56f85f7d89
commit ba2e2e4087
41 changed files with 2851 additions and 96 deletions
@@ -235,6 +235,9 @@ class DoctorRepository(Protocol):
def get_prescription(self, prescription_id: int) -> Prescription:
"""Return one issued prescription."""
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
"""Read the server's current agreement visibility decision."""
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
"""Read cached dual-model states for up to 100 prescriptions."""
@@ -1611,6 +1614,9 @@ class RemoteDoctorRepository:
payload = _client_request(self.client, "post" if mutation else "get", endpoint, params, timeout=30.0)
return dict(_require_mapping(payload, endpoint))
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
return self._prescription_ai_request("capabilities", {})
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
if len(ids) > 100 or any(int(value) <= 0 for value in ids):
raise ValueError("statuses requires at most 100 positive prescription IDs")
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
from copy import deepcopy
from html import escape
from math import isfinite
from time import monotonic
@@ -402,6 +403,28 @@ def can_open_issued_ai(permissions: Any) -> bool:
return has_permission(permissions, "tcm.prescriptionAi/reports", default=False) and has_permission(permissions, "tcm.prescriptionAi/detail", default=False)
def agreement_safe_batch(value: Any, visible: bool) -> dict[str, Any]:
"""Discard restricted structured comparisons before retaining any UI cache."""
data = deepcopy(mapping(value))
if visible:
return data
fields = {"comparison", "score", "herb_score", "comparison_status", "comparison_reason_code", "comparison_type",
"algorithm_version", "normalization_version", "denominator", "dictionary_version", "dictionary_versions",
"baseline_eligible", "baseline_exclusion_reasons", "baseline_exclusions_json"}
for item in (data, *mapping(data.get("models")).values()):
if isinstance(item, dict):
for field in fields:
item.pop(field, None)
for model in mapping(data.get("models")).values():
if isinstance(model, dict) and not model.get("error_code"):
# Successful model reasons describe the comparison, while failed
# task reasons explain generation and remain useful with reports.
model.pop("reason", None)
data["agreement_visible"] = False
return data
def _html(value: Any, field: str = "") -> str:
"""Escape all stored/model content, including source URLs, before rendering."""
if isinstance(value, Mapping):
@@ -536,6 +559,8 @@ class IssuedPrescriptionAiDialog(QDialog):
self._progress_frozen_seconds = 0
self._progress_stale = False
self._read_access_denied = False
self._agreement_visible = False
self._history_agreement_visible = False
self.setWindowTitle("诊断与药方 · AI 分析报告")
self.resize(1440, 940)
self.setMinimumSize(940, 640)
@@ -575,7 +600,7 @@ class IssuedPrescriptionAiDialog(QDialog):
identity_block.addWidget(self.breadcrumb)
title_row = QHBoxLayout()
title_row.setSpacing(10)
heading = QLabel("诊断与药方对照", top)
heading = self.heading = QLabel("诊断与药方对照", top)
heading.setObjectName("AiHeroTitle")
title_row.addWidget(heading)
self.chip_row = QHBoxLayout()
@@ -903,7 +928,7 @@ class IssuedPrescriptionAiDialog(QDialog):
footer_text = QVBoxLayout()
footer_text.setSpacing(4)
footer_text.addWidget(self.message)
note = QLabel(DISCLAIMER)
note = self.disclaimer = QLabel(DISCLAIMER)
note.setObjectName("AiFootnote")
note.setWordWrap(True)
footer_text.addWidget(note)
@@ -921,6 +946,7 @@ class IssuedPrescriptionAiDialog(QDialog):
self._progress_timer.timeout.connect(self._render_progress)
self._sync_actions()
self._navigation_changed(self.tabs.currentIndex())
self._apply_agreement_visibility(self._agreement_visible)
REPORT_MODES = (("both", "并排对照"), ("qwen", "仅千问"), ("openai", "仅 OpenAI"),
("differences", "只看不同段落"))
@@ -1063,6 +1089,8 @@ class IssuedPrescriptionAiDialog(QDialog):
return host
def _goto(self, key: str) -> None:
if not self._agreement_visible:
key = "candidate" if key == "per_herb" else "report" if key in {"comparison_raw", "history"} else key
page = self.tab_pages.get(key)
if page is not None:
self.tabs.setCurrentWidget(page)
@@ -1070,7 +1098,10 @@ class IssuedPrescriptionAiDialog(QDialog):
def _navigation_changed(self, index: int) -> None:
widget = self.tabs.widget(index)
current = next((key for key, page in self.tab_pages.items() if page is widget), "")
self.rail.set_current(current)
if not self._agreement_visible and current in {"per_herb", "comparison_raw", "history"}:
self._goto("report")
return
self.rail.set_current("per_herb" if current == "candidate" and not self._agreement_visible else current)
self._current_page = current
self._apply_density()
@@ -1101,6 +1132,36 @@ class IssuedPrescriptionAiDialog(QDialog):
if field in MODELS:
self.model_views[field]["report"].setFocus()
def _apply_agreement_visibility(self, visible: bool) -> None:
"""One server decision controls every comparison surface, without losing review."""
self._agreement_visible = visible
self.agreement.set_agreement_visible(visible)
self.agreement_note.setVisible(visible)
self.heading.setText("诊断与药方对照" if visible else "诊断与药方报告")
self.disclaimer.setText(DISCLAIMER if visible else "候选建议仅供医生复核。")
self.comparison_panel.set_agreement_visible(visible)
self.rail.names["overview"].setText("对比总览" if visible else "报告复核")
self.rail.names["per_herb"].setText("候选与逐味" if visible else "AI 候选处方")
self.nav_buttons["history"].setVisible(visible)
for key in ("differences", "consensus"):
self.rail.focus_values[key].parentWidget().setVisible(visible)
if not visible:
self._batch = agreement_safe_batch(self._batch, False) if self._batch else {}
self._history_scope = {key: agreement_safe_batch(row, False) for key, row in self._history_scope.items()}
self.history_page.set_history([])
self.candidates_page.set_batch({})
for views in self.model_views.values():
views["comparison"].clear()
self.comparison_panel.set_batch(self._batch)
self.rail.set_focus({})
self.rail.badges["overview"].clear()
self.rail.badges["per_herb"].clear()
self.rail.badges["overview"].hide()
self.rail.badges["per_herb"].hide()
if self._current_page in {"per_herb", "comparison_raw", "history"}:
self._goto("report")
@property
def save_review_button(self) -> QPushButton:
"""The review card's own save action for the model currently being annotated."""
@@ -1206,9 +1267,12 @@ class IssuedPrescriptionAiDialog(QDialog):
return
self._read_access_denied = False
data = mapping(result)
self._history_agreement_visible = data.get("agreement_visible") is True
self._apply_agreement_visibility(self._history_agreement_visible)
self._feature_enabled = data.get("enabled", True) is not False
rows = data.get("lists") or []
self.history_page.set_history([mapping(row) for row in rows])
rows = [agreement_safe_batch(row, self._history_agreement_visible and mapping(row).get("agreement_visible") is True)
for row in data.get("lists") or []]
self.history_page.set_history(rows if self._agreement_visible else [])
self.history.blockSignals(True)
self.history.clear()
self._history_scope = {}
@@ -1321,6 +1385,8 @@ class IssuedPrescriptionAiDialog(QDialog):
def _read_failed(self, error: Exception, prefix: str) -> None:
self._timer.stop()
self._history_agreement_visible = False
self._apply_agreement_visibility(False)
text = str(error).lower()
restricted = isinstance(error, (ValueError, AuthenticationExpiredError)) or getattr(error, "status_code", None) in {401, 403, 404} or any(
marker in text for marker in ("forbidden", "unauthorized", "permission", "access_revoked", "not found", "权限", "无权", "登录", "归属", "不存在")
@@ -1354,6 +1420,10 @@ class IssuedPrescriptionAiDialog(QDialog):
if int(batch.get("id") or 0) != batch_id or not identity_matches or (self.prescription_id and int(batch.get("prescription_id") or 0) != self.prescription_id):
self._detail_error(ValueError("报告归属与当前处方或诊单不一致"), generation)
return
visible = (self._history_agreement_visible and batch.get("agreement_visible") is True
and mapping(expected).get("agreement_visible") is True)
self._apply_agreement_visibility(visible)
batch = agreement_safe_batch(batch, visible)
first_load = not self._batch
self._batch = batch
self._read_access_denied = False
@@ -1368,7 +1438,7 @@ class IssuedPrescriptionAiDialog(QDialog):
self.identity.setText(f"处方 {batch.get('prescription_id')} · 诊单 {batch.get('diagnosis_id')} · 版本 {batch.get('prescription_revision', '—')}")
self._set_chips([
(label(batch.get("validity")), "info" if current_batch(batch) else "warn"),
(label(batch.get("comparison_type")), "neutral"),
(label(batch.get("comparison_type")) if self._agreement_visible else "", "neutral"),
])
self.batch_summary.setText(f"{compact_state} {_reason(batch.get('error_message') or batch.get('error_code'))}\n资料截止:{format_record_time(batch.get('cutoff_at'))} · 批次建立:{format_record_time(batch.get('created_at'))}")
self._render_facts(batch)
@@ -1411,9 +1481,11 @@ class IssuedPrescriptionAiDialog(QDialog):
"risk_assessment": report.get("risk_assessment"),
}
_set_html(views["candidate"], _candidate_html(candidate_content))
comparison_html = f"<p><b>药味与剂量一致度:{percentage(score, 1)} 纯药味重合度:{percentage(comparison.get('herb_score'), 1)}</b></p>"
comparison_html += "<p class=sec>逐味贡献</p>" + _html(_comparison_rows(comparison.get("rows")) or "没有可展示的逐味对照。")
comparison_html += _html({field: value for field, value in comparison.items() if field not in {"score", "herb_score", "rows"}})
comparison_html = ""
if self._agreement_visible:
comparison_html = f"<p><b>药味与剂量一致度:{percentage(score, 1)} 纯药味重合度:{percentage(comparison.get('herb_score'), 1)}</b></p>"
comparison_html += "<p class=sec>逐味贡献</p>" + _html(_comparison_rows(comparison.get("rows")) or "没有可展示的逐味对照。")
comparison_html += _html({field: value for field, value in comparison.items() if field not in {"score", "herb_score", "rows"}})
_set_html(views["comparison"], comparison_html)
source_html = _html({"source_summary": batch.get("source_summary"), "missing": batch.get("missing"), "coverage": model.get("coverage"), "baseline_eligible": batch.get("baseline_eligible"), "baseline_exclusion_reasons": batch.get("baseline_exclusion_reasons"), "cutoff_at": format_record_time(batch.get("cutoff_at")), "generated_at": format_record_time(model.get("generated_at"))})
reference_lines = [line for line in report_provenance_lines(model)
@@ -1428,7 +1500,7 @@ class IssuedPrescriptionAiDialog(QDialog):
views["comment"].setPlainText(str(review.get("comment") or ""))
index = views["review_state"].findData(review.get("status"))
views["review_state"].setCurrentIndex(max(0, index))
self.candidates_page.set_batch(batch)
self.candidates_page.set_batch(batch if self._agreement_visible else {})
self.sources_page.set_batch(batch)
self.pipeline_page.set_batch(batch)
self._sync_failures(batch)
@@ -1488,6 +1560,12 @@ class IssuedPrescriptionAiDialog(QDialog):
"restricted": panel.restricted_count(data) if hasattr(panel, "restricted_count") else None,
"consensus": panel.consensus_count() if hasattr(panel, "consensus_count") else None,
})
if not self._agreement_visible:
for key in ("differences", "consensus"):
self.rail.focus_values[key].clear()
for key in ("overview", "per_herb"):
self.rail.badges[key].clear()
self.rail.badges[key].hide()
def _render_facts(self, batch: Any) -> None:
snapshot = mapping(mapping(batch).get("doctor_snapshot"))
@@ -1707,6 +1785,7 @@ class PrescriptionAiStatisticsDialog(QDialog):
super().__init__(parent)
self.repository, self.permissions = repository, permissions
self._generation = 0
self._agreement_visible = False
self.setWindowTitle("医生处方 AI 一致度统计")
self.setStyleSheet(dialog_style() + rail_qss())
self.resize(1180, 860)
@@ -1742,6 +1821,7 @@ class PrescriptionAiStatisticsDialog(QDialog):
self.report.document().setDefaultStyleSheet(_report_css())
self.report.setMinimumHeight(130)
root.addWidget(self.report, 1)
self.panel.hide()
def showEvent(self, event: Any) -> None:
super().showEvent(event)
@@ -1750,9 +1830,19 @@ class PrescriptionAiStatisticsDialog(QDialog):
def hideEvent(self, event: Any) -> None:
self._generation += 1
self._clear_statistics()
super().hideEvent(event)
def _clear_statistics(self) -> None:
self._agreement_visible = False
self.panel.set_statistics({})
self.panel.hide()
self.report.clear()
def refresh(self) -> None:
self._generation += 1
generation = self._generation
self._clear_statistics()
if not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
self.summary.setText("没有查看医生统计的权限。")
return
@@ -1763,20 +1853,33 @@ class PrescriptionAiStatisticsDialog(QDialog):
if doctor and (not doctor.isdecimal() or int(doctor) <= 0):
self.summary.setText("请填写有效的医生编号。")
return
self._generation += 1
generation = self._generation
self.summary.setText("正在读取统计…")
self.report.clear()
self.summary.setText("正在核验一致度查看权限…")
method = getattr(self.repository, "get_prescription_ai_capabilities", None)
if not callable(method):
self.summary.setText("当前账号无权查看一致度统计。")
return
date_from, date_to = self.date_from.date().toString("yyyy-MM-dd"), self.date_to.date().toString("yyyy-MM-dd")
run_async(lambda: self.repository.prescription_ai_statistics(date_from, date_to, int(doctor) if doctor else None), on_success=lambda result: self._ready(result, generation), on_error=lambda error: self._error(error, generation))
run_async(method, on_success=lambda result: self._capabilities_ready(result, generation, date_from, date_to, int(doctor) if doctor else None),
on_error=lambda error: self._error(error, generation))
def _capabilities_ready(self, result: Any, generation: int, date_from: str, date_to: str, doctor: int | None) -> None:
if generation != self._generation:
return
if mapping(result).get("agreement_visible") is not True or not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
self._clear_statistics()
self.summary.setText("当前账号无权查看一致度统计。")
return
self._agreement_visible = True
self.summary.setText("正在读取统计…")
run_async(lambda: self.repository.prescription_ai_statistics(date_from, date_to, doctor), on_success=lambda result: self._ready(result, generation), on_error=lambda error: self._error(error, generation))
def _error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
self.summary.setText("统计暂不可用:" + _error_text(error))
self.report.clear()
self._clear_statistics()
def _ready(self, result: Any, generation: int) -> None:
if generation != self._generation:
if generation != self._generation or not self._agreement_visible:
return
data = mapping(result)
if data.get("enabled") is False:
@@ -1786,6 +1889,7 @@ class PrescriptionAiStatisticsDialog(QDialog):
return
self.summary.setText(f"范围内开方事件:{label(data.get('total_count'))} · 患者数:{label(data.get('patient_count'))}。小样本应谨慎解读,未按分数排名。")
self.panel.set_statistics(data)
self.panel.show()
sections = []
for row in data.get("doctors") or []:
doctor = mapping(row)
@@ -190,6 +190,8 @@ class AgreementBar(QFrame):
layout = QHBoxLayout(self)
layout.setContentsMargins(22, 16, 22, 16)
layout.setSpacing(24)
self._agreement_visible = True
self._metric_chrome: list[QWidget] = []
self.blocks: dict[str, dict[str, Any]] = {}
for index, key in enumerate(("qwen", "openai")):
if index:
@@ -197,11 +199,14 @@ class AgreementBar(QFrame):
divider.setObjectName("AiFactDivider")
divider.setFixedWidth(1)
layout.addWidget(divider)
layout.addWidget(self._delta_block())
delta = self._delta_block()
layout.addWidget(delta)
self._metric_chrome.extend((divider, delta))
divider = QFrame(self)
divider.setObjectName("AiFactDivider")
divider.setFixedWidth(1)
layout.addWidget(divider)
self._metric_chrome.append(divider)
# Both heads line up at the top, so a model that failed does not slide its column down.
layout.addWidget(self._model_block(key), 1, Qt.AlignmentFlag.AlignTop)
@@ -294,7 +299,7 @@ class AgreementBar(QFrame):
carrier.setFixedHeight(4)
carrier.setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
carrier.setVisible(False)
self.blocks[key] = {"score": score, "coverage": coverage, "elapsed": elapsed, "track": track,
self.blocks[key] = {"score": score, "caption": caption, "coverage": coverage, "elapsed": elapsed, "track": track,
"status": status, "failure": failure, "failure_text": failure_text,
"retry_slot": retry_slot, "stage": stage, "carrier": carrier}
return holder
@@ -313,6 +318,30 @@ class AgreementBar(QFrame):
self.blocks[key]["retry_slot"].addWidget(button)
def set_agreement_visible(self, visible: bool) -> None:
"""Keep model states and retries available when the metric is restricted."""
self._agreement_visible = visible
for widget in self._metric_chrome:
widget.setVisible(visible)
for key, block in self.blocks.items():
for field in ("score", "caption", "track"):
block[field].setVisible(visible)
if visible:
block["carrier"].setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
if not visible:
block["score"].clear()
block["score"].setToolTip("")
block["track"].set_values(None, None, "")
block["track"].setAccessibleDescription("")
block["carrier"].setValue(0)
block["carrier"].setToolTip("")
block["carrier"].setAccessibleName("")
if not visible:
self.delta.clear()
self.delta_note.clear()
self.overlap.clear()
def set_failure(self, key: str, message: str, retryable: bool) -> None:
block = self.blocks[key]
block["failure_text"].setText(message)
@@ -351,7 +380,7 @@ class AgreementBar(QFrame):
elapsed: Mapping[str, str] | None = None) -> None:
data = _mapping(batch)
models = _mapping(data.get("models"))
scores = {key: _score(_mapping(models.get(key))) for key in ("qwen", "openai")}
scores = {key: _score(_mapping(models.get(key))) if self._agreement_visible else None for key in ("qwen", "openai")}
for key in ("qwen", "openai"):
block = self.blocks[key]
value = scores[key]
@@ -364,6 +393,9 @@ class AgreementBar(QFrame):
spent = _mapping(elapsed).get(key, "")
block["elapsed"].setText(spent)
block["elapsed"].setVisible(bool(spent))
if not self._agreement_visible:
self.set_agreement_visible(False)
return
if scores["qwen"] is None or scores["openai"] is None:
self.delta.setText("—")
self.delta_note.setText("两个模型都可比后才给差值")
@@ -537,6 +537,9 @@ class DoseDiffPanel(QFrame):
item = self.body_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.hide()
for label in widget.findChildren(QLabel):
label.clear()
widget.setParent(None)
widget.deleteLater()
extent = _axis_extent(deltas)
@@ -620,6 +623,9 @@ class ConclusionsPanel(QFrame):
entry = self.body_layout.takeAt(0)
widget = entry.widget()
if widget is not None:
widget.hide()
for label in widget.findChildren(QLabel):
label.clear()
widget.setParent(None)
widget.deleteLater()
for index, text in enumerate(items, start=1):
@@ -844,6 +850,9 @@ class RiskPanel(QFrame):
entry = self.body_layout.takeAt(0)
widget = entry.widget()
if widget is not None:
widget.hide()
for label in widget.findChildren(QLabel):
label.clear()
widget.setParent(None)
widget.deleteLater()
flagged = []
@@ -1015,6 +1024,9 @@ class ChecklistPanel(QFrame):
entry = self.body_layout.takeAt(0)
widget = entry.widget()
if widget is not None:
widget.hide()
for label in widget.findChildren(QLabel):
label.clear()
widget.setParent(None)
widget.deleteLater()
grouped: dict[str, tuple[str, str, int, str]] = {}
@@ -1132,6 +1144,7 @@ class PrescriptionReviewWorkspace(QWidget):
self._batch: dict[str, Any] = {}
self.rows: list[ReviewDoseRow] = []
self._review_widget: QWidget | None = None
self._agreement_visible = True
self._summary = "选择报告后查看诊断与药方"
self.setStyleSheet(f"""
QWidget#PrescriptionReviewWorkspace {{ background: transparent; }}
@@ -1150,7 +1163,7 @@ class PrescriptionReviewWorkspace(QWidget):
page = QWidget(self.page_scroll)
self.page_scroll.setWidget(page)
outer.addWidget(self.page_scroll)
root = QVBoxLayout(page)
root = self._workspace_layout = QVBoxLayout(page)
root.setContentsMargins(0, 0, 6, 0)
root.setSpacing(14)
@@ -1201,6 +1214,10 @@ class PrescriptionReviewWorkspace(QWidget):
layout.takeAt(0)
for index in range(layout.columnCount()):
layout.setColumnStretch(index, 0)
if not self._agreement_visible:
self.first.addWidget(self.checklist, 0, 0)
self.first.setColumnStretch(0, 1)
return
cards = (self.conclusions, self.attribution, self.checklist)
if columns == 1:
for row, card in enumerate(cards):
@@ -1289,7 +1306,31 @@ class PrescriptionReviewWorkspace(QWidget):
self._batch = deepcopy(data)
self._render()
def set_agreement_visible(self, visible: bool) -> None:
"""Use the existing checklist as a report-only review workspace."""
if visible == self._agreement_visible:
return
self._agreement_visible = visible
for widget in (self.conclusions, self.attribution, self.doses, self.risks, self.links["comparison"]):
widget.setVisible(visible)
self._workspace_layout.setStretch(0, 5 if visible else 1)
self._workspace_layout.setStretch(1, 4 if visible else 0)
self._columns = 0
self._reflow(1 if self.width() < self.STACK_BELOW else 3)
self._render()
def _render(self) -> None:
if not self._agreement_visible:
self.rows = []
self.doses.set_rows([])
self.risks.set_rows([])
self.attribution.apply({})
self.conclusions.set_items([])
self.checklist.set_items(checklist_items(self._batch))
self._summary = "请结合报告与资料缺口记录复核意见。"
self.summary_changed.emit(self._summary)
return
self.rows, _states = review_rows(self._batch)
deltas = dose_deltas(self.rows)
self.doses.set_rows(deltas)
@@ -52,6 +52,7 @@ from PySide6.QtWidgets import (
from .. import icons, motion
from ..dialogs.issued_prescription_ai import (
PrescriptionAiStatisticsDialog,
agreement_safe_batch,
agreement_text,
batch_running,
can_open_issued_ai,
@@ -1007,6 +1008,10 @@ class PrescriptionsPage(QWidget):
self._mutation_pending = False
self._ai_statuses: dict[int, dict[str, Any]] = {}
self._ai_enabled = False
self._agreement_visible = False
self._capability_agreement_visible = False
self._ai_capability_token = 0
self._ai_capability_pending = False
self._ai_request_token = 0
self._ai_pending = False
self._ai_timer = QTimer(self)
@@ -1048,7 +1053,7 @@ class PrescriptionsPage(QWidget):
self.orders_button.clicked.connect(lambda: self._open_orders())
header.add_action(self.orders_button)
self.ai_statistics_button = QPushButton("AI 一致度统计", header)
self.ai_statistics_button.setVisible(has_permission(permissions, "tcm.prescriptionAi/statistics", default=False) and callable(getattr(repository, "prescription_ai_statistics", None)))
self.ai_statistics_button.hide()
self.ai_statistics_button.clicked.connect(self._open_ai_statistics)
header.add_action(self.ai_statistics_button)
self.add_button = QPushButton("新增处方", header)
@@ -1437,6 +1442,7 @@ class PrescriptionsPage(QWidget):
self._refresh_pending = False
self._generation += 1
generation = self._generation
self._load_ai_capabilities()
page_size = self._page_size
start_time, end_time = self._date_filters()
filters: dict[str, Any] = {
@@ -1656,6 +1662,7 @@ class PrescriptionsPage(QWidget):
return
method = getattr(self.repository, "list_prescription_ai_statuses", None)
if not has_permission(self.permissions, "tcm.prescriptionAi/statuses", default=False):
self._set_ai_columns(False)
self.ai_status_notice.setText("AI 分析:当前账号没有查看分析状态的权限,请联系管理员授权。")
return
if not callable(method):
@@ -1687,8 +1694,8 @@ class PrescriptionsPage(QWidget):
def _set_ai_columns(self, enabled: bool) -> None:
changed = enabled != self._ai_enabled
self._ai_enabled = enabled
for column in (11, 12):
self.table.setColumnHidden(column, not enabled)
self.table.setColumnHidden(11, not enabled)
self.table.setColumnHidden(12, not (enabled and self._agreement_visible))
if enabled:
header = self.table.horizontalHeader()
header.moveSection(header.visualIndex(11), header.visualIndex(4) + 1)
@@ -1699,10 +1706,54 @@ class PrescriptionsPage(QWidget):
if changed:
self._decorate_rows()
def _set_agreement_visible(self, visible: bool) -> None:
self._agreement_visible = visible
self.table.setColumnHidden(12, not (self._ai_enabled and visible))
self.ai_statistics_button.setVisible(
visible and has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False)
and callable(getattr(self.repository, "prescription_ai_statistics", None)))
if not visible:
self._ai_statuses = {key: agreement_safe_batch(row, False) for key, row in self._ai_statuses.items()}
for index in range(self.table.rowCount()):
item = self.table.item(index, 12)
if item is not None:
item.setText("")
item.setToolTip("")
item.setData(Qt.ItemDataRole.AccessibleTextRole, "")
def _load_ai_capabilities(self) -> None:
self._ai_capability_token += 1
token, generation = self._ai_capability_token, self._generation
self._ai_request_token += 1
self._ai_pending = False
self._capability_agreement_visible = False
self._set_agreement_visible(False)
method = getattr(self.repository, "get_prescription_ai_capabilities", None)
self._ai_capability_pending = callable(method)
if not callable(method):
return
run_async(method,
on_success=lambda result: self._ai_capabilities_ready(result, token, generation),
on_error=lambda _error: self._ai_capabilities_ready({}, token, generation))
def _ai_capabilities_ready(self, result: Any, token: int, generation: int) -> None:
if token != self._ai_capability_token or generation != self._generation:
return
self._ai_capability_pending = False
self._capability_agreement_visible = _row_mapping(result).get("agreement_visible") is True
self._set_agreement_visible(self._capability_agreement_visible)
if self._capability_agreement_visible:
# A previous deny discarded scores, including completed cached batches.
# Fetch them again after a fresh grant rather than displaying that cache.
self._ai_statuses.clear()
if self.isVisible() and not self._loading:
self._load_ai_statuses()
def _ai_statuses_ready(self, result: Any, ids: list[int], token: int, generation: int) -> None:
if token != self._ai_request_token or generation != self._generation or not self.isVisible():
return
data = _row_mapping(result)
self._set_agreement_visible(self._capability_agreement_visible and data.get("agreement_visible") is True)
self._set_ai_columns(data.get("enabled") is True)
if not self._ai_enabled:
self._ai_timer.stop()
@@ -1713,15 +1764,17 @@ class PrescriptionsPage(QWidget):
self.ai_status_notice.setToolTip("")
self.ai_report_button.setToolTip("自动分析未启用;仍可查看已保存的历史报告。")
return
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告,并显示药味与剂量一致度。")
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告。"
+ ("可查看药味与剂量一致度。" if self._agreement_visible else ""))
self.ai_status_notice.setToolTip("")
self.ai_report_button.setToolTip("查看两份 AI 报告、候选处方和逐味对照。")
self.ai_report_button.setToolTip("查看两份 AI 报告和候选处方。"
+ ("可查看逐味对照。" if self._agreement_visible else ""))
for value in ids:
self._ai_statuses[value] = {}
for batch in data.get("items") or []:
value = _int(get_value(batch, "prescription_id"), 0)
if value in ids:
self._ai_statuses[value] = _row_mapping(batch)
self._ai_statuses[value] = agreement_safe_batch(batch, self._agreement_visible and _row_mapping(batch).get("agreement_visible") is True)
self._render_ai_statuses()
if any(batch_running(self._ai_statuses.get(value)) for value in self._visible_ai_ids()):
self._ai_timer.start()
@@ -1741,13 +1794,13 @@ class PrescriptionsPage(QWidget):
if batch is None and not blank:
continue
state = "" if blank else (state_text(batch) if batch else "尚无分析记录")
agreement = "" if blank else agreement_text(batch)
agreement = "" if blank or not self._agreement_visible else agreement_text(batch)
for column, text in ((11, state), (12, agreement)):
item = self.table.item(index, column)
if item.text() != text:
item.setText(text)
changed = True
item.setToolTip("" if blank else status_tooltip(batch))
item.setToolTip("" if blank or column == 12 and not self._agreement_visible else status_tooltip(batch))
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
if changed:
self.table.resizeRowsToContents()
@@ -1757,6 +1810,7 @@ class PrescriptionsPage(QWidget):
def _ai_statuses_error(self, error: Exception, token: int, generation: int) -> None:
if token == self._ai_request_token and generation == self._generation:
self._ai_timer.stop()
self._set_agreement_visible(False)
self._set_ai_columns(False)
self.ai_status_notice.setText("AI 分析暂不可用:请稍后刷新;持续无法使用时,请联系管理员检查服务。")
self.ai_status_notice.setToolTip(friendly_error(error))
@@ -1781,13 +1835,13 @@ class PrescriptionsPage(QWidget):
if _is_blank_prescription(row):
return
menu = QMenu(self.table)
action = menu.addAction("AI 报告 / 逐味对照")
action = menu.addAction("AI 报告 / 逐味对照" if self._agreement_visible else "AI 报告")
action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report))
self._ai_context_menu = menu
menu.popup(self.table.viewport().mapToGlobal(position))
def _open_ai_statistics(self) -> None:
if not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
if not self._agreement_visible or not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
return
dialog = PrescriptionAiStatisticsDialog(self.repository, self.permissions, self)
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
@@ -2207,12 +2261,14 @@ class PrescriptionsPage(QWidget):
if self.table.rowCount() == 0 and not self._loading:
self.refresh()
elif not self._loading:
self._load_ai_statuses()
self._load_ai_capabilities()
def hideEvent(self, event: Any) -> None:
self._ai_timer.stop()
self._ai_scroll_timer.stop()
self._ai_request_token += 1
self._ai_capability_token += 1
self._ai_capability_pending = False
self._ai_pending = False
super().hideEvent(event)