This commit is contained in:
Your Name
2026-10-08 18:03:11 +08:00
parent 4dac39c4c2
commit 19ea2dadeb
26 changed files with 1131 additions and 28 deletions
@@ -54,6 +54,7 @@ from ..diagnosis_terms import (
TermIndex,
shared_terms,
)
from ..report_provenance import report_provenance_lines
from ..theme import apply_reading_rhythm, mark_business_dialog
from ..widgets import (
display_text,
@@ -495,6 +496,22 @@ QDialog#AiConsultDialog QLabel#AiConsultClinicalDisclaimer {
font-size: 13px;
font-weight: 400;
}
QDialog#AiConsultDialog QFrame#AiConsultReportProvenance {
background-color: #F7F8FC;
border: 1px solid #E4E8F2;
border-radius: 9px;
}
QDialog#AiConsultDialog QLabel#AiConsultReportProvenanceTitle {
color: #27336B;
background-color: transparent;
font-size: 12px;
font-weight: 600;
}
QDialog#AiConsultDialog QLabel#AiConsultReportProvenanceLine {
color: #555B68;
background-color: transparent;
font-size: 12px;
}
QDialog#AiConsultDialog QFrame#AiConsultEventCard {
background-color: #F7F7F7;
border: 1px solid #EDEDEE;
@@ -2128,6 +2145,26 @@ def parse_structured_report(raw: Any) -> tuple[str, tuple[_ReportSection, ...],
)
def _report_provenance_widget(raw: Any, parent: QWidget) -> QFrame:
card = QFrame(parent)
card.setObjectName("AiConsultReportProvenance")
_style_surface(card)
layout = QVBoxLayout(card)
layout.setContentsMargins(12, 10, 12, 11)
layout.setSpacing(4)
title = QLabel("报告出处", card)
title.setObjectName("AiConsultReportProvenanceTitle")
layout.addWidget(title)
for line in report_provenance_lines(raw):
label = QLabel(line, card)
label.setObjectName("AiConsultReportProvenanceLine")
label.setWordWrap(True)
label.setTextFormat(Qt.TextFormat.PlainText)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
layout.addWidget(label)
return card
_InsightItem = namedtuple("_InsightItem", "kind marker label text")
_INSIGHT_LIST_RE = re.compile(r"^\s*(\d{1,2})\s*([\.、))]\s*)")
@@ -2433,6 +2470,7 @@ class _StructuredReportPanel(QWidget):
disclaimer: str,
*,
time_text: str = "",
provenance: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
@@ -2479,6 +2517,7 @@ class _StructuredReportPanel(QWidget):
note = _FittedText(_reading_html(disclaimer), parent=self)
note.setObjectName("AiConsultReportDisclaimer")
root.addWidget(note)
root.addWidget(_report_provenance_widget(provenance, self))
# 富余高度归到底部,否则会被平摊进每张卡片,把报告拉得空空荡荡。
root.addStretch(1)
@@ -2552,6 +2591,7 @@ class _ClinicalAnalysisPanel(QWidget):
model: _ClinicalAnalysisModel,
*,
time_text: str = "",
provenance: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
@@ -2645,6 +2685,7 @@ class _ClinicalAnalysisPanel(QWidget):
disclaimer.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
_style_surface(disclaimer)
root.addWidget(disclaimer)
root.addWidget(_report_provenance_widget(provenance, self))
QTimer.singleShot(0, self._apply_responsive_layout)
def set_time_text(self, time_text: str) -> None:
@@ -2868,6 +2909,7 @@ class _ChatBubble(QWidget):
super().__init__(parent)
self._role = role
self._raw_payload = text
self._provenance: Mapping[str, Any] = {}
self._clinical_panel: _ClinicalAnalysisPanel | None = None
row = QHBoxLayout(self)
row.setContentsMargins(12, 7, 12, 7)
@@ -2938,6 +2980,9 @@ class _ChatBubble(QWidget):
if self.body is not None:
self.body.set_payload(text)
def set_provenance(self, value: Any) -> None:
self._provenance = dict(value) if isinstance(value, Mapping) else {}
def set_time_text(self, text: str) -> None:
if not text:
return
@@ -2958,6 +3003,7 @@ class _ChatBubble(QWidget):
panel = _ClinicalAnalysisPanel(
model,
time_text=self.stamp.text(),
provenance=self._provenance,
parent=self._bubble_frame,
)
else:
@@ -2968,6 +3014,7 @@ class _ChatBubble(QWidget):
panel = _StructuredReportPanel(
*report,
time_text=self.stamp.text(),
provenance=self._provenance,
parent=self._bubble_frame,
)
if panel is None:
@@ -4643,6 +4690,7 @@ class AiConsultDialog(QDialog):
time_text=item.time_text,
)
if item.role == "ai":
bubble.set_provenance(row)
bubble.finalize_clinical_analysis()
def _render_analysis(self, analysis: Mapping[str, Any]) -> None:
@@ -4669,6 +4717,7 @@ class AiConsultDialog(QDialog):
markdown = _analysis_markdown(payload)
if markdown:
bubble = self._append_bubble("ai", markdown)
bubble.set_provenance(payload)
bubble.finalize_clinical_analysis()
self._set_insights(
[
@@ -6025,6 +6074,7 @@ class AiConsultDialog(QDialog):
self._stream_bubble.set_time_text(
_join_meta(model, datetime.now().strftime("%H:%M"))
)
self._stream_bubble.set_provenance(self._stream_meta)
self._stream_bubble.finalize_clinical_analysis()
def _flush_stream_chunks(self) -> None:
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
from ...core.errors import AuthenticationExpiredError
from ..reception_style import body_family
from ..report_provenance import report_provenance_lines
from ..widgets import first_value, format_record_time, friendly_error, has_permission, run_async
from .issued_prescription_ai_console import AgreementBar, StepRail, rail_qss
from .issued_prescription_ai_glyphs import Glyph, LogoTile, glyph_icon
@@ -1414,7 +1415,14 @@ class IssuedPrescriptionAiDialog(QDialog):
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)
_set_html(views["sources"], _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"))}))
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)
if line.startswith(("知识参考:", "参考章节:", "原文:", "古籍参考:"))]
if reference_lines:
source_html += '<p class="sec">文献出处</p>' + "".join(
"<p>" + escape(line) + "</p>" for line in reference_lines
)
_set_html(views["sources"], source_html)
if first_load and not self._pending_drafts:
review = mapping(model.get("review"))
views["comment"].setPlainText(str(review.get("comment") or ""))
@@ -26,6 +26,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from ..report_provenance import report_provenance_lines
from ..theme import apply_reading_rhythm
from ..widgets import (
EmptyState,
@@ -1413,8 +1414,22 @@ class PrescriptionAiReportDialog(QDialog):
or "仅供专业人员辅助审方,不替代辨证、诊断和处方审核。"
)
self._add_label(disclaimer, "PrescriptionAiMuted")
self._add_report_provenance(data)
return
self._section("报告正文", str(data.get("content") or "暂无报告正文"))
self._add_report_provenance(data)
def _add_report_provenance(self, data: dict[str, Any]) -> None:
if self.kind is not DIAGNOSIS_AI_KIND:
return
lines = [f"关联诊单:#{self._entity_id()}", *report_provenance_lines(data)]
label = QLabel("报告出处\n" + "\n".join(lines))
label.setObjectName("DiagnosisAiReportProvenance")
label.setTextFormat(Qt.TextFormat.PlainText)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
label.setWordWrap(True)
label.setStyleSheet("color: #555B68; font-size: 12px; padding: 10px 14px; background: #F7F8FC; border: 1px solid #E4E8F2; border-radius: 8px;")
self.host_layout.addWidget(label)
def reject(self) -> None:
self.dialog_generation += 1
@@ -1464,6 +1479,13 @@ class DiagnosisAiAssistantDialog(QDialog):
self.answer_label = _AiAnswerBrowser()
self.answer_scroll = self.answer_label
root.addWidget(self.answer_scroll, 1)
self.source_label = QLabel("")
self.source_label.setObjectName("DiagnosisAiAssistantProvenance")
self.source_label.setTextFormat(Qt.TextFormat.PlainText)
self.source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.source_label.setWordWrap(True)
self.source_label.setVisible(False)
root.addWidget(self.source_label)
actions = QHBoxLayout()
actions.addStretch(1)
self.retry_button = QPushButton("重试")
@@ -1481,6 +1503,8 @@ class DiagnosisAiAssistantDialog(QDialog):
self.question_label.setText(f"问题:{self.prompt}")
self.answer_label.clear()
self.model_label.clear()
self.source_label.clear()
self.source_label.hide()
self._ask()
def _ask(self) -> None:
@@ -1519,6 +1543,8 @@ class DiagnosisAiAssistantDialog(QDialog):
)
self.model_label.setText(f"本次模型:{model}")
self.answer_label.set_answer(answer)
self.source_label.setText("分析出处:" + ";".join(report_provenance_lines(payload)))
self.source_label.show()
self.status_banner.show_message("分析完成", "success")
self.loading = False
self.retry_button.setEnabled(True)
@@ -1527,6 +1553,7 @@ class DiagnosisAiAssistantDialog(QDialog):
if generation != self.request_generation:
return
self.status_banner.show_message(error_message(error), "danger")
self.source_label.hide()
self.loading = False
self.retry_button.setEnabled(True)
@@ -89,6 +89,7 @@ from ..dialogs.prescription_ai import (
)
from ..filter_disclosure import FilterDisclosure
from ..reception_style import body_family, heading_family, number_family
from ..report_provenance import report_provenance_lines
from ..theme import mark_business_dialog
from ..widgets import (
EmptyState,
@@ -3335,6 +3336,14 @@ class _ReceptionAiAnalysisDialog(QDialog):
"",
"ReceptionAiAnalysisDialogTreatmentText",
)
source_label = QLabel("", body)
source_label.setObjectName("ReceptionAiAnalysisDialogSources")
source_label.setTextFormat(Qt.TextFormat.PlainText)
source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
source_label.setWordWrap(True)
source_label.setStyleSheet("color: #555B68; font-size: 12px; padding: 10px 14px; background: #F7F8FC; border: 1px solid #E4E8F2; border-radius: 8px;")
body_layout.addWidget(source_label)
self.source_label = source_label
body_layout.addStretch(1)
scroll.setWidget(body)
surface_layout.addWidget(scroll, 1)
@@ -3525,6 +3534,7 @@ class _ReceptionAiAnalysisDialog(QDialog):
" | ".join(part for part in (model, f"第 {version} 版", generated_at) if part)
)
self.meta_label.setVisible(bool(self.meta_label.text()))
self.source_label.setText("报告出处\n" + "\n".join(report_provenance_lines(payload)))
self.scroll_area.verticalScrollBar().setValue(0)
@@ -5198,6 +5208,13 @@ class ReceptionPage(QWidget):
state_layout.addStretch(1)
self.ai_analysis_stack.addWidget(analysis_state)
analysis_layout.addWidget(self.ai_analysis_stack)
self.ai_source_label = QLabel("", analysis_card)
self.ai_source_label.setObjectName("ReceptionAiSourceSummary")
self.ai_source_label.setTextFormat(Qt.TextFormat.PlainText)
self.ai_source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.ai_source_label.setWordWrap(True)
self.ai_source_label.setStyleSheet("color: #5B6474; font-size: 11px;")
analysis_layout.addWidget(self.ai_source_label)
self.ai_detail_actions_host = QWidget(analysis_card)
report_footer = QHBoxLayout(self.ai_detail_actions_host)
@@ -5432,6 +5449,7 @@ class ReceptionPage(QWidget):
self.ai_analysis_stack.setCurrentWidget(
self.ai_analysis_content_page if success else self.ai_analysis_state_page
)
self.ai_source_label.setVisible(success)
if success and payload is not None:
if (
self._ai_analysis_rendered_model != model
@@ -5559,6 +5577,9 @@ class ReceptionPage(QWidget):
)
self.ai_analysis_snapshot_meta.setVisible(bool(meta_text))
self.ai_analysis_card.setToolTip("")
source_lines = report_provenance_lines(payload)
self.ai_source_label.setText("报告出处:" + ";".join(source_lines[:2]))
self.ai_source_label.setToolTip("\n".join(source_lines))
def _render_ai_risk_chips(self, risks: Sequence[Mapping[str, Any]]) -> None:
_clear_ai_layout(self.ai_risk_chip_layout)
@@ -0,0 +1,109 @@
"""Human-readable, server-recorded source scope for clinical AI reports."""
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
_REPORT_SOURCE_COUNTS = (
("diagnosis_count", "诊单"),
("doctor_note_count", "医生备注"),
("tracking_note_count", "随访记录"),
("blood_record_count", "血糖血压记录"),
("diet_record_count", "饮食记录"),
("exercise_record_count", "运动记录"),
("prescription_count", "历史处方"),
("im_message_count", "IM 消息"),
("wechat_message_count", "企微消息"),
("call_record_count", "视频问诊"),
("transcript_segment_count", "视频转写片段"),
)
_NIHAIXIA_SOURCE_URL = "https://github.com/coinweth/nihaixia-skill"
_NIHAIXIA_COMMIT = "270e94e637c4249dc411e54ac6715f21247de3cb"
_ANCIENT_BOOKS_COMMIT = "db0155dc7c42b9c6b3736896661f317c7110038f"
_ANCIENT_BOOKS_URL = "https://github.com/xiaopangxia/TCM-Ancient-Books"
def report_provenance_lines(raw: Any) -> tuple[str, ...]:
"""Describe recorded inputs; never turn model prose into a claimed citation."""
report = raw if isinstance(raw, Mapping) else {}
lines: list[str] = []
ids = report.get("source_diagnosis_ids")
diagnosis_ids = [
value for value in ids if type(value) is int and value > 0
] if isinstance(ids, (list, tuple)) else []
if diagnosis_ids:
shown = "、".join(f"#{value}" for value in diagnosis_ids[:12])
lines.append("来源诊单:" + shown + (" 等" if len(diagnosis_ids) > 12 else ""))
summary = report.get("source_summary")
counts = []
if isinstance(summary, Mapping):
for key, label in _REPORT_SOURCE_COUNTS:
value = summary.get(key)
if type(value) is int and value > 0:
counts.append(f"{label} {value} 条")
if counts:
lines.append("患者资料范围:" + "、".join(counts))
elif isinstance(summary, Mapping) and type(summary.get("source_record_count")) is int and summary["source_record_count"] > 0:
lines.append(f"患者资料范围:记录 {summary['source_record_count']} 条,分类未记录")
elif not diagnosis_ids:
lines.append("患者资料范围:此报告未附可核查的来源清单")
knowledge = report.get("knowledge_source")
url = knowledge.get("url") if isinstance(knowledge, Mapping) else None
source_match = re.fullmatch(
re.escape(_NIHAIXIA_SOURCE_URL) + r"/blob/([0-9a-f]{40})/SKILL\.md",
url,
) if isinstance(url, str) else None
if isinstance(knowledge, Mapping) and source_match is not None and source_match.group(1) == _NIHAIXIA_COMMIT:
commit = knowledge.get("commit")
commit_text = commit[:12] if isinstance(commit, str) and commit == _NIHAIXIA_COMMIT else ""
lines.append(
"知识参考:倪海厦 skill · SKILL.md"
+ (f"(commit {commit_text})" if commit_text else "")
)
sections = knowledge.get("sections")
allowed_sections = {"六经速查表", "六经辨证诊断公式", "七步走辨证思维模式"}
if isinstance(sections, (list, tuple)) and sections and all(
isinstance(item, str) and item in allowed_sections for item in sections
):
lines.append("参考章节:" + "、".join(sections))
lines.append("原文:" + url)
else:
lines.append("知识参考:此报告未记录所用知识资料版本")
ancient_sources = report.get("ancient_book_sources")
shown_urls: set[str] = set()
if isinstance(ancient_sources, (list, tuple)):
for source in ancient_sources[:12]:
if not isinstance(source, Mapping):
continue
ancient_url = source.get("url")
if not isinstance(ancient_url, str) or ancient_url in shown_urls:
continue
if re.fullmatch(
re.escape(_ANCIENT_BOOKS_URL)
+ r"/blob/"
+ _ANCIENT_BOOKS_COMMIT
+ r"/[^/#?]+\.txt#L[1-9]\d*",
ancient_url,
) is None or source.get("commit") != _ANCIENT_BOOKS_COMMIT:
continue
shown_urls.add(ancient_url)
title = str(source.get("title") or "古籍原文")[:60]
chapter = str(source.get("chapter") or "")[:60]
lines.append(
"古籍参考:《" + title + "》"
+ ("「" + chapter + "」" if chapter else "")
+ ";原文:" + ancient_url
)
if not shown_urls and "-ancient-" in str(
report.get("prompt_version") or report.get("context_version") or ""
):
lines.append("古籍参考:本次未记录可核查的古籍片段")
if report.get("is_edited") is True:
lines.append("人工编辑:报告生成后已修改,来源范围仅对应原始生成版本。")
lines.append("以上列出本次输入范围与知识参考,不代表每条结论均有逐项引用。")
return tuple(lines)