This commit is contained in:
Your Name
2026-09-10 15:19:17 +08:00
parent 27fbef9321
commit 36975c6c1b
487 changed files with 15696 additions and 78 deletions
@@ -233,8 +233,29 @@ class DoctorRepository(Protocol):
) -> dict[str, Any]:
"""Append one model-specific AI diagnosis snapshot for one patient."""
def get_prescription(self, prescription_id: int) -> Prescription:
"""Return one issued prescription."""
def get_prescription(self, prescription_id: int) -> Prescription:
"""Return one issued prescription."""
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
"""Read cached dual-model states for up to 100 prescriptions."""
def list_prescription_ai_reports(self, *, prescription_id: int = 0, diagnosis_id: int = 0, page_no: int = 1, page_size: int = 20) -> dict[str, Any]:
"""Read immutable analysis batch history in the authorized scope."""
def get_prescription_ai_report(self, batch_id: int) -> dict[str, Any]:
"""Read one analysis batch without starting model work."""
def regenerate_prescription_ai(self, prescription_id: int, reason: str) -> dict[str, Any]:
"""Explicitly request a new analysis batch with a reason."""
def retry_prescription_ai(self, batch_id: int, model_key: str) -> dict[str, Any]:
"""Retry only the requested failed model."""
def review_prescription_ai(self, batch_id: int, model_key: str, status: str, comment: str) -> dict[str, Any]:
"""Save a doctor's review separately from immutable AI output."""
def prescription_ai_statistics(self, date_from: str, date_to: str, doctor_id: int | None = None) -> dict[str, Any]:
"""Read baseline agreement statistics, never diagnostic accuracy."""
def create_prescription(
self,
@@ -1574,14 +1595,55 @@ class RemoteDoctorRepository:
payload, Prescription.from_dict, page_no=page_no, page_size=page_size
)
def get_prescription(self, prescription_id: int) -> Prescription:
"""Load one issued prescription using ``tcm.prescription/detail``."""
def get_prescription(self, prescription_id: int) -> Prescription:
"""Load one issued prescription using ``tcm.prescription/detail``."""
result = _require_mapping(
self.client.get("tcm.prescription/detail", {"id": prescription_id}),
"tcm.prescription/detail",
)
return Prescription.from_dict(result)
return Prescription.from_dict(result)
def _prescription_ai_request(self, action: str, params: dict[str, Any], *, mutation: bool = False) -> dict[str, Any]:
endpoint = f"tcm.prescriptionAi/{action}"
payload = _client_request(self.client, "post" if mutation else "get", endpoint, params, timeout=30.0)
return dict(_require_mapping(payload, endpoint))
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")
return self._prescription_ai_request("statuses", {"ids": ",".join(str(int(value)) for value in dict.fromkeys(ids))})
def list_prescription_ai_reports(self, *, prescription_id: int = 0, diagnosis_id: int = 0, page_no: int = 1, page_size: int = 20) -> dict[str, Any]:
if bool(prescription_id) == bool(diagnosis_id):
raise ValueError("Exactly one prescription_id or diagnosis_id is required")
params = {"prescription_id": prescription_id} if prescription_id else {"diagnosis_id": diagnosis_id}
params.update(page_no=page_no, page_size=page_size)
return self._prescription_ai_request("reports", params)
def get_prescription_ai_report(self, batch_id: int) -> dict[str, Any]:
return self._prescription_ai_request("detail", {"batch_id": batch_id})
def regenerate_prescription_ai(self, prescription_id: int, reason: str) -> dict[str, Any]:
if not reason.strip():
raise ValueError("重新分析需要填写原因")
return self._prescription_ai_request("regenerate", {"prescription_id": prescription_id, "reason": reason.strip()}, mutation=True)
def retry_prescription_ai(self, batch_id: int, model_key: str) -> dict[str, Any]:
if model_key not in {"qwen", "openai"}:
raise ValueError("Unknown model_key")
return self._prescription_ai_request("retry", {"batch_id": batch_id, "model_key": model_key}, mutation=True)
def review_prescription_ai(self, batch_id: int, model_key: str, status: str, comment: str) -> dict[str, Any]:
if model_key not in {"qwen", "openai"} or status not in {"viewed", "needs_information", "not_adopted", "reviewed"}:
raise ValueError("Invalid review state")
return self._prescription_ai_request("review", {"batch_id": batch_id, "model_key": model_key, "status": status, "comment": comment}, mutation=True)
def prescription_ai_statistics(self, date_from: str, date_to: str, doctor_id: int | None = None) -> dict[str, Any]:
params: dict[str, Any] = {"date_from": date_from, "date_to": date_to}
if doctor_id is not None:
params["doctor_id"] = doctor_id
return self._prescription_ai_request("statistics", params)
def create_prescription(
self,
@@ -5729,6 +5729,7 @@ class AiConsultDialog(QDialog):
"usage_notes",
}
seed.update({key: deepcopy(value) for key, value in draft.items() if key in allowed})
seed["ai_assisted"] = True
dialog = PrescriptionEditorDialog(
self.repository,
seed,
@@ -78,6 +78,7 @@ from ..widgets import (
page_total,
run_async,
)
from .issued_prescription_ai import can_open_issued_ai, present_issued_prescription_ai
from .local_audio_queue import LocalAudioQueueDialog
from .prescription_ai import can_open_diagnosis_ai_report, present_diagnosis_ai_report
@@ -1067,6 +1068,12 @@ class DiagnosisDialog(QDialog):
self.readonly_ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.readonly_ai_button.clicked.connect(self._open_ai_report)
heading_row.addWidget(self.readonly_ai_button, 0)
self.readonly_prescription_ai_button = QPushButton("处方 AI 对照", card)
self.readonly_prescription_ai_button.setProperty("variant", "secondary")
self.readonly_prescription_ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.readonly_prescription_ai_button.setVisible(can_open_issued_ai(self.permissions) and callable(getattr(self.repository, "list_prescription_ai_reports", None)))
self.readonly_prescription_ai_button.clicked.connect(self._open_prescription_ai_report)
heading_row.addWidget(self.readonly_prescription_ai_button, 0)
layout.addLayout(heading_row)
patient_hero = QFrame()
patient_hero.setObjectName("DiagnosisReadonlyPatientHero")
@@ -1709,6 +1716,9 @@ class DiagnosisDialog(QDialog):
if hasattr(self, "readonly_ai_button"):
self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
self.readonly_ai_button.setEnabled(self._diagnosis_id > 0)
if hasattr(self, "readonly_prescription_ai_button"):
self.readonly_prescription_ai_button.setVisible(can_open_issued_ai(self.permissions) and callable(getattr(self.repository, "list_prescription_ai_reports", None)))
self.readonly_prescription_ai_button.setEnabled(self._diagnosis_id > 0)
previous_key = self._current_tab_key()
allowed_tabs = [
(key, label) for key, label, codes in _TAB_DEFINITIONS if self._tab_allowed(codes)
@@ -1960,6 +1970,10 @@ class DiagnosisDialog(QDialog):
)
present_diagnosis_ai_report(self.repository, self.permissions, self, row)
def _open_prescription_ai_report(self) -> None:
if self._diagnosis_id > 0:
present_issued_prescription_ai(self.repository, self.permissions, self, diagnosis_id=self._diagnosis_id)
def open_view_only(
self,
diagnosis_id: int,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,285 @@
"""Presentation-only Chinese labels for saved prescription analysis payloads.
Keep source identifiers, enum keys and statistics untouched in the repository data.
Only explicitly structured metadata is localized; clinical prose is not translated.
"""
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
SOURCE_LABELS = {
"diagnoses": "诊单", "doctor_notes": "医生笔记", "tracking_notes": "随访记录",
"prescriptions": "历史处方", "call_records": "问诊通话", "video_calls": "视频问诊",
"transcript_segments": "转写片段", "chat_records": "聊天记录", "daily_records": "日常记录",
"blood_records": "血糖血压记录", "blood_glucose_pressure": "血糖血压记录",
"diet_records": "饮食记录", "diet": "饮食记录", "exercise_records": "运动记录", "exercise": "运动记录",
"im_messages": "即时聊天记录", "tencent_im": "即时聊天记录",
"wechat_messages": "企业微信聊天记录", "wechat_work": "企业微信聊天记录",
"target_plan": "本次处方方案", "clinical": "临床资料", "file": "附件",
"tongue": "舌象", "tongue_image": "舌象图片", "tongue_images": "舌象图片",
"clinical_attachment": "临床附件", "patient": "患者资料",
}
SYSTEM_LABELS = {
"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": "来源历史版本无法核验",
"ARCHIVE_SYNC_WATERMARK_UNAVAILABLE": "归档同步完整性尚未核验",
"TRANSCRIPT_NOT_VERIFIED_COMPLETE": "问诊转写完整性尚未核验",
"TRANSCRIPT_NOT_FINAL": "问诊转写尚未完整归档",
"TRANSCRIPT_PARTIAL": "问诊转写仅部分完成", "TRANSCRIPT_FAILED": "问诊转写失败",
"TRANSCRIPT_RUNNING": "问诊转写进行中", "TRANSCRIPT_PENDING": "等待问诊转写",
"SOURCE_AUTHORIZATION_LINK_UNAVAILABLE": "来源缺少可核验的授权关联",
"SOURCE_PATIENT_CONFLICT": "来源的患者关联存在冲突",
"SOURCE_ACCESS_RESTRICTED": "来源访问受限",
"UNLINKED_SOURCE_REQUIRES_AUTHORIZATION": "未关联来源需核验访问权限",
"SOURCE_READ_UNAVAILABLE": "来源暂时无法读取",
"ATTACHMENT_TARGET_PLAN_LEAKAGE_UNVERIFIED": "附件可能包含本次处方,独立性未核验",
"UNSTRUCTURED_TARGET_PLAN_LEAKAGE_UNVERIFIED": "非结构化资料可能包含本次处方,独立性未核验",
"TARGET_PLAN_COPY_ISOLATED": "已隔离资料中复制的本次处方内容",
"CRITICAL_CLINICAL_FACT_MISSING": "关键临床资料缺失",
"FILE_STORAGE_AUTHORIZATION_UNVERIFIED": "附件存储访问权限尚未核验",
"FILE_CONTENT_VERSION_UNVERIFIED": "附件内容版本尚未核验",
"PATIENT_BINDING_REQUIRED": "需完善患者与诊单关联",
"PATIENT_BINDING_OR_PERMISSION_REQUIRED": "需核验患者关联与资料访问权限",
"ACCESS_REVOKED": "资料访问权限已变更", "SOURCE_CHANGED": "资料或处方已更新,请查看新版本",
"BUDGET_PAUSED": "已达到分析预算,等待额度恢复",
"CONFIG_INVALID": "模型配置无效,请联系管理员", "CONFIG_DISABLED": "模型分析尚未启用",
"UPSTREAM_AUTH_FAILED": "模型服务认证失败,请联系管理员",
"UPSTREAM_TIMEOUT": "模型响应超时,可稍后重试", "UPSTREAM_BUSY": "模型服务繁忙,可稍后重试",
"UPSTREAM_UNAVAILABLE": "模型服务暂不可用", "UPSTREAM_REJECTED": "模型服务未接受本次请求",
"UPSTREAM_FAILED": "模型服务处理失败", "EMPTY_RESPONSE": "模型未返回内容",
"INCOMPLETE_RESPONSE": "模型返回内容不完整", "INVALID_RESPONSE": "模型返回内容未通过校验",
"RESPONSE_INVALID": "模型返回内容未通过校验", "INVALID_REPORT_OUTPUT": "模型报告未通过格式校验",
"INVALID_EVIDENCE_OUTPUT": "模型证据来源未通过校验",
"INVALID_FILE_EVIDENCE_OUTPUT": "模型附件证据未通过校验",
"CONTEXT_TOO_LARGE": "资料超过本次处理预算",
"INPUT_TOKEN_BUDGET_EXCEEDED": "输入资料超过本次处理预算",
"SYNTHESIS_BUDGET_EXCEEDED": "资料汇总超过本次处理预算",
"FINAL_CONTEXT_EXCEEDS_BUDGET": "来源与缺口说明超过汇总预算,请联系管理员",
"TOTAL_CALL_BUDGET_EXCEEDED": "模型调用次数达到本次上限",
"SOURCE_UNIT_EXCEEDS_BUDGET": "单条来源资料超过处理预算",
"RESPONSE_SIZE_EXCEEDED": "模型返回内容超过长度上限",
"LEASE_EXPIRED": "工作进程中断,任务等待恢复",
"SOURCE_PREPARATION_FAILED": "来源资料准备失败", "INTERNAL_ERROR": "分析处理异常,可稍后重试",
"GENERATION_FAILED": "报告生成失败,可稍后重试", "CHECKPOINT_REJECTED": "分析进度保存未通过校验",
"INVALID_PROFILE": "模型配置未通过校验", "INVALID_FROZEN_CONTEXT": "冻结资料未通过校验",
"INVALID_FILE_MANIFEST": "附件清单未通过校验", "SOURCE_GAP": "来源资料存在缺口",
"FILE_CAPABILITY_DISABLED": "模型附件处理能力尚未启用",
"FILE_UNAVAILABLE_OR_UNSUPPORTED": "附件不可用或格式不受支持",
"FILE_TYPE_UNSUPPORTED": "附件格式不受支持",
"STRICT_FILES_INVALID_OR_LIMIT": "附件校验未通过或超过处理上限",
"FILE_DELIVERY_UNVERIFIED": "附件送达情况尚未核验",
"MODEL_REPORTED_UNREADABLE": "模型无法读取附件", "MODEL_REPORTED_UNSUPPORTED": "模型不支持此附件",
"MODEL_FILE_OUTPUT_INVALID": "模型未能正确解析这组附件",
"AI_ANALYSIS_CIPHER_INVALID": "报告加密数据无法读取",
"AI_ANALYSIS_ENCRYPTION_FAILED": "报告加密保存失败",
"AI_ANALYSIS_KEY_INVALID": "报告加密配置无效", "AI_ANALYSIS_KEY_UNAVAILABLE": "报告加密配置不可用",
"formulation_mismatch": "剂型不同,未提供经确认的换算规则",
"dose_basis_mismatch": "剂量基准不同", "unit_mismatch": "剂量单位不同",
"unknown_formulation": "剂型缺失或不受支持", "empty_prescription": "处方为空或药味结构无效",
"catalog_unavailable": "缺少可用的药材字典", "invalid_herb": "药味结构无效",
"ambiguous_herb_name": "药名存在多种字典匹配", "unknown_herb_name": "药名未匹配药材字典",
"doctor_identity_mismatch": "医生药材编号与规范药名不一致",
"processing_conflict": "炮制信息与药材字典冲突或无效",
"ambiguous_herb_role": "主辅方、给药途径或分组不明确",
"missing_or_unknown_unit": "剂量单位缺失或不受支持",
"missing_or_unknown_dose_basis": "每剂或每日剂量基准不明确",
"invalid_dosage": "剂量数值无效", "invalid_herb_usage": "药味煎服说明格式无效",
"duplicate_semantics_conflict": "重复药项的单位、剂量基准或煎服说明不一致",
"no_medication": "建议暂不使用药物", "no_medication_recommended": "建议暂不使用药物",
"baseline_ineligible": "不符合独立基线统计条件", "incomplete_coverage": "资料覆盖不完整",
"missing_result": "缺少模型结果", "invalid_score": "一致度分值无效",
"missing_algorithm_version": "缺少算法版本", "transcript_not_final": "问诊转写尚未完整归档",
"event_patient_conflict": "开方事件的患者关联冲突", "duplicate_baseline_conflict": "重复基线结果存在冲突",
"review_conflict": "复核记录存在冲突", "review_not_completed": "复核尚未完成",
"review_not_independent": "复核不具独立性", "review_disputed": "复核存在争议",
"review_not_evaluable": "复核不可评价", "invalid_review_outcome": "复核结论无效",
"unknown_review_sampling": "复核抽样方式未确认", "start_at_required": "需设置分析起始时间",
"processed": "已处理", "restricted": "访问受限", "error": "处理失败", "timeout": "处理超时",
"delivered": "已送达", "unreadable": "不可读", "unsupported": "不支持", "parsed": "已解析",
"ok": "可比条件已通过", "doctor": "医生方", "candidate": "候选方", "both": "双方",
"low": "低风险", "medium": "中风险", "high": "高风险", "none": "",
"main": "主方", "oral": "口服", "external": "外用",
"raw": "生品", "image": "图片", "document": "文档", "remote_url": "远程附件",
"stratified_versions": "按版本分层统计", "single_version": "单一版本", "no_valid_samples": "无有效样本",
"no_samples": "未建立复核样本", "recorded": "已记录", "conflict": "存在冲突",
"not_evaluable": "不可评价", "qualified": "合格", "needs_revision": "需修订", "unqualified": "不合格",
"random": "随机抽样", "stratified": "分层抽样", "risk_directed": "按风险抽样",
"stratified_sampling": "按抽样方式分层统计",
"spelling_aliases_only_no_quantity_conversion": "仅规范单位写法,不进行剂量换算",
"g": "", "mg": "毫克", "kg": "千克", "ml": "毫升", "mL": "毫升", "l": "", "L": "",
"qwen": "千问", "openai": "OpenAI",
}
EXTRA_FIELD_LABELS = {
"source": "来源", "code": "原因说明", "reason_code": "原因说明", "error_code": "错误原因",
"error_message": "错误说明", "message": "说明", "detail": "详情", "details": "详细记录",
"reason_message": "原因说明", "field": "字段", "value": "记录值", "side": "所属处方",
"row": "药项序号", "rows": "逐味记录", "key": "药项标识", "id": "编号", "medicine_id": "药材编号",
"prescription_id": "处方编号", "diagnosis_id": "诊单编号", "patient_id": "患者编号",
"doctor_id": "医生编号", "doctor_name": "医生姓名", "model_key": "模型", "model_name": "模型名称",
"configured_model_name": "配置的模型名称", "comparison_status": "可比状态", "coverage_status": "资料覆盖状态",
"comparison_type": "比较类型", "validity": "报告有效性", "version_verified": "内容版本已核验",
"source_record_count": "来源记录总数", "missing_count": "资料缺口数", "snapshot_complete": "资料快照完整",
"may_be_truncated": "资料可能截断", "history_versioning": "历史版本核验", "archive_sync_verified": "归档同步已核验",
"file_ids": "附件编号", "evidence_file_ids": "证据附件编号", "covered_source_ids": "已覆盖来源编号",
"source_kind": "来源类型", "kind": "来源类型", "type": "类型", "purpose": "用途",
"transfer_method": "附件传递方式", "content_hash": "内容指纹", "dictionary_hash": "药材字典指纹",
"source_hash": "来源指纹", "url": "附件地址", "uri": "附件地址", "path": "附件路径",
"age": "年龄", "gender": "性别", "gender_label": "性别说明", "allergy_history": "过敏史",
"pregnancy_history": "妊娠与哺乳情况", "current_medications": "当前用药",
"allergy_history_text": "过敏史正文", "allergy_history_desc": "过敏史说明",
"pregnancy_history_text": "妊娠与哺乳正文", "pregnancy_history_desc": "妊娠与哺乳说明",
"current_medicine": "当前用药", "current_medication": "当前用药",
"prescription": "处方", "prescription_opinion": "处方意见", "prescription_advice": "处方建议",
"treatment_principle": "治则", "doctor_advice": "医嘱", "prescription_date": "开方日期",
"issues": "需核对问题", "formulation": "剂型", "items": "药项", "bases": "剂量基准",
"merges": "重复药项合并", "defaults": "默认值记录", "identity_complete": "药项身份核验完整",
"raw_herb_count": "原始药项数", "source_rows": "原始药项序号", "source_names": "原始药名",
"original_dosages": "原始剂量", "usage": "煎服说明", "doctor": "医生方", "candidate": "候选方",
"dictionary_versions": "药材字典版本", "unit_policy": "单位处理规则", "denominator": "一致度计算分母",
"matched_contribution_sum": "共同药项贡献合计", "special_usage": "特殊用法",
"decoction_instruction": "煎药说明", "usage_note": "服法备注", "before": "处理前", "after": "处理后",
"dosage_amount": "每次用量", "dosage_unit": "每次用量单位", "dosage_bag_count": "每次袋数", "aux_usage": "辅助用法",
"score": "药味剂量一致度", "herb_score": "纯药味重合度", "model": "模型",
"schema_version": "资料格式版本", "decision_at": "处方决策时间", "created_at": "建立时间", "updated_at": "更新时间",
"source_diagnosis_ids": "来源诊单编号", "redaction_manifest": "处方内容隔离清单",
"total_count": "开方事件数", "total_events": "开方事件数", "patient_count": "患者数",
"eligible_count": "有效比较数", "valid_count": "有效比较数", "excluded_count": "排除样本数",
"coverage_rate": "覆盖率", "coverage_percent": "覆盖率", "paired_count": "双模型共同有效样本数",
"excluded_reasons": "排除原因及数量", "exclusion_reasons": "排除原因及数量", "exclusion_reason": "排除原因",
"aggregation_status": "统计汇总方式", "paired_strata": "双模型版本分层", "models": "模型统计",
"review": "专家复核", "reviews": "专家复核", "evaluated_count": "可评价样本数", "evaluable_count": "可评价样本数",
"qualified_count": "合格样本数", "qualified_rate": "合格率", "qualification_rate": "合格率",
"reviewed_events": "已复核事件数", "unreviewed_events": "未复核事件数", "sampling_method": "抽样方式",
"sampling_groups": "抽样分组", "sampling_coverage_percent": "抽样覆盖率", "outcomes": "复核结论分布",
"outcome": "复核结论", "independent": "独立复核", "disputed": "存在争议",
"confidence_interval": "置信区间", "confidence_interval_reason": "置信区间说明",
"unknown_patient_events": "患者身份未确认事件数", "repeated_patient_events": "重复患者事件数",
"invalid_row_count": "无效记录数", "metric": "统计指标",
}
ENUM_FIELDS = {
"status", "comparison_status", "coverage_status", "comparison_type", "validity", "dose_basis", "bases",
"match_type", "match", "side", "level", "kind", "source_kind", "type", "purpose", "transfer_method",
"sample_status", "aggregation_status", "history_versioning", "sampling_method", "outcome", "unit_policy",
"model_key", "model", "unit", "formula_type",
}
REASON_FIELDS = {
"reason", "reason_message", "reason_code", "error_code", "error_message", "code", "message",
"missing", "missing_information", "baseline_exclusion_reasons", "excluded_reasons", "exclusion_reasons", "exclusion_reason",
}
SOURCE_FIELDS = {
"source", "source_id", "source_ids", "file_id", "file_ids", "evidence_file_ids", "covered_source_ids",
"evidence_references", "redaction_manifest",
}
_MACHINE_KEY = re.compile(r"[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+\Z")
_UPPER_CODE = re.compile(r"(?<![A-Za-z0-9_])[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?![A-Za-z0-9_])")
_SOURCE_ID = re.compile(r"([a-z][a-z0-9_]*)[:]([^\s,;;:<>]+)\Z")
_KNOWN_SOURCE_IN_TEXT = re.compile(r"(?<![A-Za-z0-9_])(" + "|".join(SOURCE_LABELS) + r")[:]([A-Za-z0-9]+)(?![A-Za-z0-9_])")
def plain_text(value: Any) -> str:
if value is None or value == "":
return ""
if isinstance(value, bool):
return "" if value else ""
return str(value)
def source_text(value: Any, fields: Mapping[str, str]) -> str:
"""Localize a source prefix, preserving its entire numeric or opaque ID."""
text = plain_text(value)
if text in SOURCE_LABELS:
return SOURCE_LABELS[text]
if text.startswith("clinical."):
field = text.removeprefix("clinical.")
return "临床资料 · " + fields.get(field, "待核对项目")
match = _SOURCE_ID.fullmatch(text)
if match:
prefix, identifier = match.groups()
# Redaction manifests append a field name after the numeric source ID.
if ":" in identifier:
identifier, field = identifier.split(":", 1)
return f"{SOURCE_LABELS.get(prefix, '其他来源')}(编号:{identifier} · {fields.get(field, '待核对字段')}"
return f"{SOURCE_LABELS.get(prefix, '其他来源')}(编号:{identifier}"
if _MACHINE_KEY.fullmatch(text) or re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", text):
return "来源类型待核对"
return _KNOWN_SOURCE_IN_TEXT.sub(lambda match: source_text(f"{match[1]}:{match[2]}", fields), text)
def system_text(value: Any, labels: Mapping[str, str], fields: Mapping[str, str], *, strict: bool = False) -> str:
"""Translate metadata, including historical ``CODE: source:id`` gap strings."""
text = plain_text(value)
if "\n" in text:
return "\n".join(system_text(line, labels, fields, strict=strict) for line in text.split("\n"))
if text in labels:
return labels[text]
if text in SOURCE_LABELS:
return SOURCE_LABELS[text]
if text in fields:
return fields[text]
# Only a technical prefix permits parsing the rest as a source identifier.
compound = re.fullmatch(r"([A-Za-z][A-Za-z0-9_]+)\s*[:]\s*(.*)", text, re.DOTALL)
if compound and (compound[1] in labels or _MACHINE_KEY.fullmatch(compound[1])):
prefix = labels.get(compound[1], "未识别的系统原因,请联系管理员核对")
return prefix + "" + source_text(compound[2], fields)
if _MACHINE_KEY.fullmatch(text):
return "未识别的系统标识,请联系管理员核对"
# Historic reports can include a known source/code inside a Chinese gap explanation.
text = _UPPER_CODE.sub(lambda match: labels.get(match[0], "未识别的系统原因,请联系管理员核对"), text)
text = _KNOWN_SOURCE_IN_TEXT.sub(lambda match: source_text(f"{match[1]}:{match[2]}", fields), text)
if strict and re.fullmatch(r"[A-Za-z][A-Za-z0-9 ._-]*", text):
return "未识别的系统状态,请联系管理员核对"
return text
def field_text(key: Any, fields: Mapping[str, str], labels: Mapping[str, str]) -> str:
text = str(key)
if text in fields:
return fields[text]
if text.endswith("_count") and text.removesuffix("_count") in SOURCE_LABELS:
return SOURCE_LABELS[text.removesuffix("_count")] + ""
translated = system_text(text, labels, fields)
if translated != text:
return translated
if text in SOURCE_LABELS or text.startswith("clinical.") or _SOURCE_ID.fullmatch(text):
return source_text(text, fields)
return "其他字段(待核对)" if re.search(r"[A-Za-z]", text) and not re.search(r"[\u4e00-\u9fff]", text) else text
def value_text(value: Any, field: str, labels: Mapping[str, str], fields: Mapping[str, str]) -> str:
if field in SOURCE_FIELDS:
return source_text(value, fields)
if field == "field":
return field_text(value, fields, labels)
if field == "coverage_status":
return {"partial": "资料不全", "pending": "资料待核对", "unavailable": "暂无覆盖信息"}.get(str(value)) or system_text(value, labels, fields, strict=True)
if field == "history_versioning" and value == "unavailable":
return "历史版本无法核验"
if field == "formula_type" and value == "auxiliary":
return "辅方"
if field.endswith("version") or field.endswith("versions"):
text = plain_text(value)
for prefix, name in (
("manual-prescription-independent-v", "手动处方独立分析"),
("manual-prescription-available-evidence-v", "手动处方已读资料分析"),
("prescription-soft-dice-v", "处方药味剂量一致度算法"),
("prescription-evidence-v", "处方证据资料格式"),
("prescription-source-access-v", "处方来源权限格式"),
):
if text.startswith(prefix) and re.fullmatch(r"\d+(?:\.\d+)*", text.removeprefix(prefix)):
return f"{name} · 第 {text.removeprefix(prefix)}"
return text
if field in ENUM_FIELDS:
return system_text(value, labels, fields, strict=True)
if field in REASON_FIELDS:
return system_text(value, labels, fields, strict=field in {"code", "error_code", "reason_code"})
if field.endswith(("_status", "_state", "_code")):
return system_text(value, labels, fields, strict=True)
if field and field not in fields:
return system_text(value, labels, fields)
return plain_text(value)
@@ -0,0 +1,135 @@
"""Honest presentation of server checkpoints, without fabricated totals or ETA."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
ACTIVE_STATES = {"pending", "preparing", "queued", "waiting", "waiting_sources", "waiting_transcript", "waiting_transcription", "running", "processing", "retrying", "retry_wait"}
SUCCESS_STATES = {"succeeded", "completed", "success"}
TERMINAL_STATES = SUCCESS_STATES | {"partial", "failed", "cancelled", "canceled", "blocked", "stale", "superseded", "invalid", "revoked", "deleted", "voided"}
STAGES = {
"preparing": "准备资料", "waiting_sources": "等待转写与资料", "queued": "排队等待处理",
"text": "分析文字资料", "files": "分析附件", "reduce": "汇总资料要点",
"final": "生成完整报告", "validating": "校验报告", "comparing": "计算用药对照",
"completed": "已完成", "retry_wait": "等待重试", "failed": "处理失败", "cancelled": "已取消",
"unknown": "等待阶段详情",
}
COMPACT_STAGES = {"preparing": "准备资料", "waiting_sources": "等待资料", "queued": "排队中", "text": "文字",
"files": "附件", "reduce": "汇总", "final": "生成报告", "validating": "校验", "comparing": "用药对照",
"completed": "已完成", "retry_wait": "待重试", "failed": "失败", "cancelled": "已取消", "unknown": "处理中"}
STATUS_STAGE = {"pending": "queued", "waiting": "waiting_sources", "waiting_transcript": "waiting_sources",
"waiting_transcription": "waiting_sources", "retrying": "retry_wait",
**{state: "completed" for state in SUCCESS_STATES}, "canceled": "cancelled", "partial": "completed", "blocked": "failed"}
def _mapping(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {}
def _integer(value: Any) -> int | None:
# Counts/timestamps are integer contract fields, not arbitrary numeric text.
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
def duration(seconds: int) -> str:
seconds = max(0, seconds)
if seconds < 60:
return f"{seconds}"
if seconds < 3600:
return f"{seconds // 60}{seconds % 60:02d}"
return f"{seconds // 3600} 小时 {seconds % 3600 // 60:02d}"
@dataclass(frozen=True)
class ProgressView:
stage: str
headline: str
detail: str
completed: int | None = None
total: int | None = None
busy: bool = False
def progress_view(owner: Any, *, fallback_status: str = "", seconds: int = 0, live: bool = True) -> ProgressView:
data = _mapping(owner)
progress = _mapping(data.get("progress"))
status = str(data.get("status") or fallback_status)
stage = str(progress.get("stage") or STATUS_STAGE.get(status, status))
if stage not in STAGES:
stage = "unknown"
terminal = status in TERMINAL_STATES
if terminal:
stage = STATUS_STAGE.get(status, status)
if stage not in STAGES:
stage = "unknown"
active = status in ACTIVE_STATES and not terminal and stage not in {"completed", "failed", "cancelled"}
advance = max(0, seconds) if active else 0
headline = STAGES[stage]
if status == "partial":
headline = "部分模型已完成"
if stage == "unknown":
headline = "正在处理,等待阶段详情" if active else "暂无处理进度"
completed = _integer(progress.get("completed_units"))
total = _integer(progress.get("total_units"))
if stage not in {"text", "files", "reduce"} or total is None or not 0 < total <= 1_000_000 or completed is None or completed > total:
completed = total = None
if total is not None:
headline += f" · 本阶段 {completed}/{total}"
details = []
attempt = _integer(progress.get("attempt"))
if attempt is not None and 0 < attempt <= 1_000_000:
details.append(f"{attempt} 次尝试")
elapsed = _integer(progress.get("elapsed_seconds"))
stage_elapsed = _integer(progress.get("stage_elapsed_seconds"))
if elapsed is not None:
# Retry metadata freezes the previous attempt's duration. Only its
# scheduling countdown and update age continue between server polls.
details.append("已用时 " + duration(elapsed + (0 if stage == "retry_wait" else advance)))
if stage_elapsed is not None and active and stage != "retry_wait":
details.append("本阶段 " + duration(stage_elapsed + advance))
remaining = _integer(progress.get("wait_remaining_seconds"))
if stage in {"preparing", "waiting_sources", "retry_wait"} and remaining is not None and active:
remaining = max(0, remaining - advance)
details.append("资料等待窗口剩余 " + duration(remaining) if remaining else "等待窗口已到,等待服务端确认")
if stage == "retry_wait":
details[-1] = "距下次重试 " + duration(remaining) if remaining else "重试时间已到,等待服务端确认"
server_time = _integer(progress.get("server_time"))
updated_at = _integer(progress.get("updated_at"))
if active and server_time is not None and updated_at is not None and updated_at > 0:
details.append("阶段更新于 " + duration(max(0, server_time - updated_at) + advance) + "")
if not progress and active:
details.append("服务端暂未提供分阶段进度")
notice = progress.get("notice")
terminal_notice = stage in {"failed", "cancelled"} and progress.get("stage") == stage and progress.get("phase") == "failed"
if isinstance(notice, str) and notice.strip() and (not terminal or terminal_notice):
details.append(notice.strip())
elif stage in {"final", "text", "files", "reduce"} and active:
details.append("等待模型响应;耗时取决于资料量与模型服务")
return ProgressView(stage, headline, " · ".join(details), completed, total, live and active)
def flow_text(batch: Any) -> str:
data = _mapping(batch)
status = data.get("status")
if not data:
return "准备资料 → 双模型分析 → 用药对照 → 完成"
if data.get("validity") not in (None, "", "current", "valid"):
return "此批次已失效 · 以下为最后保存的处理记录"
models = [_mapping(_mapping(data.get("models")).get(key)) for key in ("qwen", "openai")]
complete = sum(model.get("status") in SUCCESS_STATES for model in models)
failed = sum(model.get("status") == "failed" for model in models)
stages = [progress_view(model).stage for model in models]
batch_stage = progress_view(data).stage
if status in {"failed", "cancelled", "canceled", "blocked"}:
return f"处理已停止 · {complete}/2 个模型完成" + (f" · {failed} 个模型失败" if failed else "")
if batch_stage in {"preparing", "waiting_sources"}:
return "准备资料:进行中 → 双模型分析:待开始 → 用药对照:待开始 → 完成:待处理"
prepared = "已完成" if any(models) else "待确认"
comparisons = sum(model.get("status") in SUCCESS_STATES and bool(model.get("comparison")) for model in models)
compare_state = f"{comparisons}/2 已处理" if comparisons else "进行中" if "comparing" in stages else "待处理"
analysis = f"{complete}/2 完成" + (f"{failed} 个失败" if failed else ",进行中" if status not in TERMINAL_STATES else "")
end = "已完成" if complete == 2 else "部分完成" if status == "partial" else "待处理"
return f"准备资料:{prepared} → 双模型分析:{analysis} → 用药对照:{compare_state} → 完成:{end}"
@@ -20,6 +20,7 @@ from datetime import date
from pathlib import Path
from time import monotonic
from typing import Any
from uuid import uuid4
from PySide6.QtCore import (
QBuffer,
@@ -2658,6 +2659,8 @@ class PrescriptionEditorDialog(QDialog):
else None
)
self._source = _mapping(prescription)
self._save_fingerprint = ""
self._save_request_key = ""
self._loading_data = False
self._linked_order_generation = 0
self._diagnosis_view: _StructuredDiagnosisDialog | None = None
@@ -3786,6 +3789,7 @@ class PrescriptionEditorDialog(QDialog):
"audit_remark",
"business_prescription_audit_rejected",
"business_prescription_audit_remark",
"ai_assisted",
)
result = {key: self._source.get(key) for key in hidden_keys if key in self._source}
if self.mode == "add":
@@ -3838,6 +3842,20 @@ class PrescriptionEditorDialog(QDialog):
result["dosage_amount"] = dosage_amount
else:
result.pop("dosage_amount", None)
# This editor has no independent-assistance attestation control. Only
# retain positive evidence of AI exposure; a seed's false is not a new
# doctor-confirmed statement that this submission was unassisted.
if result.get("ai_assisted") in (True, 1, "1", "true"):
result["ai_assisted"] = True
else:
result.pop("ai_assisted", None)
# A repeated submit of unchanged editor content keeps its idempotency key.
# Unknown AI exposure is omitted, rather than falsely asserted as False.
fingerprint = json.dumps(result, ensure_ascii=False, sort_keys=True, default=str)
if fingerprint != self._save_fingerprint:
self._save_fingerprint = fingerprint
self._save_request_key = str(uuid4())
result["request_key"] = self._save_request_key
return result
def _herb_validation_label(self, global_index: int) -> str:
@@ -8,7 +8,7 @@ from html import escape
from math import ceil
from typing import Any
from PySide6.QtCore import QDateTime, QModelIndex, QRectF, QSize, Qt
from PySide6.QtCore import QDateTime, QEvent, QModelIndex, QRectF, QSize, Qt, QTimer
from PySide6.QtGui import (
QColor,
QFont,
@@ -33,7 +33,8 @@ from PySide6.QtWidgets import (
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QListWidgetItem,
QMenu,
QMessageBox,
QPushButton,
QScrollArea,
@@ -48,7 +49,16 @@ from PySide6.QtWidgets import (
QWidget,
)
from .. import icons, motion
from .. import icons, motion
from ..dialogs.issued_prescription_ai import (
PrescriptionAiStatisticsDialog,
agreement_text,
batch_running,
can_open_issued_ai,
present_issued_prescription_ai,
state_text,
status_tooltip,
)
from ..dialogs.prescription import (
AuditPrescriptionDialog,
DiagnosisDetailDialog,
@@ -877,7 +887,7 @@ class _PrescriptionInfoDelegate(QStyledItemDelegate):
painter.setPen(QColor(foreground))
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
elif column != 2:
lines = text.rsplit(" · ", 2) if column == 5 else text.rsplit(" ", 1) if column == 10 else [text]
lines = text.rsplit(" · ", 2) if column == 5 else text.rsplit(" ", 1) if column == 10 else text.splitlines() if column == 12 else [text]
if len(lines) > 1:
primary, secondary = lines[0], " · ".join(lines[1:]) if column == 5 else lines[1]
if column == 5:
@@ -979,7 +989,18 @@ class PrescriptionsPage(QWidget):
self._detail_target = 0
self._diagnosis_detail_generation = 0
self._diagnosis_detail_target = 0
self._mutation_pending = False
self._mutation_pending = False
self._ai_statuses: dict[int, dict[str, Any]] = {}
self._ai_enabled = False
self._ai_request_token = 0
self._ai_pending = False
self._ai_timer = QTimer(self)
self._ai_timer.setInterval(5000)
self._ai_timer.timeout.connect(self._load_ai_statuses)
self._ai_scroll_timer = QTimer(self)
self._ai_scroll_timer.setSingleShot(True)
self._ai_scroll_timer.setInterval(180)
self._ai_scroll_timer.timeout.connect(self._load_ai_statuses)
outer = QVBoxLayout(self)
outer.setContentsMargins(28, 16, 26, 8)
@@ -1010,7 +1031,11 @@ class PrescriptionsPage(QWidget):
self.orders_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.orders_button.setVisible(has_permission(permissions, "tcm.prescriptionOrder/lists"))
self.orders_button.clicked.connect(lambda: self._open_orders())
header.add_action(self.orders_button)
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.clicked.connect(self._open_ai_statistics)
header.add_action(self.ai_statistics_button)
self.add_button = QPushButton("新增处方", header)
self.add_button.setObjectName("PrescriptionAddButton")
self.add_button.setMinimumWidth(122)
@@ -1156,7 +1181,10 @@ class PrescriptionsPage(QWidget):
toolbar.addWidget(self.count_badge)
toolbar.addStretch(1)
self.view_button = self._action_button("查看", "cf.prescription/read", self._view_selected)
toolbar.addWidget(self.view_button)
toolbar.addWidget(self.view_button)
self.ai_report_button = self._action_button("AI 报告", "tcm.prescriptionAi/reports", self._open_ai_report)
self.ai_report_button.setVisible(can_open_issued_ai(self.permissions) and callable(getattr(self.repository, "list_prescription_ai_reports", None)))
toolbar.addWidget(self.ai_report_button)
self.patch_button = self._action_button(
"修改患者", "tcm.prescription/patchPatient", self._patch_selected
)
@@ -1181,9 +1209,17 @@ class PrescriptionsPage(QWidget):
refresh.setIcon(_blue_prescription_icon("refresh", "#5D6B80"))
refresh.setIconSize(QSize(15, 15))
refresh.clicked.connect(self.refresh)
toolbar.addWidget(refresh)
layout.addWidget(toolbar_scroll)
self.stack = QStackedWidget()
toolbar.addWidget(refresh)
layout.addWidget(toolbar_scroll)
self.ai_status_notice = QLabel("AI 分析:正在检查服务状态。", card)
self.ai_status_notice.setObjectName("PrescriptionAiStatusNotice")
self.ai_status_notice.setProperty("role", "muted")
self.ai_status_notice.setTextFormat(Qt.TextFormat.PlainText)
self.ai_status_notice.setWordWrap(True)
self.ai_status_notice.setMargin(12)
self.ai_status_notice.setAccessibleName("AI 分析状态")
layout.addWidget(self.ai_status_notice)
self.stack = QStackedWidget()
table_host = QWidget()
table_layout = QVBoxLayout(table_host)
table_layout.setContentsMargins(0, 0, 0, 0)
@@ -1200,7 +1236,9 @@ class PrescriptionsPage(QWidget):
TableColumn("void_status", "作废", 72, _void_cell),
TableColumn("doctor_name", "医生信息", 180, _doctor_cell),
TableColumn("assistant_name", "医助", 125),
TableColumn("create_time", "创建时间", 180, _create_time_cell),
TableColumn("create_time", "创建时间", 180, _create_time_cell),
TableColumn("__ai_status__", "AI 分析", 144, lambda _value, _row: ""),
TableColumn("__ai_agreement__", "与 AI 一致度", 130, lambda _value, _row: ""),
]
)
self.table.setObjectName("PrescriptionTable")
@@ -1213,8 +1251,13 @@ class PrescriptionsPage(QWidget):
self.table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
self.table.horizontalHeader().moveSection(2, 10)
for column, width in enumerate((42, 192, 144, 108, 94, 144, 108, 80, 137, 83, 128)):
self.table.setColumnWidth(column, width)
for column, width in enumerate((42, 192, 144, 108, 94, 144, 108, 80, 137, 83, 128)):
self.table.setColumnWidth(column, width)
self.table.setColumnHidden(11, True)
self.table.setColumnHidden(12, True)
self.table.horizontalHeaderItem(12).setToolTip("分别显示千问、OpenAI 的药味与剂量一致度;点击查看逐味贡献。")
self.table.horizontalHeader().viewport().installEventFilter(self)
self.table.verticalScrollBar().valueChanged.connect(lambda _value: self._schedule_ai_statuses())
self.table.setWordWrap(False)
# Rows are not uniform: the number column's delegate grows a row that
# carries an order warning so the warning text stays readable without a
@@ -1227,7 +1270,10 @@ class PrescriptionsPage(QWidget):
self.table.horizontalHeaderItem(0).setIcon(_blue_prescription_icon("checkbox", "#8A97A9", 14))
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
self.table.itemClicked.connect(lambda item: self._open_ai_report() if item.column() in {11, 12} else None)
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.table.customContextMenuRequested.connect(self._open_ai_context_menu)
table_layout.addWidget(self.table, 1)
self.pager = InfiniteList(self._page_size)
self.pager.bind(self.table)
@@ -1271,9 +1317,22 @@ class PrescriptionsPage(QWidget):
self.filter_grid.setColumnStretch(column, stretch)
self.filter_card.setFixedHeight(200 if compact else 144)
def _number_column_resized(self, column: int, _old_size: int, _new_size: int) -> None:
def _number_column_resized(self, column: int, _old_size: int, _new_size: int) -> None:
if column == 1:
self.table.resizeRowsToContents()
self.table.resizeRowsToContents()
def eventFilter(self, watched: Any, event: Any) -> bool:
if hasattr(self, "table") and watched is self.table.horizontalHeader().viewport() and event.type() in {QEvent.Type.MouseButtonPress, QEvent.Type.MouseButtonRelease, QEvent.Type.MouseButtonDblClick}:
header = self.table.horizontalHeader()
position = event.position().toPoint()
column = header.logicalIndexAt(position)
if column in {11, 12} and event.button() == Qt.MouseButton.LeftButton:
relative = position.x() - header.sectionViewportPosition(column)
# Keep native column resizing, but never rank mixed-model display
# text or treat unavailable values as numeric zero through sorting.
if 5 < relative < header.sectionSize(column) - 5:
return True
return super().eventFilter(watched, event)
def _action_button(
self,
@@ -1354,7 +1413,11 @@ class PrescriptionsPage(QWidget):
self.doctor_filter.clear()
self._search()
def refresh(self) -> None:
def refresh(self) -> None:
self._ai_timer.stop()
self._ai_request_token += 1
self._ai_pending = False
self._ai_statuses.clear()
self._loading = True
self._refresh_pending = False
self._generation += 1
@@ -1392,8 +1455,9 @@ class PrescriptionsPage(QWidget):
if generation != self._generation:
return
rows = page_items(result)
self.table.set_rows(rows)
self._decorate_rows()
self.table.set_rows(rows)
self._decorate_rows()
self._render_ai_statuses()
total = page_total(result, len(rows))
self.pager.update_state(requested_page, total)
self.count_badge.setText(f"{total}")
@@ -1415,7 +1479,8 @@ class PrescriptionsPage(QWidget):
self.banner.clear()
if rows and self.table.currentRow() < 0:
self.table.selectRow(0)
self._selection_changed()
self._selection_changed()
self._load_ai_statuses()
def _decorate_rows(self) -> None:
"""Apply the reference table's tags, checkbox, avatar, and row actions."""
@@ -1489,7 +1554,7 @@ class PrescriptionsPage(QWidget):
actions.setContentsMargins(3, 0, 3, 0)
actions.setSpacing(3)
actions.addStretch(1)
if has_permission(self.permissions, "cf.prescription/read"):
if has_permission(self.permissions, "cf.prescription/read"):
actions.addWidget(
_row_action_button(
"eye",
@@ -1499,7 +1564,15 @@ class PrescriptionsPage(QWidget):
),
actions_host,
)
)
)
if self._ai_enabled and can_open_issued_ai(self.permissions):
actions.addWidget(
_row_action_button(
"eye", "AI 报告",
lambda _checked=False, target=row: self._run_row_action(target, self._open_ai_report),
actions_host, label="AI",
)
)
if has_permission(self.permissions, "cf.prescription/edit"):
actions.addWidget(
_row_action_button(
@@ -1531,7 +1604,162 @@ class PrescriptionsPage(QWidget):
actions.addStretch(1)
self.table.setCellWidget(row_index, 2, actions_host)
self._sync_row_mutation_actions()
self.table.resizeRowsToContents()
self.table.resizeRowsToContents()
def _visible_ai_ids(self) -> list[int]:
if not self.isVisible() or not self.table.isVisible():
return []
ids = []
viewport = self.table.viewport().rect()
for index in range(self.table.rowCount()):
item = self.table.item(index, 0)
# Column 0 may be horizontally off-screen; use row geometry only.
top = self.table.rowViewportPosition(index)
if item is None or top + self.table.rowHeight(index) <= 0 or top >= viewport.height():
continue
row = item.data(Qt.ItemDataRole.UserRole)
value = _int(first_value(row, "id", "prescription_id"), 0)
if value > 0:
ids.append(value)
return list(dict.fromkeys(ids))[:100]
def _schedule_ai_statuses(self) -> None:
if self.isVisible():
self._ai_scroll_timer.start()
def _load_ai_statuses(self) -> None:
method = getattr(self.repository, "list_prescription_ai_statuses", None)
if not has_permission(self.permissions, "tcm.prescriptionAi/statuses", default=False):
self.ai_status_notice.setText("AI 分析:当前账号没有查看分析状态的权限,请联系管理员授权。")
return
if not callable(method):
self.ai_status_notice.setText("AI 分析:当前数据模式暂不支持此功能。")
return
if self._ai_pending:
return
ids = self._visible_ai_ids()
ids = [value for value in ids if value not in self._ai_statuses or batch_running(self._ai_statuses[value])]
if not ids:
self._ai_timer.stop()
if self.table.rowCount() == 0:
self.ai_status_notice.setText("AI 分析:当前列表没有处方,暂无可查看的分析结果。")
return
self._ai_pending = True
self._ai_request_token += 1
token, generation = self._ai_request_token, self._generation
run_async(
lambda: method(ids),
on_success=lambda result: self._ai_statuses_ready(result, ids, token, generation),
on_error=lambda error: self._ai_statuses_error(error, token, generation),
on_finished=lambda: self._ai_statuses_finished(token),
)
def _ai_statuses_finished(self, token: int) -> None:
if token == self._ai_request_token:
self._ai_pending = False
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)
if enabled:
header = self.table.horizontalHeader()
header.moveSection(header.visualIndex(11), header.visualIndex(4) + 1)
header.moveSection(header.visualIndex(12), header.visualIndex(11) + 1)
self.table.setColumnWidth(11, 144)
self.table.setColumnWidth(12, 130)
self.table.setColumnWidth(2, 178)
if changed:
self._decorate_rows()
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_ai_columns(data.get("enabled") is True)
if not self._ai_enabled:
self._ai_timer.stop()
notice = "AI 分析未启用:暂不生成报告或一致度,请联系管理员启用。"
if can_open_issued_ai(self.permissions):
notice += "已有报告仍可从“AI 报告”查看。"
self.ai_status_notice.setText(notice)
self.ai_status_notice.setToolTip("")
self.ai_report_button.setToolTip("自动分析未启用;仍可查看已保存的历史报告。")
return
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告,并显示药味与剂量一致度。")
self.ai_status_notice.setToolTip("")
self.ai_report_button.setToolTip("查看两份 AI 报告、候选处方和逐味对照。")
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._render_ai_statuses()
if any(batch_running(self._ai_statuses.get(value)) for value in self._visible_ai_ids()):
self._ai_timer.start()
else:
self._ai_timer.stop()
def _render_ai_statuses(self) -> None:
sorting = self.table.isSortingEnabled()
self.table.setSortingEnabled(False)
changed = False
try:
for index in range(self.table.rowCount()):
row = self.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
value = _int(first_value(row, "id", "prescription_id"), 0)
batch = self._ai_statuses.get(value)
if batch is None:
continue
state = state_text(batch) if batch else ("尚未开方" if _truthy(get_value(row, "is_system_auto")) else "尚无分析记录")
for column, text in ((11, state), (12, agreement_text(batch))):
item = self.table.item(index, column)
if item.text() != text:
item.setText(text)
changed = True
item.setToolTip(status_tooltip(batch))
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
if changed:
self.table.resizeRowsToContents()
finally:
self.table.setSortingEnabled(sorting)
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_ai_columns(False)
self.ai_status_notice.setText("AI 分析暂不可用:请稍后刷新;持续无法使用时,请联系管理员检查服务。")
self.ai_status_notice.setToolTip(friendly_error(error))
self.ai_report_button.setToolTip("AI 状态暂不可用:" + friendly_error(error))
def _open_ai_report(self) -> None:
row = self._selected()
value = _int(first_value(row, "id", "prescription_id"), 0)
if value > 0:
present_issued_prescription_ai(self.repository, self.permissions, self, prescription_id=value)
def _open_ai_context_menu(self, position: Any) -> None:
if not can_open_issued_ai(self.permissions) or not callable(getattr(self.repository, "list_prescription_ai_reports", None)):
return
item = self.table.itemAt(position)
if item is None:
return
self.table.selectRow(item.row())
row = self._selected()
menu = QMenu(self.table)
action = menu.addAction("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):
return
dialog = PrescriptionAiStatisticsDialog(self.repository, self.permissions, self)
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
dialog.show()
def _run_row_action(self, row: Any, callback: Callable[[], None]) -> None:
target_id = _int(first_value(row, "id", "prescription_id", default=None), 0)
@@ -1566,7 +1794,8 @@ class PrescriptionsPage(QWidget):
def _selection_changed(self) -> None:
row = self.table.current_data()
active = not self._mutation_pending
self.view_button.setEnabled(active and row is not None)
self.view_button.setEnabled(active and row is not None)
self.ai_report_button.setEnabled(row is not None)
self.patch_button.setEnabled(active and can_patch_patient(row))
self.create_order_button.setEnabled(active and can_create_order(row))
self.edit_button.setEnabled(active and can_edit_or_delete(row))
@@ -1935,10 +2164,19 @@ class PrescriptionsPage(QWidget):
self.banner.show_message(message, "danger")
show_toast(self, message, "danger", 5000)
def showEvent(self, event: Any) -> None:
def showEvent(self, event: Any) -> None:
super().showEvent(event)
if self.table.rowCount() == 0 and not self._loading:
self.refresh()
if self.table.rowCount() == 0 and not self._loading:
self.refresh()
elif not self._loading:
self._load_ai_statuses()
def hideEvent(self, event: Any) -> None:
self._ai_timer.stop()
self._ai_scroll_timer.stop()
self._ai_request_token += 1
self._ai_pending = False
super().hideEvent(event)
__all__ = [
+7
View File
@@ -127,6 +127,13 @@ def format_record_time(value: Any, default: str = "—") -> str:
raw = str(value).strip()
if not raw:
return default
# The API sends 0 for "not set yet" (a snapshot cutoff that has not been frozen, an
# unfinished task); rendering it as a bare 0 or as 1970 would read as a real time.
try:
if float(raw) <= 0:
return default
except ValueError:
pass
if _UNIX_TIMESTAMP_RE.fullmatch(raw):
stamp = float(raw)
if stamp >= 10_000_000_000:
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -303,7 +303,8 @@ def test_issued_page_sends_exact_filter_dto_and_row_guards(
page.table.selectRow(0)
page._selection_changed()
assert page.table.columnCount() == 11
assert page.table.columnCount() == 13
assert page.table.isColumnHidden(11) and page.table.isColumnHidden(12)
assert page.table.horizontalHeaderItem(2).text() == "操作"
assert page.table.cellWidget(0, 2) is not None
# 处方类型与审核状态由 _RowDecorationDelegate 绘制标签,不再为每行每列
+2 -1
View File
@@ -121,7 +121,8 @@ def test_shell_preserves_all_columns_filters_and_reachable_pager(
table = page.table
assert [column.key for column in table.columns] == [
"__selected__", "sn", "__actions__", "prescription_type", "is_system_auto",
"patient_name", "audit_status", "void_status", "doctor_name", "assistant_name", "create_time",
"patient_name", "audit_status", "void_status", "doctor_name", "assistant_name", "create_time",
"__ai_status__", "__ai_agreement__",
]
assert [table.horizontalHeader().logicalIndex(index) for index in range(11)] == [
0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 2,
+9
View File
@@ -46,6 +46,15 @@ def test_format_record_time_with_datetime_returns_minute_precision() -> None:
assert format_record_time(date(2026, 8, 20)) == "2026-08-20"
def test_format_record_time_with_unset_epoch_returns_default() -> None:
# The API sends 0 (and occasionally "0") for a snapshot cutoff or finish time that does
# not exist yet; it must not be rendered as a bare 0 or as 1970.
assert format_record_time(0) == ""
assert format_record_time("0") == ""
assert format_record_time(-1) == ""
assert format_record_time(0, default="未开始") == "未开始"
def test_format_record_time_with_garbage_returns_raw_value() -> None:
assert format_record_time("not-a-timestamp") == "not-a-timestamp"