更新
This commit is contained in:
@@ -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__ = [
|
||||
|
||||
@@ -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
@@ -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 绘制标签,不再为每行每列
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user